From e9e6c40399cec34b6db5b76c4d9af811f95dcfa7 Mon Sep 17 00:00:00 2001 From: cwebster-99 Date: Tue, 16 Jun 2026 09:45:10 -0500 Subject: [PATCH 1/2] feat: add Brick Breaker game - Add BrickBreakerGame component with canvas-based rendering - Paddle, ball, brick grid with collision detection - 3 lives, scoring, win/lose conditions - Register route in App.tsx and nav card in Home.tsx Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/App.tsx | 22 +- src/components/BrickBreakerGame.tsx | 587 ++++++++++++++++++++++++++++ src/components/Home.tsx | 40 +- 3 files changed, 647 insertions(+), 2 deletions(-) create mode 100644 src/components/BrickBreakerGame.tsx diff --git a/src/App.tsx b/src/App.tsx index 03ce990..b313b5f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import Game2048 from "./components/Game2048"; import TetrisGame from "./components/TetrisGame"; import FlappyGame from "./components/FlappyGame"; import MinesweeperGame from "./components/MinesweeperGame"; +import BrickBreakerGame from "./components/BrickBreakerGame"; interface GameLayoutProps { title: string; @@ -72,7 +73,13 @@ function App() { }); const [view, setView] = useState< - "home" | "snake" | "2048" | "tetris" | "flappy" | "minesweeper" + | "home" + | "snake" + | "2048" + | "tetris" + | "flappy" + | "minesweeper" + | "brickbreaker" >("home"); useEffect(() => { @@ -109,6 +116,7 @@ function App() { onPlayTetris={() => setView("tetris")} onPlayFlappy={() => setView("flappy")} onPlayMinesweeper={() => setView("minesweeper")} + onPlayBrickBreaker={() => setView("brickbreaker")} /> )} @@ -168,6 +176,18 @@ function App() { )} + + {view === "brickbreaker" && ( + setView("home")} + onThemeChange={handleThemeChange} + theme={theme} + bgColor="bg-black" + > + + + )} ); } diff --git a/src/components/BrickBreakerGame.tsx b/src/components/BrickBreakerGame.tsx new file mode 100644 index 0000000..e1c5076 --- /dev/null +++ b/src/components/BrickBreakerGame.tsx @@ -0,0 +1,587 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +// ── Constants ───────────────────────────────────────────────────────────────── +const CANVAS_WIDTH = 480; +const CANVAS_HEIGHT = 520; +const PADDLE_WIDTH = 80; +const PADDLE_HEIGHT = 12; +const PADDLE_SPEED = 7; +const PADDLE_Y = CANVAS_HEIGHT - 40; +const BALL_RADIUS = 8; +const BALL_SPEED_INITIAL = 5; +const BRICK_ROWS = 5; +const BRICK_COLS = 9; +const BRICK_PADDING = 5; +const BRICK_LEFT_OFFSET = 15; +const BRICK_TOP_OFFSET = 60; +const BRICK_HEIGHT = 20; +const BRICK_WIDTH = + (CANVAS_WIDTH - BRICK_LEFT_OFFSET * 2 - BRICK_PADDING * (BRICK_COLS - 1)) / + BRICK_COLS; +const TOTAL_LIVES = 3; +const POINTS_PER_BRICK = 10; +const BRICK_COLORS: readonly string[] = [ + "#ef4444", // row 0 – red + "#f97316", // row 1 – orange + "#eab308", // row 2 – yellow + "#22c55e", // row 3 – green + "#3b82f6", // row 4 – blue +]; +const HIGH_SCORE_KEY = "brickHighScore"; + +// ── Types ───────────────────────────────────────────────────────────────────── +interface Brick { + x: number; + y: number; + alive: boolean; + color: string; +} + +interface Ball { + x: number; + y: number; + vx: number; + vy: number; +} + +interface Paddle { + x: number; // left edge +} + +type GamePhase = "idle" | "playing" | "ball-lost" | "game-over" | "won"; + +// ── Pure helpers ────────────────────────────────────────────────────────────── +function buildBricks(): Brick[] { + const bricks: Brick[] = []; + for (let row = 0; row < BRICK_ROWS; row++) { + for (let col = 0; col < BRICK_COLS; col++) { + bricks.push({ + x: BRICK_LEFT_OFFSET + col * (BRICK_WIDTH + BRICK_PADDING), + y: BRICK_TOP_OFFSET + row * (BRICK_HEIGHT + BRICK_PADDING), + alive: true, + color: BRICK_COLORS[row], + }); + } + } + return bricks; +} + +function makeBall(paddleX: number): Ball { + const dir = Math.random() > 0.5 ? 1 : -1; + return { + x: paddleX + PADDLE_WIDTH / 2, + y: PADDLE_Y - BALL_RADIUS - 1, + vx: dir * BALL_SPEED_INITIAL * 0.6, + vy: -BALL_SPEED_INITIAL, + }; +} + +function getStoredHighScore(): number { + try { + return parseInt(localStorage.getItem(HIGH_SCORE_KEY) ?? "0", 10) || 0; + } catch { + return 0; + } +} + +/** Portable rounded-rectangle path (avoids ctx.roundRect TS-lib variance). */ +function roundRectPath( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number +): void { + const radius = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + w - radius, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + radius); + ctx.lineTo(x + w, y + h - radius); + ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h); + ctx.lineTo(x + radius, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); +} + +// ── Component ───────────────────────────────────────────────────────────────── +export default function BrickBreakerGame(): JSX.Element { + const canvasRef = useRef(null); + const containerRef = useRef(null); + + // Mutable game state lives in refs so RAF callbacks never close over stale values + const phaseRef = useRef("idle"); + const ballRef = useRef(makeBall((CANVAS_WIDTH - PADDLE_WIDTH) / 2)); + const paddleRef = useRef({ x: (CANVAS_WIDTH - PADDLE_WIDTH) / 2 }); + const bricksRef = useRef(buildBricks()); + const livesRef = useRef(TOTAL_LIVES); + const scoreRef = useRef(0); + const highScoreRef = useRef(getStoredHighScore()); + const keysRef = useRef({ left: false, right: false }); + const rafRef = useRef(null); + + // React state drives the HUD only (no game logic reads from these) + const [phase, setPhase] = useState("idle"); + const [score, setScore] = useState(0); + const [lives, setLives] = useState(TOTAL_LIVES); + const [highScore, setHighScore] = useState(getStoredHighScore); + + // Keep highScoreRef in sync with state so game loop reads the right value + useEffect(() => { + highScoreRef.current = highScore; + }, [highScore]); + + /** Push all ref values into React state (batched in React 18). */ + const syncHUD = useCallback(() => { + setPhase(phaseRef.current); + setScore(scoreRef.current); + setLives(livesRef.current); + }, []); + + // ── Canvas draw ──────────────────────────────────────────────────────────── + const draw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const { x: bx, y: by } = ballRef.current; + const { x: px } = paddleRef.current; + + // Background + ctx.fillStyle = "#0f172a"; + ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + + // Subtle divider above bricks + ctx.strokeStyle = "#1e3a5f"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, BRICK_TOP_OFFSET - 10); + ctx.lineTo(CANVAS_WIDTH, BRICK_TOP_OFFSET - 10); + ctx.stroke(); + + // Bricks + for (const brick of bricksRef.current) { + if (!brick.alive) continue; + // Body + ctx.fillStyle = brick.color; + roundRectPath(ctx, brick.x, brick.y, BRICK_WIDTH, BRICK_HEIGHT, 4); + ctx.fill(); + // Top shine + ctx.fillStyle = "rgba(255,255,255,0.28)"; + roundRectPath(ctx, brick.x + 2, brick.y + 2, BRICK_WIDTH - 4, 5, 2); + ctx.fill(); + } + + // Paddle + const pGrad = ctx.createLinearGradient( + px, + PADDLE_Y, + px, + PADDLE_Y + PADDLE_HEIGHT + ); + pGrad.addColorStop(0, "#93c5fd"); + pGrad.addColorStop(1, "#1d4ed8"); + ctx.fillStyle = pGrad; + roundRectPath(ctx, px, PADDLE_Y, PADDLE_WIDTH, PADDLE_HEIGHT, 6); + ctx.fill(); + // Paddle shine + ctx.fillStyle = "rgba(255,255,255,0.35)"; + roundRectPath(ctx, px + 4, PADDLE_Y + 2, PADDLE_WIDTH - 8, 3, 2); + ctx.fill(); + + // Ball glow + const glowGrad = ctx.createRadialGradient( + bx, + by, + 0, + bx, + by, + BALL_RADIUS * 2.5 + ); + glowGrad.addColorStop(0, "rgba(186,230,253,0.45)"); + glowGrad.addColorStop(1, "rgba(186,230,253,0)"); + ctx.fillStyle = glowGrad; + ctx.beginPath(); + ctx.arc(bx, by, BALL_RADIUS * 2.5, 0, Math.PI * 2); + ctx.fill(); + + // Ball + const bGrad = ctx.createRadialGradient( + bx - 2, + by - 2, + 1, + bx, + by, + BALL_RADIUS + ); + bGrad.addColorStop(0, "#ffffff"); + bGrad.addColorStop(1, "#bae6fd"); + ctx.fillStyle = bGrad; + ctx.beginPath(); + ctx.arc(bx, by, BALL_RADIUS, 0, Math.PI * 2); + ctx.fill(); + + // Dashed indicator ring when ball is waiting to be launched + if (phaseRef.current === "idle" || phaseRef.current === "ball-lost") { + ctx.strokeStyle = "#fbbf24"; + ctx.lineWidth = 2; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.arc(bx, by, BALL_RADIUS + 5, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + } + }, []); + + // ── Game loop (runs every animation frame) ───────────────────────────────── + const gameLoop = useCallback(() => { + const currentPhase = phaseRef.current; + let hudDirty = false; + + if (currentPhase === "playing") { + const paddle = paddleRef.current; + const ball = ballRef.current; + const keys = keysRef.current; + + // Move paddle + if (keys.left) paddle.x = Math.max(0, paddle.x - PADDLE_SPEED); + if (keys.right) + paddle.x = Math.min(CANVAS_WIDTH - PADDLE_WIDTH, paddle.x + PADDLE_SPEED); + + // Move ball + ball.x += ball.vx; + ball.y += ball.vy; + + // Left / right wall bounce + if (ball.x - BALL_RADIUS < 0) { + ball.x = BALL_RADIUS; + ball.vx = Math.abs(ball.vx); + } else if (ball.x + BALL_RADIUS > CANVAS_WIDTH) { + ball.x = CANVAS_WIDTH - BALL_RADIUS; + ball.vx = -Math.abs(ball.vx); + } + + // Ceiling bounce + if (ball.y - BALL_RADIUS < 0) { + ball.y = BALL_RADIUS; + ball.vy = Math.abs(ball.vy); + } + + // Paddle collision — angle reflects off hit position + if ( + ball.vy > 0 && + ball.y + BALL_RADIUS >= PADDLE_Y && + ball.y - BALL_RADIUS <= PADDLE_Y + PADDLE_HEIGHT && + ball.x + BALL_RADIUS > paddle.x && + ball.x - BALL_RADIUS < paddle.x + PADDLE_WIDTH + ) { + ball.y = PADDLE_Y - BALL_RADIUS; + const hitRatio = + (ball.x - (paddle.x + PADDLE_WIDTH / 2)) / (PADDLE_WIDTH / 2); + const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy); + const maxAngle = Math.PI / 3; // ±60° + ball.vx = speed * Math.sin(hitRatio * maxAngle); + ball.vy = -Math.abs(speed * Math.cos(hitRatio * maxAngle)); + } + + // Brick collision — closest-point AABB circle test, one brick per frame + let brickHit = false; + for (const brick of bricksRef.current) { + if (!brick.alive || brickHit) continue; + const nearX = Math.max(brick.x, Math.min(ball.x, brick.x + BRICK_WIDTH)); + const nearY = Math.max(brick.y, Math.min(ball.y, brick.y + BRICK_HEIGHT)); + const dx = ball.x - nearX; + const dy = ball.y - nearY; + if (dx * dx + dy * dy < BALL_RADIUS * BALL_RADIUS) { + brick.alive = false; + brickHit = true; + scoreRef.current += POINTS_PER_BRICK; + hudDirty = true; + // Dominant-axis bounce + const overlapX = BALL_RADIUS - Math.abs(dx); + const overlapY = BALL_RADIUS - Math.abs(dy); + if (overlapX < overlapY) { + ball.vx = dx < 0 ? -Math.abs(ball.vx) : Math.abs(ball.vx); + } else { + ball.vy = dy < 0 ? -Math.abs(ball.vy) : Math.abs(ball.vy); + } + } + } + + // Ball lost below canvas + if (ball.y - BALL_RADIUS > CANVAS_HEIGHT) { + livesRef.current -= 1; + hudDirty = true; + if (livesRef.current <= 0) { + phaseRef.current = "game-over"; + if (scoreRef.current > highScoreRef.current) { + highScoreRef.current = scoreRef.current; + try { + localStorage.setItem(HIGH_SCORE_KEY, String(scoreRef.current)); + } catch { + /* noop */ + } + setHighScore(scoreRef.current); + } + } else { + phaseRef.current = "ball-lost"; + ball.x = paddle.x + PADDLE_WIDTH / 2; + ball.y = PADDLE_Y - BALL_RADIUS - 1; + ball.vx = 0; + ball.vy = 0; + } + } + + // Win — all bricks cleared + if (bricksRef.current.every((b) => !b.alive)) { + phaseRef.current = "won"; + hudDirty = true; + if (scoreRef.current > highScoreRef.current) { + highScoreRef.current = scoreRef.current; + try { + localStorage.setItem(HIGH_SCORE_KEY, String(scoreRef.current)); + } catch { + /* noop */ + } + setHighScore(scoreRef.current); + } + } + } else if (currentPhase === "idle" || currentPhase === "ball-lost") { + // Paddle still moves while waiting to launch; ball follows paddle + const paddle = paddleRef.current; + if (keysRef.current.left) paddle.x = Math.max(0, paddle.x - PADDLE_SPEED); + if (keysRef.current.right) + paddle.x = Math.min( + CANVAS_WIDTH - PADDLE_WIDTH, + paddle.x + PADDLE_SPEED + ); + ballRef.current.x = paddle.x + PADDLE_WIDTH / 2; + ballRef.current.y = PADDLE_Y - BALL_RADIUS - 1; + } + + draw(); + if (hudDirty) syncHUD(); + + rafRef.current = requestAnimationFrame(gameLoop); + }, [draw, syncHUD]); + + // Start (and keep alive) the RAF loop + useEffect(() => { + rafRef.current = requestAnimationFrame(gameLoop); + return () => { + if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); + }; + }, [gameLoop]); + + // ── Actions ──────────────────────────────────────────────────────────────── + const launchBall = useCallback(() => { + if (phaseRef.current !== "idle" && phaseRef.current !== "ball-lost") return; + ballRef.current = makeBall(paddleRef.current.x); + phaseRef.current = "playing"; + syncHUD(); + }, [syncHUD]); + + const resetGame = useCallback(() => { + const centerX = (CANVAS_WIDTH - PADDLE_WIDTH) / 2; + phaseRef.current = "idle"; + paddleRef.current = { x: centerX }; + ballRef.current = makeBall(centerX); + bricksRef.current = buildBricks(); + livesRef.current = TOTAL_LIVES; + scoreRef.current = 0; + syncHUD(); + }, [syncHUD]); + + // ── Keyboard input ───────────────────────────────────────────────────────── + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "ArrowLeft") { + e.preventDefault(); + keysRef.current.left = true; + } else if (e.key === "ArrowRight") { + e.preventDefault(); + keysRef.current.right = true; + } else if (e.key === " ") { + e.preventDefault(); + if (phaseRef.current === "idle" || phaseRef.current === "ball-lost") { + launchBall(); + } else if ( + phaseRef.current === "game-over" || + phaseRef.current === "won" + ) { + resetGame(); + } + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === "ArrowLeft") { + e.preventDefault(); + keysRef.current.left = false; + } else if (e.key === "ArrowRight") { + e.preventDefault(); + keysRef.current.right = false; + } + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + }; + }, [launchBall, resetGame]); + + // Focus the container so keyboard works immediately + useEffect(() => { + containerRef.current?.focus(); + }, []); + + // ── Render ───────────────────────────────────────────────────────────────── + return ( +
+ {/* HUD */} +
+
+ {Array.from({ length: TOTAL_LIVES }, (_, i) => ( + + ))} +
+ +
+ Score:{" "} + {score} +
+ +
+ Best: {highScore} +
+
+ + {/* Game canvas + overlays */} +
+ + + {/* Idle overlay */} + {phase === "idle" && ( +
+
+

+ BRICK BREAKER +

+

+ Clear all the bricks! +

+ +

+ ◄ ► Arrow keys to move • SPACE to launch +

+
+
+ )} + + {/* Ball-lost overlay */} + {phase === "ball-lost" && ( +
+
+

+ Ball Lost! +

+

+ {lives} {lives === 1 ? "life" : "lives"} remaining +

+ +

or press SPACE

+
+
+ )} + + {/* Game-over overlay */} + {phase === "game-over" && ( +
+
+

+ GAME OVER +

+

Score: {score}

+

Best: {highScore}

+ +

or press SPACE

+
+
+ )} + + {/* Win overlay */} + {phase === "won" && ( +
+
+

+ 🎉 YOU WIN! +

+

Score: {score}

+

Best: {highScore}

+ +

or press SPACE

+
+
+ )} +
+ + {/* Instructions */} +
+

