{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "snake",
  "type": "registry:component",
  "title": "Snake",
  "description": "A tiny, themeable Snake game. ≤6KB gzipped, zero external assets.",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/games/snake.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/* -------------------------------------------------------------------------- */\n/*                                   Engine                                    */\n/* -------------------------------------------------------------------------- */\n\ntype Vec = { x: number; y: number };\ntype Dir = \"up\" | \"down\" | \"left\" | \"right\";\n\nconst DELTA: Record<Dir, Vec> = {\n  up: { x: 0, y: -1 },\n  down: { x: 0, y: 1 },\n  left: { x: -1, y: 0 },\n  right: { x: 1, y: 0 },\n};\nconst OPPOSITE: Record<Dir, Dir> = { up: \"down\", down: \"up\", left: \"right\", right: \"left\" };\n\ninterface SnakeState {\n  snake: Vec[];\n  dir: Dir;\n  food: Vec;\n  score: number;\n  gameOver: boolean;\n}\n\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a |= 0;\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nfunction createSnakeEngine({ cols, rows, seed }: { cols: number; rows: number; seed: number }) {\n  const rand = mulberry32(seed);\n  let queued: Dir | null = null;\n\n  const spawnFood = (snake: Vec[]): Vec => {\n    let food: Vec;\n    do {\n      food = { x: Math.floor(rand() * cols), y: Math.floor(rand() * rows) };\n    } while (snake.some((s) => s.x === food.x && s.y === food.y));\n    return food;\n  };\n\n  const init = (): SnakeState => {\n    const startX = Math.floor(cols / 2);\n    const startY = Math.floor(rows / 2);\n    const snake = [\n      { x: startX, y: startY },\n      { x: startX - 1, y: startY },\n      { x: startX - 2, y: startY },\n    ];\n    return { snake, dir: \"right\", food: spawnFood(snake), score: 0, gameOver: false };\n  };\n\n  let state = init();\n\n  return {\n    get state() {\n      return state;\n    },\n    input(dir: Dir) {\n      // Reject reversing into yourself; queue so a single tick turns once.\n      if (dir !== OPPOSITE[state.dir]) queued = dir;\n    },\n    step() {\n      if (state.gameOver) return;\n      const dir = queued ?? state.dir;\n      queued = null;\n      const d = DELTA[dir];\n      const head = state.snake[0]!;\n      const next: Vec = { x: head.x + d.x, y: head.y + d.y };\n\n      const hitWall = next.x < 0 || next.y < 0 || next.x >= cols || next.y >= rows;\n      const hitSelf = state.snake.some((s, i) => i < state.snake.length - 1 && s.x === next.x && s.y === next.y);\n      if (hitWall || hitSelf) {\n        state = { ...state, dir, gameOver: true };\n        return;\n      }\n\n      const ate = next.x === state.food.x && next.y === state.food.y;\n      const snake = [next, ...state.snake];\n      if (!ate) snake.pop();\n      state = {\n        snake,\n        dir,\n        food: ate ? spawnFood(snake) : state.food,\n        score: state.score + (ate ? 1 : 0),\n        gameOver: false,\n      };\n    },\n    reset() {\n      queued = null;\n      state = init();\n    },\n  };\n}\n\n/* -------------------------------------------------------------------------- */\n/*                              Shared primitives                             */\n/* -------------------------------------------------------------------------- */\n\nconst THEME_TOKENS = [\n  \"background\",\n  \"foreground\",\n  \"primary\",\n  \"primary-foreground\",\n  \"secondary\",\n  \"accent\",\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/*                                  Component                                  */\n/* -------------------------------------------------------------------------- */\n\nexport interface SnakeProps {\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?: (result: { score: number; won: boolean }) => void;\n  onStart?: () => void;\n}\n\nconst COLS = 20;\nconst ROWS = 20;\n\nexport function Snake({\n  className,\n  width,\n  paused = false,\n  autoFocus = true,\n  captureGlobalKeys = true,\n  persistHighScore = true,\n  onScoreChange,\n  onGameOver,\n  onStart,\n}: SnakeProps) {\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  // Live logical (CSS px) size of the playfield, kept in a ref for the rAF loop.\n  const sizeRef = React.useRef({ w: 0, h: 0 });\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 [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:snake:hi\";\n\n  // Mutable refs the rAF loop reads without re-subscribing.\n  const engineRef = React.useRef<ReturnType<typeof createSnakeEngine> | 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  statusRef.current = status;\n  pausedRef.current = paused;\n  offscreenRef.current = offscreen;\n  themeRef.current = theme;\n  reduceRef.current = reduce;\n\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  // 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    const engine = createSnakeEngine({ cols: COLS, rows: ROWS, seed: (Math.random() * 2 ** 31) | 0 });\n    engineRef.current = engine;\n    setScore(0);\n    setStatus(\"playing\");\n    onStart?.();\n    onScoreChange?.(0);\n    // Focus the wrapper so keyboard controls work after the Start button unmounts.\n    wrapperRef.current?.focus();\n  }, [onScoreChange, onStart]);\n\n  // Responsive canvas sizing: match the playfield's rendered CSS size × DPR.\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, Math.round(rect.width));\n      const h = Math.max(1, Math.round(rect.height));\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = w * dpr;\n      canvas.height = h * dpr;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      sizeRef.current = { w, h };\n    };\n    resize();\n    const ro = new ResizeObserver(resize);\n    ro.observe(field);\n    return () => ro.disconnect();\n  }, []);\n\n  // Drawing + main loop.\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const draw = () => {\n      const t = themeRef.current;\n      const engine = engineRef.current;\n      const { w: width, h: height } = sizeRef.current;\n      if (width === 0 || height === 0) return;\n      const cell = Math.min(width / COLS, height / ROWS);\n      const offX = (width - cell * COLS) / 2;\n      const offY = (height - cell * ROWS) / 2;\n      ctx.clearRect(0, 0, width, height);\n      ctx.fillStyle = t.background || \"#fff\";\n      ctx.fillRect(0, 0, width, height);\n\n      // grid\n      ctx.strokeStyle = t.border || \"rgba(0,0,0,.1)\";\n      ctx.lineWidth = 1;\n      ctx.globalAlpha = 0.5;\n      for (let i = 0; i <= COLS; i++) {\n        ctx.beginPath();\n        ctx.moveTo(offX + i * cell, offY);\n        ctx.lineTo(offX + i * cell, offY + ROWS * cell);\n        ctx.stroke();\n      }\n      for (let j = 0; j <= ROWS; j++) {\n        ctx.beginPath();\n        ctx.moveTo(offX, offY + j * cell);\n        ctx.lineTo(offX + COLS * cell, offY + j * cell);\n        ctx.stroke();\n      }\n      ctx.globalAlpha = 1;\n\n      if (!engine) return;\n      const s = engine.state;\n      const pad = Math.max(1, cell * 0.12);\n      const r = Math.max(1, cell * 0.18);\n      const rect = (gx: number, gy: number, fill: string) => {\n        const x = offX + gx * cell + pad;\n        const y = offY + gy * cell + pad;\n        const w = cell - pad * 2;\n        ctx.fillStyle = fill;\n        ctx.beginPath();\n        ctx.roundRect(x, y, w, w, r);\n        ctx.fill();\n      };\n\n      // food (destructive — a contrasting accent, distinct from the primary snake)\n      rect(s.food.x, s.food.y, t.destructive || \"#f00\");\n\n      // snake body (primary), head a brighter shade\n      for (let i = s.snake.length - 1; i >= 0; i--) {\n        const seg = s.snake[i]!;\n        rect(seg.x, seg.y, i === 0 ? t.foreground || t.primary : t.primary || \"#000\");\n      }\n    };\n\n    let raf = 0;\n    let last = 0;\n    let acc = 0;\n    const interval = reduceRef.current ? 200 : 110;\n\n    const frame = (now: number) => {\n      raf = requestAnimationFrame(frame);\n      if (last === 0) last = now;\n      const dt = now - last;\n      last = now;\n\n      const active = statusRef.current === \"playing\" && !pausedRef.current && !offscreenRef.current;\n      if (active) {\n        acc += dt;\n        while (acc >= interval) {\n          acc -= interval;\n          const engine = engineRef.current;\n          if (!engine) break;\n          engine.step();\n          if (engine.state.gameOver) {\n            const finalScore = engine.state.score;\n            setStatus(\"gameover\");\n            submitHigh(finalScore);\n            onGameOver?.({ score: finalScore, won: false });\n            break;\n          } else {\n            const sc = engine.state.score;\n            setScore((prev) => {\n              if (prev !== sc) onScoreChange?.(sc);\n              return sc;\n            });\n          }\n        }\n      } else {\n        acc = 0;\n      }\n      draw();\n    };\n    raf = requestAnimationFrame(frame);\n    return () => cancelAnimationFrame(raf);\n  }, [onGameOver, onScoreChange, submitHigh]);\n\n  // Keyboard.\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 engine = engineRef.current;\n      const map: 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 = map[e.key];\n      if (dir) {\n        e.preventDefault();\n        if (statusRef.current === \"idle\") start();\n        engine?.input(dir);\n      } else if (e.key === \" \" || e.key === \"Enter\") {\n        e.preventDefault();\n        if (statusRef.current !== \"playing\") start();\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  }, [start, captureGlobalKeys]);\n\n  // Touch swipe.\n  React.useEffect(() => {\n    const el = wrapperRef.current;\n    if (!el) return;\n    let sx = 0;\n    let sy = 0;\n    const onStartTouch = (e: TouchEvent) => {\n      const t = e.touches[0];\n      if (!t) return;\n      sx = t.clientX;\n      sy = t.clientY;\n      if (statusRef.current !== \"playing\") start();\n    };\n    const onMove = (e: TouchEvent) => {\n      const t = e.changedTouches[0];\n      if (!t) return;\n      const dx = t.clientX - sx;\n      const dy = t.clientY - sy;\n      if (Math.abs(dx) < 20 && Math.abs(dy) < 20) return;\n      const dir: Dir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? \"right\" : \"left\") : dy > 0 ? \"down\" : \"up\";\n      engineRef.current?.input(dir);\n      sx = t.clientX;\n      sy = t.clientY;\n      e.preventDefault();\n    };\n    el.addEventListener(\"touchstart\", onStartTouch, { passive: true });\n    el.addEventListener(\"touchmove\", onMove, { passive: false });\n    return () => {\n      el.removeEventListener(\"touchstart\", onStartTouch);\n      el.removeEventListener(\"touchmove\", onMove);\n    };\n  }, [start]);\n\n  React.useEffect(() => {\n    if (autoFocus) wrapperRef.current?.focus();\n  }, [autoFocus]);\n\n  return (\n    <div\n      ref={wrapperRef}\n      tabIndex={0}\n      role=\"application\"\n      aria-label=\"Snake 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      <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        >\n          Score {score}\n        </span>\n        <span className=\"text-muted-foreground tabular-nums\">Best {high}</span>\n      </div>\n\n      <div\n        ref={playfieldRef}\n        className=\"relative aspect-square 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        {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              {status === \"gameover\" ? (\n                <>\n                  <p className=\"font-semibold text-lg\">Game over</p>\n                  <p className=\"text-muted-foreground text-sm\">\n                    You scored {score}\n                    {score >= high && score > 0 ? \" — new best!\" : \"\"}\n                  </p>\n                </>\n              ) : (\n                <>\n                  <p className=\"font-semibold text-lg\">Snake</p>\n                  <p className=\"text-muted-foreground text-sm\">Arrow keys, WASD, or swipe to move.</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                {status === \"gameover\" ? \"Play again\" : \"Start\"}\n              </button>\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport default Snake;\n",
      "type": "registry:component",
      "target": "@/components/games/snake.tsx"
    }
  ],
  "categories": [
    "games"
  ],
  "meta": {
    "iframeHeight": "460px",
    "surface": "canvas"
  }
}
