shadimage
gamekitui
componentbreakoutsource: ok · hit

Breakout

Bounce the ball, clear the bricks, don't drop. Endless levels.

npx shadcn add "https://gamekitui.com/r/breakout.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Files1 · source cached 10/15/2026, 2:43:11 AM

registry/games/breakout.tsx
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

/* -------------------------------------------------------------------------- */
/*                              Theme helpers                                  */
/* -------------------------------------------------------------------------- */

const THEME_TOKENS = [
  "background",
  "foreground",
  "primary",
  "primary-foreground",
  "secondary",
  "secondary-foreground",
  "accent",
  "accent-foreground",
  "muted",
  "muted-foreground",
  "border",
  "ring",
  "destructive",
] as const;
type ThemeTokens = Record<(typeof THEME_TOKENS)[number], string>;

function readTheme(el: HTMLElement | null): ThemeTokens {
  const target = el ?? (typeof document !== "undefined" ? document.documentElement : null);
  const cs = target ? getComputedStyle(target) : null;
  const out = {} as ThemeTokens;
  for (const t of THEME_TOKENS) out[t] = cs ? cs.getPropertyValue(`--${t}`).trim() : "";
  return out;
}

function useGameTheme(ref: React.RefObject<HTMLElement | null>): ThemeTokens {
  const [theme, setTheme] = React.useState<ThemeTokens>(() => readTheme(null));
  React.useEffect(() => {
    const read = () => setTheme(readTheme(ref.current));
    read();
    const mo = new MutationObserver(read);
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "style", "data-theme"],
    });
    return () => mo.disconnect();
  }, [ref]);
  return theme;
}

function usePrefersReducedMotion(): boolean {
  const [reduce, setReduce] = React.useState(false);
  React.useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduce(mq.matches);
    const onChange = (e: MediaQueryListEvent) => setReduce(e.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);
  return reduce;
}

/* -------------------------------------------------------------------------- */
/*                              Game constants                                 */
/* -------------------------------------------------------------------------- */

// Logical (design) coordinate space. All drawing & physics happen in this space;
// the canvas is scaled to its rendered CSS size via setTransform.
const LOGW = 360;
const LOGH = 300;

const BRICK_COLS = 8;
const BRICK_ROWS = 5;
const BRICK_GAP = 4;
const PADDLE_HEIGHT = 10;
const PADDLE_WIDTH_RATIO = 0.22; // fraction of logical width
const BALL_RADIUS = 7;
const INITIAL_LIVES = 3;
const POINTS_PER_BRICK = 10;
const SPEED_GROWTH = 1.07; // per-level ball speed multiplier
const MAX_SPEED_MULT = 2.2; // clamp on level-based speed growth

/* -------------------------------------------------------------------------- */
/*                              Game state types                               */
/* -------------------------------------------------------------------------- */

interface Ball {
  x: number;
  y: number;
  vx: number;
  vy: number;
}

interface Paddle {
  x: number; // left edge
  width: number;
}

interface Brick {
  col: number;
  row: number;
  alive: boolean;
}

interface BreakoutState {
  ball: Ball;
  paddle: Paddle;
  bricks: Brick[];
  lives: number;
  score: number;
  level: number; // 1-based; increments forever
  launched: boolean; // false = ball sitting on paddle, not yet launched
}

/* -------------------------------------------------------------------------- */
/*                              Engine factory                                 */
/* -------------------------------------------------------------------------- */

function makeBricks(): Brick[] {
  const bricks: Brick[] = [];
  for (let r = 0; r < BRICK_ROWS; r++) {
    for (let c = 0; c < BRICK_COLS; c++) {
      bricks.push({ col: c, row: r, alive: true });
    }
  }
  return bricks;
}

function initState(): BreakoutState {
  const paddleW = LOGW * PADDLE_WIDTH_RATIO;
  const paddle: Paddle = { x: (LOGW - paddleW) / 2, width: paddleW };
  const ball: Ball = {
    x: LOGW / 2,
    y: LOGH - PADDLE_HEIGHT - 20 - BALL_RADIUS,
    vx: 0,
    vy: 0,
  };
  return {
    ball,
    paddle,
    bricks: makeBricks(),
    lives: INITIAL_LIVES,
    score: 0,
    level: 1,
    launched: false,
  };
}