◄ ► Arrow keys to move paddle • SPACE to launch ball

+

3 lives • clear all bricks to win

+
+
+ ); +} diff --git a/src/components/Home.tsx b/src/components/Home.tsx index efd120c..cfa0d97 100644 --- a/src/components/Home.tsx +++ b/src/components/Home.tsx @@ -4,9 +4,10 @@ interface HomeProps { onPlayTetris?: () => void; onPlayFlappy?: () => void; onPlayMinesweeper?: () => void; + onPlayBrickBreaker?: () => void; } -function Home({ onPlaySnake, onPlay2048, onPlayTetris, onPlayFlappy, onPlayMinesweeper }: HomeProps) { +function Home({ onPlaySnake, onPlay2048, onPlayTetris, onPlayFlappy, onPlayMinesweeper, onPlayBrickBreaker }: HomeProps) { return (
@@ -225,6 +226,43 @@ function Home({ onPlaySnake, onPlay2048, onPlayTetris, onPlayFlappy, onPlayMines
+ + {/* Brick Breaker Cabinet */} + {/* Arcade footer */} From bacb31507a722ed7df7e7852eb12adb05927a3e9 Mon Sep 17 00:00:00 2001 From: cwebster-99 Date: Tue, 16 Jun 2026 09:46:57 -0500 Subject: [PATCH 2/2] docs: add Brick Breaker to README Document the new Brick Breaker game in the games list, project structure, and how-to-play sections. Also add Minesweeper which was missing from the README. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 4da8904..10422b1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Arcade -A collection of classic arcade games built with React and TypeScript. Play Snake, 2048, Tetris, and Flappy Bird right in your browser! +A collection of classic arcade games built with React and TypeScript. Play Snake, 2048, Tetris, Flappy Bird, Minesweeper, and Brick Breaker right in your browser! ## Games @@ -12,6 +12,10 @@ A collection of classic arcade games built with React and TypeScript. Play Snake 🐦 **Flappy Bird** - Tap to fly through gaps in the pipes +💣 **Minesweeper** - Uncover tiles without triggering hidden mines + +🧱 **Brick Breaker** - Bounce the ball off your paddle to smash all the bricks, earn points, and clear every row before you run out of lives + ## Features - Light/Dark theme toggle with persistent preference @@ -49,11 +53,13 @@ A collection of classic arcade games built with React and TypeScript. Play Snake ``` src/ ├── components/ -│ ├── Home.tsx # Home page with game selection -│ ├── SnakeGame.tsx # Snake game component -│ ├── Game2048.tsx # 2048 game component -│ ├── TetrisGame.tsx # Tetris game component -│ └── FlappyGame.tsx # Flappy Bird game component +│ ├── Home.tsx # Home page with game selection +│ ├── SnakeGame.tsx # Snake game component +│ ├── Game2048.tsx # 2048 game component +│ ├── TetrisGame.tsx # Tetris game component +│ ├── FlappyGame.tsx # Flappy Bird game component +│ ├── MinesweeperGame.tsx # Minesweeper game component +│ └── BrickBreakerGame.tsx # Brick Breaker game component ├── App.tsx # Main app with routing ├── main.tsx # Entry point └── index.css # Global styles @@ -80,4 +86,16 @@ src/ ### Flappy Bird - **Space** or **Click** to flap - Navigate through the pipes -- Don't hit the ground or pipes! \ No newline at end of file +- Don't hit the ground or pipes! + +### Minesweeper +- **Left click** to reveal a tile +- **Right click** to place or remove a flag +- Use the numbers to deduce where the mines are hiding + +### Brick Breaker +- **Left/Right arrows** or **mouse movement** to move the paddle +- **Space** or **Click** to launch the ball +- Break all the bricks to win — each brick is worth 10 points +- You have 3 lives; losing the ball costs one life +- The ball speeds up as you clear bricks, and your high score is saved locally \ No newline at end of file