{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "2048",
  "type": "registry:component",
  "title": "2048",
  "description": "The addictive sliding-tile puzzle. Reach 2048. Swipe or arrow keys.",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/games/2048.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/* -------------------------------------------------------------------------- */\n/*                                   Engine                                    */\n/* -------------------------------------------------------------------------- */\n\ntype Dir = \"up\" | \"down\" | \"left\" | \"right\";\n\nconst SIZE = 4;\n\n/** A tile has a value and a stable id for React keying / animation. */\ninterface Tile {\n  id: number;\n  value: number;\n  /** true the frame it was spawned */\n  fresh: boolean;\n  /** true the frame it was merged */\n  merged: boolean;\n}\n\ntype Board = (Tile | null)[][];\n\ninterface GameState {\n  board: Board;\n  score: number;\n  won: boolean;\n  over: boolean;\n  /** Whether the player chose to keep going after 2048 */\n  keepGoing: boolean;\n}\n\nlet nextId = 1;\nfunction mkTile(value: number, fresh = true, merged = false): Tile {\n  return { id: nextId++, value, fresh, merged };\n}\n\nfunction emptyBoard(): Board {\n  return Array.from({ length: SIZE }, () => Array<Tile | null>(SIZE).fill(null));\n}\n\nfunction emptyCells(board: Board): [number, number][] {\n  const cells: [number, number][] = [];\n  for (let r = 0; r < SIZE; r++) {\n    for (let c = 0; c < SIZE; c++) {\n      if (board[r]?.[c] == null) cells.push([r, c]);\n    }\n  }\n  return cells;\n}\n\nfunction spawnRandom(board: Board): Board {\n  const cells = emptyCells(board);\n  if (cells.length === 0) return board;\n  const idx = Math.floor(Math.random() * cells.length);\n  const cell = cells[idx];\n  if (!cell) return board;\n  const [r, c] = cell;\n  const value = Math.random() < 0.9 ? 2 : 4;\n  const next: Board = board.map((row) => [...row]);\n  const row = next[r];\n  if (row) row[c] = mkTile(value, true, false);\n  return next;\n}\n\nfunction initBoard(): Board {\n  let board = emptyBoard();\n  board = spawnRandom(board);\n  board = spawnRandom(board);\n  return board;\n}\n\n/** Returns a fresh board where each tile's fresh/merged flags are cleared. */\nfunction clearFlags(board: Board): Board {\n  return board.map((row) =>\n    row.map((tile) =>\n      tile ? { ...tile, fresh: false, merged: false } : null,\n    ),\n  );\n}\n\n/** Slide + merge a single row to the left. Returns { row, gained }. */\nfunction slideLeft(row: (Tile | null)[]): { row: (Tile | null)[]; gained: number } {\n  // Compact non-null tiles\n  const tiles = row.filter((t): t is Tile => t !== null);\n  const result: (Tile | null)[] = Array<Tile | null>(SIZE).fill(null);\n  let gained = 0;\n  let wi = 0;\n  let ri = 0;\n  while (ri < tiles.length) {\n    const cur = tiles[ri];\n    const nxt = tiles[ri + 1];\n    if (!cur) { ri++; continue; }\n    if (nxt && cur.value === nxt.value) {\n      // Merge\n      const mergedValue = cur.value * 2;\n      gained += mergedValue;\n      result[wi++] = mkTile(mergedValue, false, true);\n      ri += 2;\n    } else {\n      result[wi++] = { ...cur, fresh: false, merged: false };\n      ri++;\n    }\n  }\n  return { row: result, gained };\n}\n\nfunction rotateBoard(board: Board): Board {\n  // Rotate 90° clockwise: new[c][SIZE-1-r] = old[r][c]\n  const next = emptyBoard();\n  for (let r = 0; r < SIZE; r++) {\n    for (let c = 0; c < SIZE; c++) {\n      const tile = board[r]?.[c] ?? null;\n      const newRow = next[c];\n      if (newRow) newRow[SIZE - 1 - r] = tile;\n    }\n  }\n  return next;\n}\n\nfunction rotateN(board: Board, n: number): Board {\n  let b = board;\n  for (let i = 0; i < n; i++) b = rotateBoard(b);\n  return b;\n}\n\n/** Apply a move direction. Returns { board, gained, changed }. */\nfunction applyMove(board: Board, dir: Dir): { board: Board; gained: number; changed: boolean } {\n  // Rotate so we always slide left, then rotate back.\n  const rotations: Record<Dir, number> = { left: 0, up: 1, right: 2, down: 3 };\n  const rot = rotations[dir];\n  let working = rotateN(board, rot);\n\n  let totalGained = 0;\n  let changed = false;\n\n  const nextBoard: Board = working.map((row) => {\n    const before = row.map((t) => t?.id ?? null);\n    const { row: slid, gained } = slideLeft(row);\n    const after = slid.map((t) => t?.id ?? null);\n    if (JSON.stringify(before) !== JSON.stringify(after)) changed = true;\n    totalGained += gained;\n    return slid;\n  });\n  working = nextBoard;\n\n  // Rotate back\n  const backRot = (4 - rot) % 4;\n  working = rotateN(working, backRot);\n\n  return { board: working, gained: totalGained, changed };\n}\n\nfunction hasMovesLeft(board: Board): boolean {\n  // Any empty cell?\n  if (emptyCells(board).length > 0) return true;\n  // Any adjacent equal values?\n  for (let r = 0; r < SIZE; r++) {\n    for (let c = 0; c < SIZE; c++) {\n      const val = board[r]?.[c]?.value;\n      if (val == null) continue;\n      if (board[r]?.[c + 1]?.value === val) return true;\n      if (board[r + 1]?.[c]?.value === val) return true;\n    }\n  }\n  return false;\n}\n\nfunction has2048(board: Board): boolean {\n  for (let r = 0; r < SIZE; r++) {\n    for (let c = 0; c < SIZE; c++) {\n      if ((board[r]?.[c]?.value ?? 0) >= 2048) return true;\n    }\n  }\n  return false;\n}\n\n// Deterministic empty state for the idle screen — keeps server and client\n// markup identical so there's no hydration mismatch from random tiles.\nfunction createInitialState(): GameState {\n  return {\n    board: emptyBoard(),\n    score: 0,\n    won: false,\n    over: false,\n    keepGoing: false,\n  };\n}\n\n// Playable state with two starting tiles — only created client-side on start.\nfunction createPlayingState(): GameState {\n  return {\n    board: initBoard(),\n    score: 0,\n    won: false,\n    over: false,\n    keepGoing: false,\n  };\n}\n\n/* -------------------------------------------------------------------------- */\n/*                              Shared primitives                             */\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/*                               Tile theming                                  */\n/* -------------------------------------------------------------------------- */\n\nfunction tileClasses(value: number, fresh: boolean, merged: boolean, reduce: boolean): string {\n  const base =\n    \"absolute inset-0 flex items-center justify-center rounded-md font-bold tabular-nums select-none transition-all\";\n\n  let colorClass: string;\n  if (value <= 4) {\n    colorClass = \"bg-secondary text-secondary-foreground\";\n  } else if (value <= 32) {\n    colorClass = \"bg-accent text-accent-foreground\";\n  } else {\n    colorClass = \"bg-primary text-primary-foreground\";\n  }\n\n  const ringClass =\n    !reduce && (fresh || merged)\n      ? \"ring-2 ring-ring ring-offset-1 ring-offset-background\"\n      : \"\";\n\n  const animClass =\n    !reduce && fresh\n      ? \"animate-[pop_150ms_ease-out]\"\n      : !reduce && merged\n        ? \"animate-[pop_120ms_ease-out]\"\n        : \"\";\n\n  // Text sizing: use container-query units so font scales with tile size.\n  // 4-digit values (1024, 2048, 4096…) get a smaller clamp.\n  const textClass =\n    value >= 1024\n      ? \"text-[clamp(0.6rem,8cqi,1.5rem)]\"\n      : value >= 128\n        ? \"text-[clamp(0.75rem,9cqi,1.75rem)]\"\n        : \"text-[clamp(0.875rem,11cqi,2rem)]\";\n\n  return cn(base, colorClass, textClass, ringClass, animClass);\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                  Component                                  */\n/* -------------------------------------------------------------------------- */\n\nexport interface Game2048Props {\n  className?: string;\n  /** Optional max-width override (e.g. \"320px\", \"24rem\"). Defaults to max-w-sm. */\n  width?: string;\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\nexport function Game2048({\n  className,\n  width,\n  autoFocus = true,\n  captureGlobalKeys = true,\n  persistHighScore = true,\n  onScoreChange,\n  onGameOver,\n  onStart,\n}: Game2048Props) {\n  const wrapperRef = React.useRef<HTMLDivElement | null>(null);\n  const reduce = usePrefersReducedMotion();\n\n  const storageKey =\n    typeof persistHighScore === \"string\"\n      ? persistHighScore\n      : persistHighScore === false\n        ? null\n        : \"gamekitui:2048:hi\";\n\n  const [status, setStatus] = React.useState<\"idle\" | \"playing\" | \"won\" | \"gameover\">(\"idle\");\n  const [gameState, setGameState] = React.useState<GameState>(createInitialState);\n  const [high, setHigh] = React.useState(0);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  // Mutable ref so event handlers always see latest status without re-subscribing.\n  const statusRef = React.useRef(status);\n  statusRef.current = status;\n\n  const gameStateRef = React.useRef(gameState);\n  gameStateRef.current = gameState;\n\n  // Load persisted 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\n  const startGame = React.useCallback(() => {\n    const state = createPlayingState();\n    setGameState(state);\n    setStatus(\"playing\");\n    onStart?.();\n    onScoreChange?.(0);\n    setAnnouncement(\"Game started. Score: 0\");\n    // Focus so keyboard/WASD work immediately after the Start button unmounts.\n    wrapperRef.current?.focus();\n  }, [onScoreChange, onStart]);\n\n  /** Process a move direction. */\n  const move = React.useCallback(\n    (dir: Dir) => {\n      if (statusRef.current !== \"playing\") return;\n\n      setGameState((prev) => {\n        // Clear previous animation flags first\n        const cleanBoard = clearFlags(prev.board);\n        const { board: moved, gained, changed } = applyMove(cleanBoard, dir);\n\n        if (!changed) return prev;\n\n        const newScore = prev.score + gained;\n        const boardWithSpawn = spawnRandom(moved);\n        const nowWon = !prev.keepGoing && has2048(boardWithSpawn);\n        const nowOver = !hasMovesLeft(boardWithSpawn);\n\n        const next: GameState = {\n          board: boardWithSpawn,\n          score: newScore,\n          won: nowWon || prev.won,\n          over: nowOver,\n          keepGoing: prev.keepGoing,\n        };\n\n        // Side effects scheduled via queueMicrotask to avoid setState-in-setState warnings.\n        queueMicrotask(() => {\n          onScoreChange?.(newScore);\n          if (nowWon && !prev.keepGoing) {\n            setStatus(\"won\");\n            setAnnouncement(`You reached 2048! Score: ${newScore}`);\n            submitHigh(newScore);\n            onGameOver?.({ score: newScore, won: true });\n          } else if (nowOver) {\n            setStatus(\"gameover\");\n            setAnnouncement(`Game over! Final score: ${newScore}`);\n            submitHigh(newScore);\n            onGameOver?.({ score: newScore, won: prev.won || nowWon });\n          } else {\n            setAnnouncement(`Score: ${newScore}`);\n          }\n        });\n\n        return next;\n      });\n    },\n    [onScoreChange, onGameOver, submitHigh],\n  );\n\n  const keepGoing = React.useCallback(() => {\n    setGameState((prev) => ({ ...prev, keepGoing: true, won: false }));\n    setStatus(\"playing\");\n    setAnnouncement(\"Continuing game…\");\n  }, []);\n\n  // Keyboard handler on the wrapper (not window).\n  React.useEffect(() => {\n    const onKey = (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      const dirMap: Record<string, Dir> = {\n        ArrowUp: \"up\",\n        ArrowDown: \"down\",\n        ArrowLeft: \"left\",\n        ArrowRight: \"right\",\n        w: \"up\",\n        s: \"down\",\n        a: \"left\",\n        d: \"right\",\n        W: \"up\",\n        S: \"down\",\n        A: \"left\",\n        D: \"right\",\n      };\n      const dir = dirMap[e.key];\n      if (dir) {\n        e.preventDefault();\n        if (statusRef.current === \"idle\") {\n          startGame();\n        } else {\n          move(dir);\n        }\n      } else if (e.key === \" \" || e.key === \"Enter\") {\n        e.preventDefault();\n        if (statusRef.current !== \"playing\") startGame();\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\", onKey as EventListener);\n    return () => {\n      target?.removeEventListener(\"keydown\", onKey as EventListener);\n    };\n  }, [startGame, move, captureGlobalKeys]);\n\n  // Touch swipe. A single deliberate swipe should cause AT MOST one move: we\n  // only register a direction on touchend (never on touchmove), so drag-holds\n  // and circular drags that return near the origin don't move anything.\n  React.useEffect(() => {\n    const el = wrapperRef.current;\n    if (!el) return;\n    // Minimum net travel for a swipe to count.\n    const THRESHOLD = 24;\n    // Dominant axis must clearly win (1.3×) for a diagonal-ish swipe to register.\n    const DOMINANCE = 1.3;\n    let sx = 0;\n    let sy = 0;\n    let active = false;\n    const onTouchStart = (e: TouchEvent) => {\n      const t = e.touches[0];\n      if (!t) return;\n      sx = t.clientX;\n      sy = t.clientY;\n      active = true;\n      if (statusRef.current === \"idle\") startGame();\n    };\n    const onTouchMove = (e: TouchEvent) => {\n      // Don't move tiles here — only prevent the page from scrolling while the\n      // gesture is over the board.\n      if (active) e.preventDefault();\n    };\n    const onTouchEnd = (e: TouchEvent) => {\n      if (!active) return;\n      active = false;\n      const t = e.changedTouches[0];\n      if (!t) return;\n      const dx = t.clientX - sx;\n      const dy = t.clientY - sy;\n      const adx = Math.abs(dx);\n      const ady = Math.abs(dy);\n      // Too short — treat as a tap / drag-hold, not a swipe.\n      if (Math.max(adx, ady) < THRESHOLD) return;\n      // One axis must clearly dominate; otherwise the gesture is ambiguous.\n      const horizontal = adx >= ady;\n      if (horizontal && adx < ady * DOMINANCE) return;\n      if (!horizontal && ady < adx * DOMINANCE) return;\n      const dir: Dir = horizontal\n        ? dx > 0\n          ? \"right\"\n          : \"left\"\n        : dy > 0\n          ? \"down\"\n          : \"up\";\n      move(dir);\n    };\n    el.addEventListener(\"touchstart\", onTouchStart, { passive: true });\n    el.addEventListener(\"touchmove\", onTouchMove, { passive: false });\n    el.addEventListener(\"touchend\", onTouchEnd, { passive: true });\n    el.addEventListener(\"touchcancel\", onTouchEnd, { passive: true });\n    return () => {\n      el.removeEventListener(\"touchstart\", onTouchStart);\n      el.removeEventListener(\"touchmove\", onTouchMove);\n      el.removeEventListener(\"touchend\", onTouchEnd);\n      el.removeEventListener(\"touchcancel\", onTouchEnd);\n    };\n  }, [startGame, move]);\n\n  React.useEffect(() => {\n    if (autoFocus) wrapperRef.current?.focus();\n  }, [autoFocus]);\n\n  const { board, score } = gameState;\n\n  return (\n    <div\n      ref={wrapperRef}\n      tabIndex={0}\n      role=\"application\"\n      aria-label=\"2048 game\"\n      style={width ? { maxWidth: width } : undefined}\n      className={cn(\n        \"relative flex w-full max-w-sm 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 row */}\n      <div className=\"flex items-center justify-between gap-3 text-sm\">\n        <span\n          className=\"inline-flex items-center rounded-md border bg-secondary px-2 py-0.5 font-medium text-secondary-foreground tabular-nums\"\n          aria-live=\"polite\"\n          aria-atomic=\"true\"\n        >\n          Score {score}\n        </span>\n        <span className=\"text-muted-foreground tabular-nums\">Best {high}</span>\n      </div>\n\n      {/* Hidden aria-live region for game state announcements */}\n      <div aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {announcement}\n      </div>\n\n      {/* Board */}\n      <div className=\"relative rounded-lg border bg-muted p-2\">\n        {/* Inject pop keyframe animation */}\n        <style>{`@keyframes pop{0%{transform:scale(.8);opacity:.6}60%{transform:scale(1.1)}100%{transform:scale(1);opacity:1}}`}</style>\n\n        {/* @container enables cqi units inside for tile font scaling */}\n        <div\n          className=\"@container grid grid-cols-4 grid-rows-4 w-full aspect-square gap-[2.5%]\"\n        >\n          {Array.from({ length: SIZE }, (_, r) =>\n            Array.from({ length: SIZE }, (_, c) => {\n              const tile = board[r]?.[c] ?? null;\n              return (\n                <div\n                  key={`${r}-${c}`}\n                  className=\"relative size-full rounded-md bg-background border border-border\"\n                >\n                  {tile && (\n                    <div\n                      key={tile.id}\n                      className={tileClasses(tile.value, tile.fresh, tile.merged, reduce)}\n                    >\n                      {tile.value}\n                    </div>\n                  )}\n                </div>\n              );\n            }),\n          )}\n        </div>\n\n        {/* Overlays */}\n        {status !== \"playing\" && (\n          <div className=\"absolute inset-0 grid place-items-center rounded-lg bg-background/80 backdrop-blur-[1px]\">\n            <div className=\"flex flex-col items-center gap-3 px-6 text-center\">\n              {status === \"gameover\" ? (\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              ) : status === \"won\" ? (\n                <>\n                  <p className=\"text-lg font-semibold\">You reached 2048!</p>\n                  <p className=\"text-sm text-muted-foreground\">Score: {score}</p>\n                  <div className=\"flex gap-2\">\n                    <button\n                      type=\"button\"\n                      onClick={keepGoing}\n                      className={cn(\n                        \"inline-flex h-9 items-center justify-center rounded-md border bg-secondary px-4 text-sm font-medium text-secondary-foreground\",\n                        \"transition-colors hover:bg-secondary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                      )}\n                    >\n                      Keep going\n                    </button>\n                    <button\n                      type=\"button\"\n                      onClick={startGame}\n                      className={cn(\n                        \"inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground\",\n                        \"transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                      )}\n                    >\n                      New game\n                    </button>\n                  </div>\n                </>\n              ) : (\n                <>\n                  <p className=\"text-lg font-semibold\">2048</p>\n                  <p className=\"text-sm text-muted-foreground\">\n                    Arrow keys, WASD, or swipe to play.\n                  </p>\n                </>\n              )}\n              {(status === \"idle\" || status === \"gameover\") && (\n                <button\n                  type=\"button\"\n                  onClick={startGame}\n                  className={cn(\n                    \"inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground\",\n                    \"transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                  )}\n                >\n                  {status === \"gameover\" ? \"Play again\" : \"Start\"}\n                </button>\n              )}\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport default Game2048;\n",
      "type": "registry:component",
      "target": "@/components/games/2048.tsx"
    }
  ],
  "categories": [
    "games"
  ],
  "meta": {
    "iframeHeight": "520px",
    "surface": "dom"
  }
}