/* -------------------------------------------------------------------------- */
/*                              Props                                          */
/* -------------------------------------------------------------------------- */

export interface BreakoutProps {
  className?: string;
  width?: number;
  height?: number;
  paused?: boolean;
  autoFocus?: boolean;
  /**
   * When true (the default), the game listens for keys on `window` so it works
   * without being focused first — ideal for single-game pages (404s, loading /
   * empty states). Set false when multiple games share a page, or the game sits
   * in scrollable content, so it only responds while focused. Ignores keys aimed
   * at form fields.
   */
  captureGlobalKeys?: boolean;
  persistHighScore?: boolean | string;
  onScoreChange?: (score: number) => void;
  onGameOver?: (r: { score: number; won: boolean }) => void;
  onStart?: () => void;
}

/* -------------------------------------------------------------------------- */
/*                              Component                                      */
/* -------------------------------------------------------------------------- */

export function Breakout({
  className,
  width,
  paused = false,
  autoFocus = true,
  captureGlobalKeys = true,
  persistHighScore = true,
  onScoreChange,
  onGameOver,
  onStart,
}: BreakoutProps) {
  const wrapperRef = React.useRef<HTMLDivElement | null>(null);
  const playfieldRef = React.useRef<HTMLDivElement | null>(null);
  const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
  const theme = useGameTheme(wrapperRef);
  const reduce = usePrefersReducedMotion();

  const [status, setStatus] = React.useState<"idle" | "playing" | "gameover">("idle");
  const [score, setScore] = React.useState(0);
  const [lives, setLives] = React.useState(INITIAL_LIVES);
  const [level, setLevel] = React.useState(1);
  const [high, setHigh] = React.useState(0);
  const [offscreen, setOffscreen] = React.useState(false);

  const storageKey =
    typeof persistHighScore === "string"
      ? persistHighScore
      : persistHighScore === false
        ? null
        : "gamekitui:breakout:hi";

  // Mutable refs for the rAF loop — never cause re-renders.
  const stateRef = React.useRef<BreakoutState | null>(null);
  const statusRef = React.useRef(status);
  const pausedRef = React.useRef(paused);
  const offscreenRef = React.useRef(offscreen);
  const themeRef = React.useRef(theme);
  const reduceRef = React.useRef(reduce);
  const onScoreChangeRef = React.useRef(onScoreChange);
  const onGameOverRef = React.useRef(onGameOver);
  const submitHighRef = React.useRef<(s: number) => void>(() => undefined);

  statusRef.current = status;
  pausedRef.current = paused;
  offscreenRef.current = offscreen;
  themeRef.current = theme;
  reduceRef.current = reduce;
  onScoreChangeRef.current = onScoreChange;
  onGameOverRef.current = onGameOver;

  // Load high score.
  React.useEffect(() => {
    if (!storageKey) return;
    try {
      const raw = window.localStorage.getItem(storageKey);
      if (raw) setHigh(Number(raw) || 0);
    } catch {
      /* ignore */
    }
  }, [storageKey]);

  const submitHigh = React.useCallback(
    (s: number) => {
      setHigh((prev) => {
        if (s <= prev) return prev;
        if (storageKey) {
          try {
            window.localStorage.setItem(storageKey, String(s));
          } catch {
            /* ignore */
          }
        }
        return s;
      });
    },
    [storageKey],
  );
  submitHighRef.current = submitHigh;

  // Pause when scrolled offscreen or tab hidden.
  React.useEffect(() => {
    const el = wrapperRef.current;
    if (!el) return;
    const io = new IntersectionObserver(([e]) => {
      if (e) setOffscreen(!e.isIntersecting);
    });
    io.observe(el);
    const onVis = () => setOffscreen(document.hidden);
    document.addEventListener("visibilitychange", onVis);
    return () => {
      io.disconnect();
      document.removeEventListener("visibilitychange", onVis);
    };
  }, []);

  const start = React.useCallback(() => {
    stateRef.current = initState();
    setScore(0);
    setLives(INITIAL_LIVES);
    setLevel(1);
    setStatus("playing");
    onStart?.();
    onScoreChangeRef.current?.(0);
    // Focus the wrapper so keyboard controls work after the Start button unmounts.
    wrapperRef.current?.focus();
  }, [onStart]);

  // Brick layout helpers (computed in logical space).
  const getBrickLayout = React.useCallback(() => {
    const topMargin = 30;
    const sideMargin = 8;
    const totalW = LOGW - sideMargin * 2;
    const brickW = (totalW - BRICK_GAP * (BRICK_COLS - 1)) / BRICK_COLS;
    const brickH = 14;
    return { topMargin, sideMargin, brickW, brickH };
  }, []);

  // Responsive canvas sizing: match the playfield's rendered CSS size × DPR,
  // then scale the drawing context so all rendering happens in LOGW×LOGH space.
  React.useEffect(() => {
    const canvas = canvasRef.current;
    const field = playfieldRef.current;
    if (!canvas || !field) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const resize = () => {
      const rect = field.getBoundingClientRect();
      const w = Math.max(1, rect.width);
      const h = Math.max(1, rect.height);
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      const scale = w / LOGW;
      ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0);
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(field);
    return () => ro.disconnect();
  }, []);

  // Main rAF loop + drawing.
  React.useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    // ------------------------------------------------------------------
    // Draw helpers
    // ------------------------------------------------------------------

    const drawRoundRect = (x: number, y: number, w: number, h: number, r: number, fill: string) => {
      ctx.fillStyle = fill;
      ctx.beginPath();
      ctx.roundRect(x, y, w, h, r);
      ctx.fill();
    };

    const draw = () => {
      const t = themeRef.current;
      const gs = stateRef.current;

      // Background.
      ctx.clearRect(0, 0, LOGW, LOGH);
      ctx.fillStyle = t.background || "#fff";
      ctx.fillRect(0, 0, LOGW, LOGH);

      // Border line at top.
      ctx.fillStyle = t.border || "rgba(0,0,0,.15)";
      ctx.fillRect(0, 0, LOGW, 2);

      if (!gs) return;

      const { topMargin, sideMargin, brickW, brickH } = getBrickLayout();

      // Draw bricks.
      for (const brick of gs.bricks) {
        if (!brick.alive) continue;
        const bx = sideMargin + brick.col * (brickW + BRICK_GAP);
        const by = topMargin + brick.row * (brickH + BRICK_GAP);

        // Alternating primary / muted-foreground bricks — themeable and visible
        // on both light and dark backgrounds. (Tokens are already full color
        // strings like "oklch(…)", so use them directly — no extra wrapping.)
        const fill =
          brick.row % 2 === 0
            ? t.primary || "#3b82f6"
            : t["muted-foreground"] || "#64748b";

        drawRoundRect(bx, by, brickW, brickH, 3, fill);

        // Shine highlight.
        ctx.globalAlpha = 0.25;
        ctx.fillStyle = "#ffffff";
        ctx.beginPath();
        ctx.roundRect(bx + 2, by + 2, brickW - 4, 3, 1);
        ctx.fill();
        ctx.globalAlpha = 1;
      }

      // Paddle.
      const paddleY = LOGH - PADDLE_HEIGHT - 10;
      const pFill = t.primary || "#3b82f6";
      drawRoundRect(gs.paddle.x, paddleY, gs.paddle.width, PADDLE_HEIGHT, 5, pFill);

      // Ball.
      const bFill = t.primary || "#3b82f6";
      ctx.fillStyle = bFill;
      ctx.beginPath();
      ctx.arc(gs.ball.x, gs.ball.y, BALL_RADIUS, 0, Math.PI * 2);
      ctx.fill();
      // Ball glint.
      ctx.globalAlpha = 0.45;
      ctx.fillStyle = "#ffffff";
      ctx.beginPath();
      ctx.arc(gs.ball.x - 2, gs.ball.y - 2, BALL_RADIUS * 0.38, 0, Math.PI * 2);
      ctx.fill();
      ctx.globalAlpha = 1;
    };

    // ------------------------------------------------------------------
    // Physics step
    // ------------------------------------------------------------------

    const step = (dt: number) => {
      const gs = stateRef.current;
      if (!gs || !gs.launched) return;

      // Speed scales with level (clamped) and reduced-motion preference.
      const levelMult = Math.min(SPEED_GROWTH ** (gs.level - 1), MAX_SPEED_MULT);
      const maxSpeed = (reduceRef.current ? 180 : 320) * levelMult; // px/s

      // Cap speed.
      const speed = Math.sqrt(gs.ball.vx ** 2 + gs.ball.vy ** 2);
      let vx = gs.ball.vx;
      let vy = gs.ball.vy;
      if (speed > maxSpeed) {
        const scale = maxSpeed / speed;
        vx *= scale;
        vy *= scale;
      }

      let nx = gs.ball.x + vx * dt;
      let ny = gs.ball.y + vy * dt;

      // Wall collisions (left, right, top).
      if (nx - BALL_RADIUS < 0) {
        nx = BALL_RADIUS;
        vx = Math.abs(vx);
      }
      if (nx + BALL_RADIUS > LOGW) {
        nx = LOGW - BALL_RADIUS;
        vx = -Math.abs(vx);
      }
      if (ny - BALL_RADIUS < 0) {
        ny = BALL_RADIUS;
        vy = Math.abs(vy);
      }

      // Paddle collision.
      const paddleY = LOGH - PADDLE_HEIGHT - 10;
      const paddleLeft = gs.paddle.x;
      const paddleRight = gs.paddle.x + gs.paddle.width;

      if (
        vy > 0 &&
        ny + BALL_RADIUS >= paddleY &&
        ny - BALL_RADIUS <= paddleY + PADDLE_HEIGHT &&
        nx >= paddleLeft - BALL_RADIUS &&
        nx <= paddleRight + BALL_RADIUS
      ) {
        ny = paddleY - BALL_RADIUS;
        // Angle based on hit position relative to paddle center.
        const hitPos = (nx - (paddleLeft + gs.paddle.width / 2)) / (gs.paddle.width / 2);
        const angle = hitPos * (Math.PI / 3); // max ±60°
        const currSpeed = Math.max(Math.sqrt(vx ** 2 + vy ** 2), reduceRef.current ? 130 : 200);
        vx = currSpeed * Math.sin(angle);
        vy = -Math.abs(currSpeed * Math.cos(angle));
      }

      // Brick collisions. Resolve at most ONE brick hit per step: find the first
      // alive brick the ball overlaps, reflect the correct axis (minimal-penetration
      // resolution), push the ball out of the brick, destroy it, then stop. This
      // prevents the ball from tunnelling through (and flattening) a whole column
      // of bricks in a single frame.
      const { topMargin, sideMargin, brickW, brickH } = getBrickLayout();
      let scoreGain = 0;
      let bricksRemaining = 0;
      let hitBrick: Brick | null = null;
      let hitBx = 0;
      let hitBy = 0;

      for (const brick of gs.bricks) {
        if (!brick.alive) continue;
        bricksRemaining++;
        if (hitBrick) continue; // already found this step's collision; just keep counting

        const bx = sideMargin + brick.col * (brickW + BRICK_GAP);
        const by = topMargin + brick.row * (brickH + BRICK_GAP);

        // AABB + circle collision.
        const closestX = Math.max(bx, Math.min(nx, bx + brickW));
        const closestY = Math.max(by, Math.min(ny, by + brickH));
        const distX = nx - closestX;
        const distY = ny - closestY;
        const distSq = distX ** 2 + distY ** 2;

        if (distSq < BALL_RADIUS ** 2) {
          hitBrick = brick;
          hitBx = bx;
          hitBy = by;
        }
      }

      if (hitBrick) {
        hitBrick.alive = false;
        bricksRemaining--;
        scoreGain += POINTS_PER_BRICK;

        // Pick the reflection axis by minimal penetration. Compare how far the ball
        // has entered the brick on each axis (using its expanded AABB by BALL_RADIUS);
        // reflect whichever axis the ball is shallowest in, which corresponds to the
        // side it actually crossed.
        const penLeft = nx + BALL_RADIUS - hitBx; // entering from the left edge
        const penRight = hitBx + brickW + BALL_RADIUS - nx; // from the right edge
        const penTop = ny + BALL_RADIUS - hitBy; // from the top edge
        const penBottom = hitBy + brickH + BALL_RADIUS - ny; // from the bottom edge
        const penX = Math.min(penLeft, penRight);
        const penY = Math.min(penTop, penBottom);

        if (penX < penY) {
          // Horizontal hit — reflect vx and push the ball clear on the x axis.
          vx = penLeft < penRight ? -Math.abs(vx) : Math.abs(vx);
          nx = penLeft < penRight ? hitBx - BALL_RADIUS : hitBx + brickW + BALL_RADIUS;
        } else {
          // Vertical hit — reflect vy and push the ball clear on the y axis.
          vy = penTop < penBottom ? -Math.abs(vy) : Math.abs(vy);
          ny = penTop < penBottom ? hitBy - BALL_RADIUS : hitBy + brickH + BALL_RADIUS;
        }
      }

      // Update score.
      if (scoreGain > 0) {
        gs.score += scoreGain;
        const newScore = gs.score;
        setScore(newScore);
        onScoreChangeRef.current?.(newScore);
      }

      // Ball fell below paddle.
      if (ny - BALL_RADIUS > LOGH) {
        gs.lives -= 1;
        const newLives = gs.lives;
        setLives(newLives);

        if (newLives <= 0) {
          // The only terminal state: out of lives.
          const finalScore = gs.score;
          submitHighRef.current(finalScore);
          onGameOverRef.current?.({ score: finalScore, won: false });
          setStatus("gameover");
          return;
        }

        // Reset ball to paddle.
        gs.ball = {
          x: gs.paddle.x + gs.paddle.width / 2,
          y: paddleY - BALL_RADIUS - 1,
          vx: 0,
          vy: 0,
        };
        gs.launched = false;
        return;
      }

      // All bricks cleared — advance to the next level (endless). Keep score &
      // lives, regenerate the grid, and reset the ball onto the paddle so the
      // player re-launches. Difficulty rises via the level-based speed multiplier.
      if (bricksRemaining === 0) {
        gs.level += 1;
        gs.bricks = makeBricks();
        gs.ball = {
          x: gs.paddle.x + gs.paddle.width / 2,
          y: paddleY - BALL_RADIUS - 1,
          vx: 0,
          vy: 0,
        };
        gs.launched = false;
        setLevel(gs.level);
        return;
      }

      gs.ball.x = nx;
      gs.ball.y = ny;
      gs.ball.vx = vx;
      gs.ball.vy = vy;
    };

    let raf = 0;
    let last = 0;

    const frame = (now: number) => {
      raf = requestAnimationFrame(frame);
      if (last === 0) last = now;
      const rawDt = Math.min((now - last) / 1000, 0.05); // seconds, capped at 50ms
      last = now;

      const active =
        statusRef.current === "playing" && !pausedRef.current && !offscreenRef.current;

      if (active) {
        step(rawDt);
      }
      draw();
    };

    raf = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(raf);
  }, [getBrickLayout]);

  // Keyboard handler.
  React.useEffect(() => {
    const PADDLE_SPEED = 280; // px/s — but we use key-held logic
    let leftHeld = false;
    let rightHeld = false;
    let moveRaf = 0;
    let moveLast = 0;

    const movePaddle = (now: number) => {
      const gs = stateRef.current;
      if (!gs) {
        moveRaf = requestAnimationFrame(movePaddle);
        return;
      }
      if (moveLast === 0) moveLast = now;
      const dt = Math.min((now - moveLast) / 1000, 0.05);
      moveLast = now;

      if (leftHeld) {
        gs.paddle.x = Math.max(0, gs.paddle.x - PADDLE_SPEED * dt);
      }
      if (rightHeld) {
        gs.paddle.x = Math.min(LOGW - gs.paddle.width, gs.paddle.x + PADDLE_SPEED * dt);
      }

      // Keep ball on paddle if not launched.
      if (!gs.launched) {
        const paddleY = LOGH - PADDLE_HEIGHT - 10;
        gs.ball.x = gs.paddle.x + gs.paddle.width / 2;
        gs.ball.y = paddleY - BALL_RADIUS - 1;
      }

      if (leftHeld || rightHeld) {
        moveRaf = requestAnimationFrame(movePaddle);
      }
    };

    const launchBall = () => {
      const gs = stateRef.current;
      if (!gs || gs.launched) return;
      const angle = -Math.PI / 2 + (Math.random() - 0.5) * (Math.PI / 4); // upward, slight random spread
      const levelMult = Math.min(SPEED_GROWTH ** (gs.level - 1), MAX_SPEED_MULT);
      const speed = (reduceRef.current ? 150 : 230) * levelMult;
      gs.ball.vx = speed * Math.cos(angle);
      gs.ball.vy = speed * Math.sin(angle);
      gs.launched = true;
    };

    const onKeyDown = (e: KeyboardEvent) => {
      // In global mode, ignore keys aimed at form fields / editable content.
      const t = e.target as HTMLElement | null;
      if (t && (t.isContentEditable || /^(input|textarea|select)$/i.test(t.tagName))) return;
      if (e.key === "ArrowLeft" || e.key === "a" || e.key === "A") {
        e.preventDefault();
        if (statusRef.current === "idle") start();
        if (!leftHeld) {
          leftHeld = true;
          moveLast = 0;
          moveRaf = requestAnimationFrame(movePaddle);
        }
      } else if (e.key === "ArrowRight" || e.key === "d" || e.key === "D") {
        e.preventDefault();
        if (statusRef.current === "idle") start();
        if (!rightHeld) {
          rightHeld = true;
          moveLast = 0;
          moveRaf = requestAnimationFrame(movePaddle);
        }
      } else if (e.key === " " || e.key === "Enter") {
        e.preventDefault();
        if (statusRef.current === "idle" || statusRef.current === "gameover") {
          start();
        } else if (statusRef.current === "playing") {
          launchBall();
        }
      }
    };

    const onKeyUp = (e: KeyboardEvent) => {
      if (e.key === "ArrowLeft" || e.key === "a" || e.key === "A") {
        leftHeld = false;
        cancelAnimationFrame(moveRaf);
      } else if (e.key === "ArrowRight" || e.key === "d" || e.key === "D") {
        rightHeld = false;
        cancelAnimationFrame(moveRaf);
      }
    };

    // Single-game pages can opt into window-level capture so keys work without
    // focusing the game; otherwise listen on the wrapper (focus-scoped).
    const target: Window | HTMLElement | null = captureGlobalKeys
      ? window
      : wrapperRef.current;
    target?.addEventListener("keydown", onKeyDown as EventListener);
    target?.addEventListener("keyup", onKeyUp as EventListener);
    return () => {
      target?.removeEventListener("keydown", onKeyDown as EventListener);
      target?.removeEventListener("keyup", onKeyUp as EventListener);
      cancelAnimationFrame(moveRaf);
    };
  }, [start, captureGlobalKeys]);

  // Pointer / touch — move paddle to pointer x.
  React.useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    // Map a client X coordinate into logical (LOGW) space.
    const getLogicalX = (clientX: number): number => {
      const rect = canvas.getBoundingClientRect();
      return ((clientX - rect.left) / rect.width) * LOGW;
    };

    const movePaddleTo = (logicalX: number) => {
      const gs = stateRef.current;
      if (!gs) return;
      const half = gs.paddle.width / 2;
      gs.paddle.x = Math.max(0, Math.min(LOGW - gs.paddle.width, logicalX - half));
      // Keep ball on paddle if not launched.
      if (!gs.launched) {
        const paddleY = LOGH - PADDLE_HEIGHT - 10;
        gs.ball.x = gs.paddle.x + gs.paddle.width / 2;
        gs.ball.y = paddleY - BALL_RADIUS - 1;
      }
    };

    let pointerDown = false;

    const onPointerDown = (e: PointerEvent) => {
      pointerDown = true;
      if (statusRef.current === "idle") start();
      movePaddleTo(getLogicalX(e.clientX));
    };

    const onPointerMove = (e: PointerEvent) => {
      if (!pointerDown) return;
      movePaddleTo(getLogicalX(e.clientX));
    };

    const onPointerUp = (e: PointerEvent) => {
      if (pointerDown && statusRef.current === "playing") {
        const gs = stateRef.current;
        if (gs && !gs.launched) {
          // Tap/click launches the ball.
          const angle = -Math.PI / 2 + (Math.random() - 0.5) * (Math.PI / 4);
          const levelMult = Math.min(SPEED_GROWTH ** (gs.level - 1), MAX_SPEED_MULT);
          const speed = (reduceRef.current ? 150 : 230) * levelMult;
          gs.ball.vx = speed * Math.cos(angle);
          gs.ball.vy = speed * Math.sin(angle);
          gs.launched = true;
        }
        movePaddleTo(getLogicalX(e.clientX));
      }
      pointerDown = false;
    };

    canvas.addEventListener("pointerdown", onPointerDown);
    canvas.addEventListener("pointermove", onPointerMove);
    canvas.addEventListener("pointerup", onPointerUp);
    canvas.addEventListener("pointerleave", onPointerUp);

    return () => {
      canvas.removeEventListener("pointerdown", onPointerDown);
      canvas.removeEventListener("pointermove", onPointerMove);
      canvas.removeEventListener("pointerup", onPointerUp);
      canvas.removeEventListener("pointerleave", onPointerUp);
    };
  }, [start]);

  // Auto-focus.
  React.useEffect(() => {
    if (autoFocus) wrapperRef.current?.focus();
  }, [autoFocus]);

  const isOver = status === "gameover";

  return (
    <div
      ref={wrapperRef}
      tabIndex={0}
      role="application"
      aria-label="Breakout game"
      style={width ? { maxWidth: width } : undefined}
      className={cn(
        "relative flex w-full select-none flex-col gap-2 outline-none",
        // Focus ring only matters when keys are focus-scoped; global capture hides it.
        !captureGlobalKeys &&
          "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        className,
      )}
    >
      {/* Score bar */}
      <div className="flex items-center justify-between gap-3 text-sm">
        <span className="inline-flex items-center gap-2">
          <span className="inline-flex items-center rounded-md border bg-secondary px-2 py-0.5 font-medium text-secondary-foreground tabular-nums">
            Score {score}
          </span>
          <span className="inline-flex items-center rounded-md border bg-secondary px-2 py-0.5 font-medium text-secondary-foreground tabular-nums">
            Lvl {level}
          </span>
        </span>
        <span className="inline-flex items-center gap-1.5 tabular-nums text-muted-foreground">
          {lives > 0 && (
            <span aria-hidden="true" className="tracking-[0.15em]">
              {"♥".repeat(lives)}
            </span>
          )}
          <span>
            {lives === 0 ? "no lives" : lives === 1 ? "1 life" : `${lives} lives`}
          </span>
        </span>
        <span className="text-muted-foreground tabular-nums">Best {high}</span>
      </div>

      {/* aria-live region */}
      <div aria-live="polite" className="sr-only">
        {status === "playing"
          ? `Level ${level}, score ${score}, ${lives} lives remaining`
          : status === "gameover"
            ? `Game over. Final score ${score}`
            : "Breakout game. Press Start to play."}
      </div>

      {/* Canvas container */}
      <div
        ref={playfieldRef}
        className="relative aspect-[6/5] w-full overflow-hidden rounded-lg border bg-background"
      >
        <canvas ref={canvasRef} className="absolute inset-0 block h-full w-full touch-none" />

        {/* Overlays */}
        {status !== "playing" && (
          <div className="absolute inset-0 grid place-items-center bg-background/80 backdrop-blur-[1px]">
            <div className="flex flex-col items-center gap-3 px-6 text-center">
              {isOver ? (
                <>
                  <p className="text-lg font-semibold">Game over</p>
                  <p className="text-sm text-muted-foreground">
                    You scored {score}
                    {score >= high && score > 0 ? " — new best!" : ""}
                  </p>
                </>
              ) : (
                <>
                  <p className="text-lg font-semibold">Breakout</p>
                  <p className="text-sm text-muted-foreground">
                    Arrow keys / A D to move. Space or tap to launch.
                  </p>
                </>
              )}
              <button
                type="button"
                onClick={start}
                className={cn(
                  "inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 font-medium text-primary-foreground text-sm",
                  "transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
                )}
              >
                {isOver ? "Play again" : "Start"}
              </button>
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

export default Breakout;

More from gamekitui

Browse all components in this library →