{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "breakout",
  "type": "registry:component",
  "title": "Breakout",
  "description": "Bounce the ball, clear the bricks, don't drop. Endless levels.",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/games/breakout.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/* -------------------------------------------------------------------------- */\n/*                              Theme helpers                                  */\n/* -------------------------------------------------------------------------- */\n\nconst THEME_TOKENS = [\n  \"background\",\n  \"foreground\",\n  \"primary\",\n  \"primary-foreground\",\n  \"secondary\",\n  \"secondary-foreground\",\n  \"accent\",\n  \"accent-foreground\",\n  \"muted\",\n  \"muted-foreground\",\n  \"border\",\n  \"ring\",\n  \"destructive\",\n] as const;\ntype ThemeTokens = Record<(typeof THEME_TOKENS)[number], string>;\n\nfunction readTheme(el: HTMLElement | null): ThemeTokens {\n  const target = el ?? (typeof document !== \"undefined\" ? document.documentElement : null);\n  const cs = target ? getComputedStyle(target) : null;\n  const out = {} as ThemeTokens;\n  for (const t of THEME_TOKENS) out[t] = cs ? cs.getPropertyValue(`--${t}`).trim() : \"\";\n  return out;\n}\n\nfunction useGameTheme(ref: React.RefObject<HTMLElement | null>): ThemeTokens {\n  const [theme, setTheme] = React.useState<ThemeTokens>(() => readTheme(null));\n  React.useEffect(() => {\n    const read = () => setTheme(readTheme(ref.current));\n    read();\n    const mo = new MutationObserver(read);\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\", \"style\", \"data-theme\"],\n    });\n    return () => mo.disconnect();\n  }, [ref]);\n  return theme;\n}\n\nfunction usePrefersReducedMotion(): boolean {\n  const [reduce, setReduce] = React.useState(false);\n  React.useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setReduce(mq.matches);\n    const onChange = (e: MediaQueryListEvent) => setReduce(e.matches);\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n  return reduce;\n}\n\n/* -------------------------------------------------------------------------- */\n/*                              Game constants                                 */\n/* -------------------------------------------------------------------------- */\n\n// Logical (design) coordinate space. All drawing & physics happen in this space;\n// the canvas is scaled to its rendered CSS size via setTransform.\nconst LOGW = 360;\nconst LOGH = 300;\n\nconst BRICK_COLS = 8;\nconst BRICK_ROWS = 5;\nconst BRICK_GAP = 4;\nconst PADDLE_HEIGHT = 10;\nconst PADDLE_WIDTH_RATIO = 0.22; // fraction of logical width\nconst BALL_RADIUS = 7;\nconst INITIAL_LIVES = 3;\nconst POINTS_PER_BRICK = 10;\nconst SPEED_GROWTH = 1.07; // per-level ball speed multiplier\nconst MAX_SPEED_MULT = 2.2; // clamp on level-based speed growth\n\n/* -------------------------------------------------------------------------- */\n/*                              Game state types                               */\n/* -------------------------------------------------------------------------- */\n\ninterface Ball {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n}\n\ninterface Paddle {\n  x: number; // left edge\n  width: number;\n}\n\ninterface Brick {\n  col: number;\n  row: number;\n  alive: boolean;\n}\n\ninterface BreakoutState {\n  ball: Ball;\n  paddle: Paddle;\n  bricks: Brick[];\n  lives: number;\n  score: number;\n  level: number; // 1-based; increments forever\n  launched: boolean; // false = ball sitting on paddle, not yet launched\n}\n\n/* -------------------------------------------------------------------------- */\n/*                              Engine factory                                 */\n/* -------------------------------------------------------------------------- */\n\nfunction makeBricks(): Brick[] {\n  const bricks: Brick[] = [];\n  for (let r = 0; r < BRICK_ROWS; r++) {\n    for (let c = 0; c < BRICK_COLS; c++) {\n      bricks.push({ col: c, row: r, alive: true });\n    }\n  }\n  return bricks;\n}\n\nfunction initState(): BreakoutState {\n  const paddleW = LOGW * PADDLE_WIDTH_RATIO;\n  const paddle: Paddle = { x: (LOGW - paddleW) / 2, width: paddleW };\n  const ball: Ball = {\n    x: LOGW / 2,\n    y: LOGH - PADDLE_HEIGHT - 20 - BALL_RADIUS,\n    vx: 0,\n    vy: 0,\n  };\n  return {\n    ball,\n    paddle,\n    bricks: makeBricks(),\n    lives: INITIAL_LIVES,\n    score: 0,\n    level: 1,\n    launched: false,\n  };\n}\n\n/* -------------------------------------------------------------------------- */\n/*                              Props                                          */\n/* -------------------------------------------------------------------------- */\n\nexport interface BreakoutProps {\n  className?: string;\n  width?: number;\n  height?: number;\n  paused?: boolean;\n  autoFocus?: boolean;\n  /**\n   * When true (the default), the game listens for keys on `window` so it works\n   * without being focused first — ideal for single-game pages (404s, loading /\n   * empty states). Set false when multiple games share a page, or the game sits\n   * in scrollable content, so it only responds while focused. Ignores keys aimed\n   * at form fields.\n   */\n  captureGlobalKeys?: boolean;\n  persistHighScore?: boolean | string;\n  onScoreChange?: (score: number) => void;\n  onGameOver?: (r: { score: number; won: boolean }) => void;\n  onStart?: () => void;\n}\n\n/* -------------------------------------------------------------------------- */\n/*                              Component                                      */\n/* -------------------------------------------------------------------------- */\n\nexport function Breakout({\n  className,\n  width,\n  paused = false,\n  autoFocus = true,\n  captureGlobalKeys = true,\n  persistHighScore = true,\n  onScoreChange,\n  onGameOver,\n  onStart,\n}: BreakoutProps) {\n  const wrapperRef = React.useRef<HTMLDivElement | null>(null);\n  const playfieldRef = React.useRef<HTMLDivElement | null>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement | null>(null);\n  const theme = useGameTheme(wrapperRef);\n  const reduce = usePrefersReducedMotion();\n\n  const [status, setStatus] = React.useState<\"idle\" | \"playing\" | \"gameover\">(\"idle\");\n  const [score, setScore] = React.useState(0);\n  const [lives, setLives] = React.useState(INITIAL_LIVES);\n  const [level, setLevel] = React.useState(1);\n  const [high, setHigh] = React.useState(0);\n  const [offscreen, setOffscreen] = React.useState(false);\n\n  const storageKey =\n    typeof persistHighScore === \"string\"\n      ? persistHighScore\n      : persistHighScore === false\n        ? null\n        : \"gamekitui:breakout:hi\";\n\n  // Mutable refs for the rAF loop — never cause re-renders.\n  const stateRef = React.useRef<BreakoutState | null>(null);\n  const statusRef = React.useRef(status);\n  const pausedRef = React.useRef(paused);\n  const offscreenRef = React.useRef(offscreen);\n  const themeRef = React.useRef(theme);\n  const reduceRef = React.useRef(reduce);\n  const onScoreChangeRef = React.useRef(onScoreChange);\n  const onGameOverRef = React.useRef(onGameOver);\n  const submitHighRef = React.useRef<(s: number) => void>(() => undefined);\n\n  statusRef.current = status;\n  pausedRef.current = paused;\n  offscreenRef.current = offscreen;\n  themeRef.current = theme;\n  reduceRef.current = reduce;\n  onScoreChangeRef.current = onScoreChange;\n  onGameOverRef.current = onGameOver;\n\n  // Load high score.\n  React.useEffect(() => {\n    if (!storageKey) return;\n    try {\n      const raw = window.localStorage.getItem(storageKey);\n      if (raw) setHigh(Number(raw) || 0);\n    } catch {\n      /* ignore */\n    }\n  }, [storageKey]);\n\n  const submitHigh = React.useCallback(\n    (s: number) => {\n      setHigh((prev) => {\n        if (s <= prev) return prev;\n        if (storageKey) {\n          try {\n            window.localStorage.setItem(storageKey, String(s));\n          } catch {\n            /* ignore */\n          }\n        }\n        return s;\n      });\n    },\n    [storageKey],\n  );\n  submitHighRef.current = submitHigh;\n\n  // Pause when scrolled offscreen or tab hidden.\n  React.useEffect(() => {\n    const el = wrapperRef.current;\n    if (!el) return;\n    const io = new IntersectionObserver(([e]) => {\n      if (e) setOffscreen(!e.isIntersecting);\n    });\n    io.observe(el);\n    const onVis = () => setOffscreen(document.hidden);\n    document.addEventListener(\"visibilitychange\", onVis);\n    return () => {\n      io.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n  }, []);\n\n  const start = React.useCallback(() => {\n    stateRef.current = initState();\n    setScore(0);\n    setLives(INITIAL_LIVES);\n    setLevel(1);\n    setStatus(\"playing\");\n    onStart?.();\n    onScoreChangeRef.current?.(0);\n    // Focus the wrapper so keyboard controls work after the Start button unmounts.\n    wrapperRef.current?.focus();\n  }, [onStart]);\n\n  // Brick layout helpers (computed in logical space).\n  const getBrickLayout = React.useCallback(() => {\n    const topMargin = 30;\n    const sideMargin = 8;\n    const totalW = LOGW - sideMargin * 2;\n    const brickW = (totalW - BRICK_GAP * (BRICK_COLS - 1)) / BRICK_COLS;\n    const brickH = 14;\n    return { topMargin, sideMargin, brickW, brickH };\n  }, []);\n\n  // Responsive canvas sizing: match the playfield's rendered CSS size × DPR,\n  // then scale the drawing context so all rendering happens in LOGW×LOGH space.\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const field = playfieldRef.current;\n    if (!canvas || !field) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const resize = () => {\n      const rect = field.getBoundingClientRect();\n      const w = Math.max(1, rect.width);\n      const h = Math.max(1, rect.height);\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.round(w * dpr);\n      canvas.height = Math.round(h * dpr);\n      const scale = w / LOGW;\n      ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0);\n    };\n    resize();\n    const ro = new ResizeObserver(resize);\n    ro.observe(field);\n    return () => ro.disconnect();\n  }, []);\n\n  // Main rAF loop + drawing.\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    // ------------------------------------------------------------------\n    // Draw helpers\n    // ------------------------------------------------------------------\n\n    const drawRoundRect = (x: number, y: number, w: number, h: number, r: number, fill: string) => {\n      ctx.fillStyle = fill;\n      ctx.beginPath();\n      ctx.roundRect(x, y, w, h, r);\n      ctx.fill();\n    };\n\n    const draw = () => {\n      const t = themeRef.current;\n      const gs = stateRef.current;\n\n      // Background.\n      ctx.clearRect(0, 0, LOGW, LOGH);\n      ctx.fillStyle = t.background || \"#fff\";\n      ctx.fillRect(0, 0, LOGW, LOGH);\n\n      // Border line at top.\n      ctx.fillStyle = t.border || \"rgba(0,0,0,.15)\";\n      ctx.fillRect(0, 0, LOGW, 2);\n\n      if (!gs) return;\n\n      const { topMargin, sideMargin, brickW, brickH } = getBrickLayout();\n\n      // Draw bricks.\n      for (const brick of gs.bricks) {\n        if (!brick.alive) continue;\n        const bx = sideMargin + brick.col * (brickW + BRICK_GAP);\n        const by = topMargin + brick.row * (brickH + BRICK_GAP);\n\n        // Alternating primary / muted-foreground bricks — themeable and visible\n        // on both light and dark backgrounds. (Tokens are already full color\n        // strings like \"oklch(…)\", so use them directly — no extra wrapping.)\n        const fill =\n          brick.row % 2 === 0\n            ? t.primary || \"#3b82f6\"\n            : t[\"muted-foreground\"] || \"#64748b\";\n\n        drawRoundRect(bx, by, brickW, brickH, 3, fill);\n\n        // Shine highlight.\n        ctx.globalAlpha = 0.25;\n        ctx.fillStyle = \"#ffffff\";\n        ctx.beginPath();\n        ctx.roundRect(bx + 2, by + 2, brickW - 4, 3, 1);\n        ctx.fill();\n        ctx.globalAlpha = 1;\n      }\n\n      // Paddle.\n      const paddleY = LOGH - PADDLE_HEIGHT - 10;\n      const pFill = t.primary || \"#3b82f6\";\n      drawRoundRect(gs.paddle.x, paddleY, gs.paddle.width, PADDLE_HEIGHT, 5, pFill);\n\n      // Ball.\n      const bFill = t.primary || \"#3b82f6\";\n      ctx.fillStyle = bFill;\n      ctx.beginPath();\n      ctx.arc(gs.ball.x, gs.ball.y, BALL_RADIUS, 0, Math.PI * 2);\n      ctx.fill();\n      // Ball glint.\n      ctx.globalAlpha = 0.45;\n      ctx.fillStyle = \"#ffffff\";\n      ctx.beginPath();\n      ctx.arc(gs.ball.x - 2, gs.ball.y - 2, BALL_RADIUS * 0.38, 0, Math.PI * 2);\n      ctx.fill();\n      ctx.globalAlpha = 1;\n    };\n\n    // ------------------------------------------------------------------\n    // Physics step\n    // ------------------------------------------------------------------\n\n    const step = (dt: number) => {\n      const gs = stateRef.current;\n      if (!gs || !gs.launched) return;\n\n      // Speed scales with level (clamped) and reduced-motion preference.\n      const levelMult = Math.min(SPEED_GROWTH ** (gs.level - 1), MAX_SPEED_MULT);\n      const maxSpeed = (reduceRef.current ? 180 : 320) * levelMult; // px/s\n\n      // Cap speed.\n      const speed = Math.sqrt(gs.ball.vx ** 2 + gs.ball.vy ** 2);\n      let vx = gs.ball.vx;\n      let vy = gs.ball.vy;\n      if (speed > maxSpeed) {\n        const scale = maxSpeed / speed;\n        vx *= scale;\n        vy *= scale;\n      }\n\n      let nx = gs.ball.x + vx * dt;\n      let ny = gs.ball.y + vy * dt;\n\n      // Wall collisions (left, right, top).\n      if (nx - BALL_RADIUS < 0) {\n        nx = BALL_RADIUS;\n        vx = Math.abs(vx);\n      }\n      if (nx + BALL_RADIUS > LOGW) {\n        nx = LOGW - BALL_RADIUS;\n        vx = -Math.abs(vx);\n      }\n      if (ny - BALL_RADIUS < 0) {\n        ny = BALL_RADIUS;\n        vy = Math.abs(vy);\n      }\n\n      // Paddle collision.\n      const paddleY = LOGH - PADDLE_HEIGHT - 10;\n      const paddleLeft = gs.paddle.x;\n      const paddleRight = gs.paddle.x + gs.paddle.width;\n\n      if (\n        vy > 0 &&\n        ny + BALL_RADIUS >= paddleY &&\n        ny - BALL_RADIUS <= paddleY + PADDLE_HEIGHT &&\n        nx >= paddleLeft - BALL_RADIUS &&\n        nx <= paddleRight + BALL_RADIUS\n      ) {\n        ny = paddleY - BALL_RADIUS;\n        // Angle based on hit position relative to paddle center.\n        const hitPos = (nx - (paddleLeft + gs.paddle.width / 2)) / (gs.paddle.width / 2);\n        const angle = hitPos * (Math.PI / 3); // max ±60°\n        const currSpeed = Math.max(Math.sqrt(vx ** 2 + vy ** 2), reduceRef.current ? 130 : 200);\n        vx = currSpeed * Math.sin(angle);\n        vy = -Math.abs(currSpeed * Math.cos(angle));\n      }\n\n      // Brick collisions. Resolve at most ONE brick hit per step: find the first\n      // alive brick the ball overlaps, reflect the correct axis (minimal-penetration\n      // resolution), push the ball out of the brick, destroy it, then stop. This\n      // prevents the ball from tunnelling through (and flattening) a whole column\n      // of bricks in a single frame.\n      const { topMargin, sideMargin, brickW, brickH } = getBrickLayout();\n      let scoreGain = 0;\n      let bricksRemaining = 0;\n      let hitBrick: Brick | null = null;\n      let hitBx = 0;\n      let hitBy = 0;\n\n      for (const brick of gs.bricks) {\n        if (!brick.alive) continue;\n        bricksRemaining++;\n        if (hitBrick) continue; // already found this step's collision; just keep counting\n\n        const bx = sideMargin + brick.col * (brickW + BRICK_GAP);\n        const by = topMargin + brick.row * (brickH + BRICK_GAP);\n\n        // AABB + circle collision.\n        const closestX = Math.max(bx, Math.min(nx, bx + brickW));\n        const closestY = Math.max(by, Math.min(ny, by + brickH));\n        const distX = nx - closestX;\n        const distY = ny - closestY;\n        const distSq = distX ** 2 + distY ** 2;\n\n        if (distSq < BALL_RADIUS ** 2) {\n          hitBrick = brick;\n          hitBx = bx;\n          hitBy = by;\n        }\n      }\n\n      if (hitBrick) {\n        hitBrick.alive = false;\n        bricksRemaining--;\n        scoreGain += POINTS_PER_BRICK;\n\n        // Pick the reflection axis by minimal penetration. Compare how far the ball\n        // has entered the brick on each axis (using its expanded AABB by BALL_RADIUS);\n        // reflect whichever axis the ball is shallowest in, which corresponds to the\n        // side it actually crossed.\n        const penLeft = nx + BALL_RADIUS - hitBx; // entering from the left edge\n        const penRight = hitBx + brickW + BALL_RADIUS - nx; // from the right edge\n        const penTop = ny + BALL_RADIUS - hitBy; // from the top edge\n        const penBottom = hitBy + brickH + BALL_RADIUS - ny; // from the bottom edge\n        const penX = Math.min(penLeft, penRight);\n        const penY = Math.min(penTop, penBottom);\n\n        if (penX < penY) {\n          // Horizontal hit — reflect vx and push the ball clear on the x axis.\n          vx = penLeft < penRight ? -Math.abs(vx) : Math.abs(vx);\n          nx = penLeft < penRight ? hitBx - BALL_RADIUS : hitBx + brickW + BALL_RADIUS;\n        } else {\n          // Vertical hit — reflect vy and push the ball clear on the y axis.\n          vy = penTop < penBottom ? -Math.abs(vy) : Math.abs(vy);\n          ny = penTop < penBottom ? hitBy - BALL_RADIUS : hitBy + brickH + BALL_RADIUS;\n        }\n      }\n\n      // Update score.\n      if (scoreGain > 0) {\n        gs.score += scoreGain;\n        const newScore = gs.score;\n        setScore(newScore);\n        onScoreChangeRef.current?.(newScore);\n      }\n\n      // Ball fell below paddle.\n      if (ny - BALL_RADIUS > LOGH) {\n        gs.lives -= 1;\n        const newLives = gs.lives;\n        setLives(newLives);\n\n        if (newLives <= 0) {\n          // The only terminal state: out of lives.\n          const finalScore = gs.score;\n          submitHighRef.current(finalScore);\n          onGameOverRef.current?.({ score: finalScore, won: false });\n          setStatus(\"gameover\");\n          return;\n        }\n\n        // Reset ball to paddle.\n        gs.ball = {\n          x: gs.paddle.x + gs.paddle.width / 2,\n          y: paddleY - BALL_RADIUS - 1,\n          vx: 0,\n          vy: 0,\n        };\n        gs.launched = false;\n        return;\n      }\n\n      // All bricks cleared — advance to the next level (endless). Keep score &\n      // lives, regenerate the grid, and reset the ball onto the paddle so the\n      // player re-launches. Difficulty rises via the level-based speed multiplier.\n      if (bricksRemaining === 0) {\n        gs.level += 1;\n        gs.bricks = makeBricks();\n        gs.ball = {\n          x: gs.paddle.x + gs.paddle.width / 2,\n          y: paddleY - BALL_RADIUS - 1,\n          vx: 0,\n          vy: 0,\n        };\n        gs.launched = false;\n        setLevel(gs.level);\n        return;\n      }\n\n      gs.ball.x = nx;\n      gs.ball.y = ny;\n      gs.ball.vx = vx;\n      gs.ball.vy = vy;\n    };\n\n    let raf = 0;\n    let last = 0;\n\n    const frame = (now: number) => {\n      raf = requestAnimationFrame(frame);\n      if (last === 0) last = now;\n      const rawDt = Math.min((now - last) / 1000, 0.05); // seconds, capped at 50ms\n      last = now;\n\n      const active =\n        statusRef.current === \"playing\" && !pausedRef.current && !offscreenRef.current;\n\n      if (active) {\n        step(rawDt);\n      }\n      draw();\n    };\n\n    raf = requestAnimationFrame(frame);\n    return () => cancelAnimationFrame(raf);\n  }, [getBrickLayout]);\n\n  // Keyboard handler.\n  React.useEffect(() => {\n    const PADDLE_SPEED = 280; // px/s — but we use key-held logic\n    let leftHeld = false;\n    let rightHeld = false;\n    let moveRaf = 0;\n    let moveLast = 0;\n\n    const movePaddle = (now: number) => {\n      const gs = stateRef.current;\n      if (!gs) {\n        moveRaf = requestAnimationFrame(movePaddle);\n        return;\n      }\n      if (moveLast === 0) moveLast = now;\n      const dt = Math.min((now - moveLast) / 1000, 0.05);\n      moveLast = now;\n\n      if (leftHeld) {\n        gs.paddle.x = Math.max(0, gs.paddle.x - PADDLE_SPEED * dt);\n      }\n      if (rightHeld) {\n        gs.paddle.x = Math.min(LOGW - gs.paddle.width, gs.paddle.x + PADDLE_SPEED * dt);\n      }\n\n      // Keep ball on paddle if not launched.\n      if (!gs.launched) {\n        const paddleY = LOGH - PADDLE_HEIGHT - 10;\n        gs.ball.x = gs.paddle.x + gs.paddle.width / 2;\n        gs.ball.y = paddleY - BALL_RADIUS - 1;\n      }\n\n      if (leftHeld || rightHeld) {\n        moveRaf = requestAnimationFrame(movePaddle);\n      }\n    };\n\n    const launchBall = () => {\n      const gs = stateRef.current;\n      if (!gs || gs.launched) return;\n      const angle = -Math.PI / 2 + (Math.random() - 0.5) * (Math.PI / 4); // upward, slight random spread\n      const levelMult = Math.min(SPEED_GROWTH ** (gs.level - 1), MAX_SPEED_MULT);\n      const speed = (reduceRef.current ? 150 : 230) * levelMult;\n      gs.ball.vx = speed * Math.cos(angle);\n      gs.ball.vy = speed * Math.sin(angle);\n      gs.launched = true;\n    };\n\n    const onKeyDown = (e: KeyboardEvent) => {\n      // In global mode, ignore keys aimed at form fields / editable content.\n      const t = e.target as HTMLElement | null;\n      if (t && (t.isContentEditable || /^(input|textarea|select)$/i.test(t.tagName))) return;\n      if (e.key === \"ArrowLeft\" || e.key === \"a\" || e.key === \"A\") {\n        e.preventDefault();\n        if (statusRef.current === \"idle\") start();\n        if (!leftHeld) {\n          leftHeld = true;\n          moveLast = 0;\n          moveRaf = requestAnimationFrame(movePaddle);\n        }\n      } else if (e.key === \"ArrowRight\" || e.key === \"d\" || e.key === \"D\") {\n        e.preventDefault();\n        if (statusRef.current === \"idle\") start();\n        if (!rightHeld) {\n          rightHeld = true;\n          moveLast = 0;\n          moveRaf = requestAnimationFrame(movePaddle);\n        }\n      } else if (e.key === \" \" || e.key === \"Enter\") {\n        e.preventDefault();\n        if (statusRef.current === \"idle\" || statusRef.current === \"gameover\") {\n          start();\n        } else if (statusRef.current === \"playing\") {\n          launchBall();\n        }\n      }\n    };\n\n    const onKeyUp = (e: KeyboardEvent) => {\n      if (e.key === \"ArrowLeft\" || e.key === \"a\" || e.key === \"A\") {\n        leftHeld = false;\n        cancelAnimationFrame(moveRaf);\n      } else if (e.key === \"ArrowRight\" || e.key === \"d\" || e.key === \"D\") {\n        rightHeld = false;\n        cancelAnimationFrame(moveRaf);\n      }\n    };\n\n    // Single-game pages can opt into window-level capture so keys work without\n    // focusing the game; otherwise listen on the wrapper (focus-scoped).\n    const target: Window | HTMLElement | null = captureGlobalKeys\n      ? window\n      : wrapperRef.current;\n    target?.addEventListener(\"keydown\", onKeyDown as EventListener);\n    target?.addEventListener(\"keyup\", onKeyUp as EventListener);\n    return () => {\n      target?.removeEventListener(\"keydown\", onKeyDown as EventListener);\n      target?.removeEventListener(\"keyup\", onKeyUp as EventListener);\n      cancelAnimationFrame(moveRaf);\n    };\n  }, [start, captureGlobalKeys]);\n\n  // Pointer / touch — move paddle to pointer x.\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n\n    // Map a client X coordinate into logical (LOGW) space.\n    const getLogicalX = (clientX: number): number => {\n      const rect = canvas.getBoundingClientRect();\n      return ((clientX - rect.left) / rect.width) * LOGW;\n    };\n\n    const movePaddleTo = (logicalX: number) => {\n      const gs = stateRef.current;\n      if (!gs) return;\n      const half = gs.paddle.width / 2;\n      gs.paddle.x = Math.max(0, Math.min(LOGW - gs.paddle.width, logicalX - half));\n      // Keep ball on paddle if not launched.\n      if (!gs.launched) {\n        const paddleY = LOGH - PADDLE_HEIGHT - 10;\n        gs.ball.x = gs.paddle.x + gs.paddle.width / 2;\n        gs.ball.y = paddleY - BALL_RADIUS - 1;\n      }\n    };\n\n    let pointerDown = false;\n\n    const onPointerDown = (e: PointerEvent) => {\n      pointerDown = true;\n      if (statusRef.current === \"idle\") start();\n      movePaddleTo(getLogicalX(e.clientX));\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      if (!pointerDown) return;\n      movePaddleTo(getLogicalX(e.clientX));\n    };\n\n    const onPointerUp = (e: PointerEvent) => {\n      if (pointerDown && statusRef.current === \"playing\") {\n        const gs = stateRef.current;\n        if (gs && !gs.launched) {\n          // Tap/click launches the ball.\n          const angle = -Math.PI / 2 + (Math.random() - 0.5) * (Math.PI / 4);\n          const levelMult = Math.min(SPEED_GROWTH ** (gs.level - 1), MAX_SPEED_MULT);\n          const speed = (reduceRef.current ? 150 : 230) * levelMult;\n          gs.ball.vx = speed * Math.cos(angle);\n          gs.ball.vy = speed * Math.sin(angle);\n          gs.launched = true;\n        }\n        movePaddleTo(getLogicalX(e.clientX));\n      }\n      pointerDown = false;\n    };\n\n    canvas.addEventListener(\"pointerdown\", onPointerDown);\n    canvas.addEventListener(\"pointermove\", onPointerMove);\n    canvas.addEventListener(\"pointerup\", onPointerUp);\n    canvas.addEventListener(\"pointerleave\", onPointerUp);\n\n    return () => {\n      canvas.removeEventListener(\"pointerdown\", onPointerDown);\n      canvas.removeEventListener(\"pointermove\", onPointerMove);\n      canvas.removeEventListener(\"pointerup\", onPointerUp);\n      canvas.removeEventListener(\"pointerleave\", onPointerUp);\n    };\n  }, [start]);\n\n  // Auto-focus.\n  React.useEffect(() => {\n    if (autoFocus) wrapperRef.current?.focus();\n  }, [autoFocus]);\n\n  const isOver = status === \"gameover\";\n\n  return (\n    <div\n      ref={wrapperRef}\n      tabIndex={0}\n      role=\"application\"\n      aria-label=\"Breakout game\"\n      style={width ? { maxWidth: width } : undefined}\n      className={cn(\n        \"relative flex w-full select-none flex-col gap-2 outline-none\",\n        // Focus ring only matters when keys are focus-scoped; global capture hides it.\n        !captureGlobalKeys &&\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        className,\n      )}\n    >\n      {/* Score bar */}\n      <div className=\"flex items-center justify-between gap-3 text-sm\">\n        <span className=\"inline-flex items-center gap-2\">\n          <span className=\"inline-flex items-center rounded-md border bg-secondary px-2 py-0.5 font-medium text-secondary-foreground tabular-nums\">\n            Score {score}\n          </span>\n          <span className=\"inline-flex items-center rounded-md border bg-secondary px-2 py-0.5 font-medium text-secondary-foreground tabular-nums\">\n            Lvl {level}\n          </span>\n        </span>\n        <span className=\"inline-flex items-center gap-1.5 tabular-nums text-muted-foreground\">\n          {lives > 0 && (\n            <span aria-hidden=\"true\" className=\"tracking-[0.15em]\">\n              {\"♥\".repeat(lives)}\n            </span>\n          )}\n          <span>\n            {lives === 0 ? \"no lives\" : lives === 1 ? \"1 life\" : `${lives} lives`}\n          </span>\n        </span>\n        <span className=\"text-muted-foreground tabular-nums\">Best {high}</span>\n      </div>\n\n      {/* aria-live region */}\n      <div aria-live=\"polite\" className=\"sr-only\">\n        {status === \"playing\"\n          ? `Level ${level}, score ${score}, ${lives} lives remaining`\n          : status === \"gameover\"\n            ? `Game over. Final score ${score}`\n            : \"Breakout game. Press Start to play.\"}\n      </div>\n\n      {/* Canvas container */}\n      <div\n        ref={playfieldRef}\n        className=\"relative aspect-[6/5] w-full overflow-hidden rounded-lg border bg-background\"\n      >\n        <canvas ref={canvasRef} className=\"absolute inset-0 block h-full w-full touch-none\" />\n\n        {/* Overlays */}\n        {status !== \"playing\" && (\n          <div className=\"absolute inset-0 grid place-items-center bg-background/80 backdrop-blur-[1px]\">\n            <div className=\"flex flex-col items-center gap-3 px-6 text-center\">\n              {isOver ? (\n                <>\n                  <p className=\"text-lg font-semibold\">Game over</p>\n                  <p className=\"text-sm text-muted-foreground\">\n                    You scored {score}\n                    {score >= high && score > 0 ? \" — new best!\" : \"\"}\n                  </p>\n                </>\n              ) : (\n                <>\n                  <p className=\"text-lg font-semibold\">Breakout</p>\n                  <p className=\"text-sm text-muted-foreground\">\n                    Arrow keys / A D to move. Space or tap to launch.\n                  </p>\n                </>\n              )}\n              <button\n                type=\"button\"\n                onClick={start}\n                className={cn(\n                  \"inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 font-medium text-primary-foreground text-sm\",\n                  \"transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                )}\n              >\n                {isOver ? \"Play again\" : \"Start\"}\n              </button>\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport default Breakout;\n",
      "type": "registry:component",
      "target": "@/components/games/breakout.tsx"
    }
  ],
  "categories": [
    "games"
  ],
  "meta": {
    "iframeHeight": "520px",
    "surface": "canvas"
  }
}
