{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stack",
  "type": "registry:component",
  "title": "Stack",
  "description": "The one-tap tower builder. Drop the sliding block, keep the overlap, stack as high as you can — every layer a shade of your theme.",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/games/stack.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 happens in this space; the\n// canvas is scaled to its rendered CSS size via setTransform.\nconst LOGW = 360;\nconst LOGH = 480;\n\nconst BASE_SIZE = 110; // footprint of the first block, in logical units\nconst LAYER_H = 17; // visual height of each layer\nconst SLIDE_RANGE = 110; // how far the active block slides from center\nconst PERFECT_TOL = 6; // |offset| ≤ this counts as a perfect drop\nconst MIN_OVERLAP = 2; // anything thinner is a miss\nconst GROW_STEP = 9; // perfect streaks regrow the block by this much\nconst GROW_AFTER = 3; // ...once the streak reaches this length\nconst GRAVITY = 1300;\n\n// Isometric projection factors (2:1-ish axonometric).\nconst KX = 0.866;\nconst KY = 0.5;\n\n/* -------------------------------------------------------------------------- */\n/*                              Color helpers                                  */\n/* -------------------------------------------------------------------------- */\n\ninterface Hsl {\n  h: number;\n  s: number;\n  l: number;\n}\n\nfunction rgbToHsl(r: number, g: number, b: number): Hsl {\n  r /= 255;\n  g /= 255;\n  b /= 255;\n  const max = Math.max(r, g, b);\n  const min = Math.min(r, g, b);\n  const l = (max + min) / 2;\n  if (max === min) return { h: 0, s: 0, l: l * 100 };\n  const d = max - min;\n  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);\n  let h: number;\n  if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;\n  else if (max === g) h = ((b - r) / d + 2) / 6;\n  else h = ((r - g) / d + 4) / 6;\n  return { h: h * 360, s: s * 100, l: l * 100 };\n}\n\n/* -------------------------------------------------------------------------- */\n/*                              Game state types                               */\n/* -------------------------------------------------------------------------- */\n\ninterface Layer {\n  x: number; // center along the x axis\n  z: number; // center along the z axis\n  w: number; // size along x\n  d: number; // size along z\n}\n\ninterface Debris {\n  x: number;\n  z: number;\n  w: number;\n  d: number;\n  yTop: number; // top height when it broke off\n  fall: number; // distance fallen so far\n  vy: number;\n  life: number;\n  ci: number; // palette index of the layer it sheared off\n}\n\ninterface Ring {\n  x: number;\n  z: number;\n  w: number;\n  d: number;\n  y: number;\n  life: number;\n}\n\ninterface FloatText {\n  sx: number; // screen-space — rises with the tower view\n  sy: number;\n  life: number;\n  maxLife: number;\n  text: string;\n}\n\ninterface StackState {\n  layers: Layer[];\n  axis: 0 | 1; // 0 = sliding along x, 1 = along z\n  pos: number; // active block center on the slide axis\n  dir: 1 | -1;\n  speed: number;\n  combo: number;\n  debris: Debris[];\n  rings: Ring[];\n  texts: FloatText[];\n  cam: number; // screen-space y offset (lerped)\n  ended: boolean; // miss happened; fx still animate until the overlay shows\n}\n\nfunction initState(): StackState {\n  return {\n    layers: [{ x: 0, z: 0, w: BASE_SIZE, d: BASE_SIZE }],\n    axis: 0,\n    pos: -SLIDE_RANGE,\n    dir: 1,\n    speed: 120,\n    combo: 0,\n    debris: [],\n    rings: [],\n    texts: [],\n    cam: LOGH * 0.45 + LAYER_H,\n    ended: false,\n  };\n}\n\n/* -------------------------------------------------------------------------- */\n/*                              Props                                          */\n/* -------------------------------------------------------------------------- */\n\nexport interface StackProps {\n  className?: string;\n  width?: 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 Stack({\n  className,\n  width,\n  paused = false,\n  autoFocus = true,\n  captureGlobalKeys = true,\n  persistHighScore = true,\n  onScoreChange,\n  onGameOver,\n  onStart,\n}: StackProps) {\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 [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:stack:hi\";\n\n  // Mutable refs for the rAF loop — never cause re-renders.\n  const stateRef = React.useRef<StackState | 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 baseHslRef = React.useRef<Hsl>({ h: 215, s: 60, l: 50 });\n  const overlayTimerRef = React.useRef(0);\n  const onScoreChangeRef = React.useRef(onScoreChange);\n  const onGameOverRef = React.useRef(onGameOver);\n  const submitHighRef = React.useRef<(s: number) => void>(() => undefined);\n  const dropRef = React.useRef<() => 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  // Derive the palette base from the theme's primary token by rasterizing it\n  // to a pixel — works for any CSS color format (hex, oklch, hsl…). Layer\n  // colors recompute from this every frame, so swapping themes recolors the\n  // whole tower live.\n  React.useEffect(() => {\n    try {\n      const c = document.createElement(\"canvas\");\n      c.width = c.height = 1;\n      const cx = c.getContext(\"2d\");\n      if (!cx) return;\n      cx.fillStyle = \"#3b82f6\";\n      cx.fillStyle = theme.primary || \"#3b82f6\";\n      cx.fillRect(0, 0, 1, 1);\n      const [r, g, b] = cx.getImageData(0, 0, 1, 1).data;\n      baseHslRef.current = rgbToHsl(r!, g!, b!);\n    } catch {\n      /* keep previous */\n    }\n  }, [theme]);\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  React.useEffect(() => () => window.clearTimeout(overlayTimerRef.current), []);\n\n  const start = React.useCallback(() => {\n    window.clearTimeout(overlayTimerRef.current);\n    stateRef.current = initState();\n    setScore(0);\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  // 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: slide → drop → slice → 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    /* ------------------------------ palette ------------------------------ */\n\n    // Each layer gets a shade of the theme palette: the hue drifts slowly away\n    // from --primary as the tower climbs, while lightness rolls in a wave.\n    const layerColor = (i: number, adjust: number): string => {\n      const { h, s } = baseHslRef.current;\n      const hue = (h + i * 5 + 360) % 360;\n      const sat = Math.min(72, Math.max(34, s));\n      const light = Math.min(86, Math.max(14, 50 + 12 * Math.sin(i * 0.45) + adjust));\n      return `hsl(${hue.toFixed(1)} ${sat.toFixed(1)}% ${light.toFixed(1)}%)`;\n    };\n\n    /* ---------------------------- projection ----------------------------- */\n\n    const px = (x: number, z: number) => LOGW / 2 + (x - z) * KX;\n    const py = (gs: StackState, x: number, z: number, y: number) => gs.cam + (x + z) * KY - y;\n\n    /** Draw an isometric box: top face + the two camera-facing walls. */\n    const drawBox = (\n      gs: StackState,\n      x: number,\n      z: number,\n      w: number,\n      d: number,\n      yBottom: number,\n      yTop: number,\n      colorIdx: number,\n      alpha = 1,\n    ) => {\n      const hw = w / 2;\n      const hd = d / 2;\n      // Corners: A back, B right, C front, D left (screen-wise).\n      const ax = px(x - hw, z - hd);\n      const ay = py(gs, x - hw, z - hd, yTop);\n      const bx = px(x + hw, z - hd);\n      const by = py(gs, x + hw, z - hd, yTop);\n      const cx = px(x + hw, z + hd);\n      const cy = py(gs, x + hw, z + hd, yTop);\n      const dx = px(x - hw, z + hd);\n      const dy = py(gs, x - hw, z + hd, yTop);\n      const drop = yTop - yBottom;\n\n      ctx.globalAlpha = alpha;\n\n      // +x wall (screen lower-right).\n      ctx.fillStyle = layerColor(colorIdx, -9);\n      ctx.beginPath();\n      ctx.moveTo(bx, by);\n      ctx.lineTo(cx, cy);\n      ctx.lineTo(cx, cy + drop);\n      ctx.lineTo(bx, by + drop);\n      ctx.closePath();\n      ctx.fill();\n\n      // +z wall (screen lower-left).\n      ctx.fillStyle = layerColor(colorIdx, -18);\n      ctx.beginPath();\n      ctx.moveTo(cx, cy);\n      ctx.lineTo(dx, dy);\n      ctx.lineTo(dx, dy + drop);\n      ctx.lineTo(cx, cy + drop);\n      ctx.closePath();\n      ctx.fill();\n\n      // Top face.\n      ctx.fillStyle = layerColor(colorIdx, 8);\n      ctx.beginPath();\n      ctx.moveTo(ax, ay);\n      ctx.lineTo(bx, by);\n      ctx.lineTo(cx, cy);\n      ctx.lineTo(dx, dy);\n      ctx.closePath();\n      ctx.fill();\n      // Crisp edge.\n      ctx.strokeStyle = \"rgba(0,0,0,0.10)\";\n      ctx.lineWidth = 1;\n      ctx.stroke();\n\n      ctx.globalAlpha = 1;\n    };\n\n    /* ------------------------------- drop -------------------------------- */\n\n    const endGame = (gs: StackState) => {\n      gs.ended = true;\n      const final = gs.layers.length - 1;\n      submitHighRef.current(final);\n      onGameOverRef.current?.({ score: final, won: final > 0 });\n      // Let the missed block tumble before the overlay covers it.\n      window.clearTimeout(overlayTimerRef.current);\n      overlayTimerRef.current = window.setTimeout(\n        () => setStatus(\"gameover\"),\n        reduceRef.current ? 150 : 750,\n      );\n    };\n\n    const drop = () => {\n      const gs = stateRef.current;\n      if (!gs || gs.ended || statusRef.current !== \"playing\") return;\n\n      const below = gs.layers[gs.layers.length - 1]!;\n      const n = gs.layers.length; // index of the block being placed\n      const yTop = (n + 1) * LAYER_H;\n      const alongX = gs.axis === 0;\n      const belowPos = alongX ? below.x : below.z;\n      const size = alongX ? below.w : below.d;\n      const off = gs.pos - belowPos;\n      const overlap = size - Math.abs(off);\n\n      if (overlap <= MIN_OVERLAP) {\n        // Clean miss — the whole block tumbles.\n        gs.debris.push({\n          x: alongX ? gs.pos : below.x,\n          z: alongX ? below.z : gs.pos,\n          w: alongX ? size : below.w,\n          d: alongX ? below.d : size,\n          yTop,\n          fall: 0,\n          vy: 0,\n          life: 0,\n          ci: n,\n        });\n        endGame(gs);\n        return;\n      }\n\n      const perfect = Math.abs(off) <= PERFECT_TOL;\n      let newPos: number;\n      let newSize: number;\n\n      if (perfect) {\n        gs.combo += 1;\n        newPos = belowPos;\n        newSize = size;\n        // Streaks regrow the block toward its original footprint.\n        if (gs.combo >= GROW_AFTER) newSize = Math.min(size + GROW_STEP, BASE_SIZE);\n        gs.rings.push({\n          x: alongX ? newPos : below.x,\n          z: alongX ? below.z : newPos,\n          w: alongX ? newSize : below.w,\n          d: alongX ? below.d : newSize,\n          y: yTop,\n          life: 0,\n        });\n        if (gs.combo >= 2) {\n          gs.texts.push({\n            sx: LOGW / 2,\n            sy: LOGH * 0.30,\n            life: 0,\n            maxLife: 0.8,\n            text: `Perfect ×${gs.combo}`,\n          });\n        }\n      } else {\n        gs.combo = 0;\n        newPos = (gs.pos + belowPos) / 2;\n        newSize = overlap;\n        // The overhang shears off and falls.\n        const cutCenter = newPos + (Math.sign(off) * size) / 2;\n        gs.debris.push({\n          x: alongX ? cutCenter : below.x,\n          z: alongX ? below.z : cutCenter,\n          w: alongX ? Math.abs(off) : below.w,\n          d: alongX ? below.d : Math.abs(off),\n          yTop,\n          fall: 0,\n          vy: 0,\n          life: 0,\n          ci: n,\n        });\n      }\n\n      gs.layers.push({\n        x: alongX ? newPos : below.x,\n        z: alongX ? below.z : newPos,\n        w: alongX ? newSize : below.w,\n        d: alongX ? below.d : newSize,\n      });\n      const s = gs.layers.length - 1;\n      setScore(s);\n      onScoreChangeRef.current?.(s);\n\n      // Next block: other axis, faster, entering from alternating sides.\n      gs.axis = gs.axis === 0 ? 1 : 0;\n      gs.dir = s % 2 === 0 ? 1 : -1;\n      gs.pos = gs.dir === 1 ? -SLIDE_RANGE : SLIDE_RANGE;\n      const ramp = reduceRef.current ? 1.6 : 3;\n      gs.speed = 120 + Math.min(s * ramp, 150);\n    };\n    dropRef.current = drop;\n\n    /* ------------------------------- step -------------------------------- */\n\n    const step = (dt: number) => {\n      const gs = stateRef.current;\n      if (!gs) return;\n\n      if (!gs.ended) {\n        gs.pos += gs.dir * gs.speed * dt;\n        if (gs.pos > SLIDE_RANGE) {\n          gs.pos = SLIDE_RANGE;\n          gs.dir = -1;\n        } else if (gs.pos < -SLIDE_RANGE) {\n          gs.pos = -SLIDE_RANGE;\n          gs.dir = 1;\n        }\n      }\n\n      // FX keep animating even while the game-over overlay is pending.\n      for (const f of gs.debris) {\n        f.vy += GRAVITY * dt;\n        f.fall += f.vy * dt;\n        f.life += dt;\n      }\n      gs.debris = gs.debris.filter((f) => f.life < 1.4);\n      for (const r of gs.rings) r.life += dt;\n      gs.rings = gs.rings.filter((r) => r.life < 0.5);\n      for (const t of gs.texts) t.life += dt;\n      gs.texts = gs.texts.filter((t) => t.life < t.maxLife);\n\n      // Camera keeps the tower top around 45% of the view.\n      const topY = gs.layers.length * LAYER_H;\n      const target = LOGH * 0.45 + topY;\n      gs.cam += (target - gs.cam) * Math.min(1, (reduceRef.current ? 12 : 5) * dt);\n    };\n\n    /* ------------------------------ drawing ------------------------------ */\n\n    const draw = () => {\n      const t = themeRef.current;\n      const gs = stateRef.current;\n\n      ctx.clearRect(0, 0, LOGW, LOGH);\n      ctx.fillStyle = t.background || \"#fff\";\n      ctx.fillRect(0, 0, LOGW, LOGH);\n\n      // A faint vertical wash of the palette keeps the scene from feeling flat.\n      const wash = ctx.createLinearGradient(0, 0, 0, LOGH);\n      const { h, s } = baseHslRef.current;\n      wash.addColorStop(0, `hsl(${h} ${Math.min(60, s)}% 50% / 0.10)`);\n      wash.addColorStop(1, \"rgba(0,0,0,0)\");\n      ctx.fillStyle = wash;\n      ctx.fillRect(0, 0, LOGW, LOGH);\n\n      if (!gs) return;\n\n      // Tower — only the layers that can be on screen.\n      const first = Math.max(0, gs.layers.length - 30);\n      for (let i = first; i < gs.layers.length; i++) {\n        const L = gs.layers[i]!;\n        const yTop = (i + 1) * LAYER_H;\n        const yBottom = i === 0 ? -LOGH : yTop - LAYER_H;\n        drawBox(gs, L.x, L.z, L.w, L.d, yBottom, yTop, i);\n      }\n\n      // Active sliding block.\n      if (!gs.ended) {\n        const n = gs.layers.length;\n        const below = gs.layers[n - 1]!;\n        const alongX = gs.axis === 0;\n        drawBox(\n          gs,\n          alongX ? gs.pos : below.x,\n          alongX ? below.z : gs.pos,\n          below.w,\n          below.d,\n          n * LAYER_H,\n          (n + 1) * LAYER_H,\n          n,\n        );\n      }\n\n      // Falling debris.\n      for (const f of gs.debris) {\n        const alpha = Math.max(0, 1 - f.life / 1.4);\n        drawBox(gs, f.x, f.z, f.w, f.d, f.yTop - LAYER_H - f.fall, f.yTop - f.fall, f.ci, alpha);\n      }\n\n      // Perfect rings — the top face outline expanding outward.\n      for (const r of gs.rings) {\n        const k = r.life / 0.5;\n        const grow = 1 + k * 0.35;\n        const hw = (r.w / 2) * grow;\n        const hd = (r.d / 2) * grow;\n        ctx.globalAlpha = (1 - k) * 0.8;\n        ctx.strokeStyle = t.foreground || \"#000\";\n        ctx.lineWidth = 2;\n        ctx.beginPath();\n        ctx.moveTo(px(r.x - hw, r.z - hd), py(gs, r.x - hw, r.z - hd, r.y));\n        ctx.lineTo(px(r.x + hw, r.z - hd), py(gs, r.x + hw, r.z - hd, r.y));\n        ctx.lineTo(px(r.x + hw, r.z + hd), py(gs, r.x + hw, r.z + hd, r.y));\n        ctx.lineTo(px(r.x - hw, r.z + hd), py(gs, r.x - hw, r.z + hd, r.y));\n        ctx.closePath();\n        ctx.stroke();\n        ctx.globalAlpha = 1;\n      }\n\n      // Big height counter — the signature Stack look.\n      if (statusRef.current === \"playing\" || gs.layers.length > 1) {\n        ctx.fillStyle = t.foreground || \"#000\";\n        ctx.globalAlpha = 0.85;\n        ctx.font = \"bold 44px ui-sans-serif, system-ui, -apple-system, sans-serif\";\n        ctx.textAlign = \"center\";\n        ctx.textBaseline = \"top\";\n        ctx.fillText(String(gs.layers.length - 1), LOGW / 2, 18);\n        ctx.globalAlpha = 1;\n      }\n\n      // Floating texts (screen space).\n      for (const tx of gs.texts) {\n        const a = Math.max(0, 1 - tx.life / tx.maxLife);\n        ctx.globalAlpha = a;\n        ctx.fillStyle = t.primary || \"#3b82f6\";\n        ctx.font = \"bold 18px ui-sans-serif, system-ui, -apple-system, sans-serif\";\n        ctx.textAlign = \"center\";\n        ctx.textBaseline = \"middle\";\n        ctx.fillText(tx.text, tx.sx, tx.sy - tx.life * 30);\n      }\n      ctx.globalAlpha = 1;\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);\n      last = now;\n\n      const active = statusRef.current === \"playing\" && !pausedRef.current && !offscreenRef.current;\n\n      if (active) step(rawDt);\n      draw();\n    };\n\n    raf = requestAnimationFrame(frame);\n    return () => cancelAnimationFrame(raf);\n  }, []);\n\n  // Pointer / touch — one tap drops the block.\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const onPointerDown = () => {\n      if (statusRef.current === \"playing\") dropRef.current();\n    };\n    canvas.addEventListener(\"pointerdown\", onPointerDown);\n    return () => canvas.removeEventListener(\"pointerdown\", onPointerDown);\n  }, []);\n\n  // Keyboard — Space/Enter/Up drop the block (or start a run).\n  React.useEffect(() => {\n    const onKeyDown = (e: KeyboardEvent) => {\n      const t = e.target as HTMLElement | null;\n      if (t && (t.isContentEditable || /^(input|textarea|select)$/i.test(t.tagName))) return;\n      if (e.repeat) return;\n      const key = e.key === \"w\" || e.key === \"W\" ? \"ArrowUp\" : e.key;\n      if (key === \" \" || key === \"Enter\" || key === \"ArrowUp\") {\n        e.preventDefault();\n        if (statusRef.current === \"playing\") dropRef.current();\n        else start();\n      }\n    };\n    const target: Window | HTMLElement | null = captureGlobalKeys ? window : wrapperRef.current;\n    target?.addEventListener(\"keydown\", onKeyDown as EventListener);\n    return () => target?.removeEventListener(\"keydown\", onKeyDown as EventListener);\n  }, [start, captureGlobalKeys]);\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=\"Stack game\"\n      style={width ? { maxWidth: width } : undefined}\n      className={cn(\n        \"relative flex w-full select-none flex-col gap-2 outline-none\",\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 rounded-md border bg-secondary px-2 py-0.5 font-medium text-secondary-foreground tabular-nums\">\n          Height {score}\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          ? `Tower height ${score}`\n          : status === \"gameover\"\n            ? `Game over. Final height ${score}`\n            : \"Stack. Press Start, then tap to drop blocks.\"}\n      </div>\n\n      {/* Canvas container */}\n      <div\n        ref={playfieldRef}\n        className=\"relative aspect-[3/4] 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\">Tower down!</p>\n                  <p className=\"text-sm text-muted-foreground\">\n                    Height {score}\n                    {score >= high && score > 0 ? \" — new best!\" : \"\"}\n                  </p>\n                </>\n              ) : (\n                <>\n                  <p className=\"text-lg font-semibold\">Stack</p>\n                  <p className=\"text-sm text-muted-foreground\">\n                    Tap to drop the sliding block. Land flush to keep your size — perfect streaks\n                    grow it back.\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 ? \"Stack again\" : \"Start\"}\n              </button>\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport default Stack;\n",
      "type": "registry:component",
      "target": "@/components/games/stack.tsx"
    }
  ],
  "categories": [
    "games"
  ],
  "meta": {
    "iframeHeight": "620px",
    "surface": "canvas"
  }
}
