{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "flappy",
  "type": "registry:component",
  "title": "Flappy",
  "description": "Tap to flap through the pipes. A tiny infinite runner for loading screens.",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/games/flappy.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\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/*                               Game constants                               */\n/* -------------------------------------------------------------------------- */\n\n// Logical playfield dimensions. All physics/drawing happen in this fixed\n// coordinate space; the canvas is scaled to its rendered size at draw time.\nconst LOGW = 300;\nconst LOGH = 400;\n\nconst BIRD_X_FRAC = 0.25; // bird fixed x as fraction of width\nconst BIRD_RADIUS = 14; // bird half-size (drawn as rounded square)\nconst GRAVITY = 1800; // px/s² (normal)\nconst GRAVITY_REDUCED = 600; // px/s² (reduced motion)\nconst FLAP_VY = -480; // px/s (normal)\nconst FLAP_VY_REDUCED = -280; // px/s (reduced motion)\nconst PIPE_WIDTH = 52;\nconst PIPE_GAP = 130; // vertical gap between top/bottom pipes\nconst PIPE_SPEED = 180; // px/s (normal)\nconst PIPE_SPEED_REDUCED = 90; // px/s (reduced motion)\nconst PIPE_SPACING = 230; // horizontal distance between pipes\nconst GROUND_H = 24; // height of ground strip\n\n/* -------------------------------------------------------------------------- */\n/*                                Game types                                   */\n/* -------------------------------------------------------------------------- */\n\ninterface Pipe {\n  x: number;\n  gapY: number; // center y of gap\n  scored: boolean;\n}\n\ninterface FlappyLiveState {\n  birdY: number;\n  birdVY: number;\n  pipes: Pipe[];\n  score: number;\n  blipAlpha: number; // accent flash when scoring\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                  Component                                  */\n/* -------------------------------------------------------------------------- */\n\nexport interface FlappyProps {\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\nexport function Flappy({\n  className,\n  width,\n  paused = false,\n  autoFocus = true,\n  captureGlobalKeys = true,\n  persistHighScore = true,\n  onScoreChange,\n  onGameOver,\n  onStart,\n}: FlappyProps) {\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:flappy:hi\";\n\n  // Mutable refs the rAF loop reads without re-subscribing.\n  const liveRef = React.useRef<FlappyLiveState>({\n    birdY: LOGH / 2,\n    birdVY: 0,\n    pipes: [],\n    score: 0,\n    blipAlpha: 0,\n  });\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  // Callbacks stored as refs to avoid rAF loop re-creation\n  const onScoreChangeRef = React.useRef(onScoreChange);\n  const onGameOverRef = React.useRef(onGameOver);\n  const onStartRef = React.useRef(onStart);\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  onStartRef.current = onStart;\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  // Keep submitHigh in a ref so the rAF closure can call it\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  // Helper: initialise live state for a fresh game\n  const resetLive = React.useCallback(() => {\n    // Spawn first pipe just off right edge\n    const firstPipeX = LOGW + PIPE_SPACING * 0.5;\n    const gapY = LOGH * 0.4 + Math.random() * (LOGH * 0.25);\n    // Begin mid-flap: give the bird a gentle upward velocity (0.8× a full flap)\n    // so it rises briefly instead of dropping under gravity immediately.\n    const startVY = (reduceRef.current ? FLAP_VY_REDUCED : FLAP_VY) * 0.8;\n    liveRef.current = {\n      birdY: LOGH / 2,\n      birdVY: startVY,\n      pipes: [{ x: firstPipeX, gapY, scored: false }],\n      score: 0,\n      blipAlpha: 0,\n    };\n  }, []);\n\n  const start = React.useCallback(() => {\n    resetLive();\n    setScore(0);\n    setStatus(\"playing\");\n    onStartRef.current?.();\n    onScoreChangeRef.current?.(0);\n    // Focus the wrapper so keyboard controls work after the Start button unmounts.\n    wrapperRef.current?.focus();\n  }, [resetLive]);\n\n  // Flap action — callable from keyboard/touch/click\n  const flap = React.useCallback(() => {\n    if (statusRef.current === \"gameover\") {\n      start();\n      return;\n    }\n    if (statusRef.current === \"idle\") {\n      start();\n      // velocity will be set after start; we set it directly on live state\n    }\n    const vy = reduceRef.current ? FLAP_VY_REDUCED : FLAP_VY;\n    liveRef.current.birdVY = vy;\n  }, [start]);\n\n  // Responsive canvas sizing: match the playfield's rendered CSS size × DPR,\n  // then scale the context so all drawing/physics can use the logical 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  // Drawing + main rAF 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 birdX = LOGW * BIRD_X_FRAC;\n    const playH = LOGH - GROUND_H; // playable height above ground\n\n    const drawRoundRect = (\n      x: number,\n      y: number,\n      w: number,\n      h: number,\n      r: number,\n      fill: string,\n    ) => {\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 live = liveRef.current;\n\n      // Clear + background (logical space)\n      ctx.clearRect(0, 0, LOGW, LOGH);\n      ctx.fillStyle = t.background || \"#fff\";\n      ctx.fillRect(0, 0, LOGW, LOGH);\n\n      // Draw pipes (secondary color)\n      for (const pipe of live.pipes) {\n        const topH = pipe.gapY - PIPE_GAP / 2;\n        const botY = pipe.gapY + PIPE_GAP / 2;\n        const botH = playH - botY;\n        const pipeColor = t[\"muted-foreground\"] || \"#888\";\n        const r = 4;\n        // Top pipe\n        if (topH > 0) drawRoundRect(pipe.x, 0, PIPE_WIDTH, topH, r, pipeColor);\n        // Bottom pipe\n        if (botH > 0) drawRoundRect(pipe.x, botY, PIPE_WIDTH, botH, r, pipeColor);\n      }\n\n      // Ground strip (border/muted)\n      ctx.fillStyle = t.border || t.muted || \"#ccc\";\n      ctx.fillRect(0, playH, LOGW, GROUND_H);\n      // Ground line\n      ctx.fillStyle = t[\"muted-foreground\"] || \"#aaa\";\n      ctx.fillRect(0, playH, LOGW, 2);\n\n      // Score blip overlay (themed flash)\n      if (live.blipAlpha > 0) {\n        ctx.globalAlpha = live.blipAlpha * 0.18;\n        ctx.fillStyle = t.primary || \"#ff0\";\n        ctx.fillRect(0, 0, LOGW, LOGH);\n        ctx.globalAlpha = 1;\n      }\n\n      // Bird (primary, rounded square)\n      const birdColor = t.primary || \"#000\";\n      drawRoundRect(\n        birdX - BIRD_RADIUS,\n        live.birdY - BIRD_RADIUS,\n        BIRD_RADIUS * 2,\n        BIRD_RADIUS * 2,\n        BIRD_RADIUS * 0.45,\n        birdColor,\n      );\n\n      // Eye detail (primary-foreground)\n      const eyeColor = t[\"primary-foreground\"] || \"#fff\";\n      ctx.fillStyle = eyeColor;\n      ctx.beginPath();\n      ctx.arc(birdX + BIRD_RADIUS * 0.35, live.birdY - BIRD_RADIUS * 0.25, 3.5, 0, Math.PI * 2);\n      ctx.fill();\n      ctx.fillStyle = \"#333\";\n      ctx.beginPath();\n      ctx.arc(birdX + BIRD_RADIUS * 0.35, live.birdY - BIRD_RADIUS * 0.25, 1.5, 0, Math.PI * 2);\n      ctx.fill();\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 dt = Math.min((now - last) / 1000, 0.05); // seconds, capped at 50ms\n      last = now;\n\n      const active = statusRef.current === \"playing\" && !pausedRef.current && !offscreenRef.current;\n\n      if (active) {\n        const live = liveRef.current;\n        const isReduce = reduceRef.current;\n        const gravity = isReduce ? GRAVITY_REDUCED : GRAVITY;\n        const pipeSpeed = isReduce ? PIPE_SPEED_REDUCED : PIPE_SPEED;\n        const playH = LOGH - GROUND_H;\n\n        // Physics\n        live.birdVY += gravity * dt;\n        live.birdY += live.birdVY * dt;\n\n        // Move pipes & spawn new ones\n        for (const pipe of live.pipes) {\n          pipe.x -= pipeSpeed * dt;\n        }\n\n        // Remove pipes that scrolled off-screen\n        while (live.pipes.length > 0 && (live.pipes[0]?.x ?? 0) < -PIPE_WIDTH - 10) {\n          live.pipes.shift();\n        }\n\n        // Spawn new pipe when last pipe is far enough left\n        const lastPipe = live.pipes[live.pipes.length - 1];\n        if (!lastPipe || lastPipe.x < LOGW - PIPE_SPACING) {\n          const minGapY = PIPE_GAP / 2 + 30;\n          const maxGapY = playH - PIPE_GAP / 2 - 30;\n          const gapY = minGapY + Math.random() * Math.max(0, maxGapY - minGapY);\n          live.pipes.push({ x: LOGW + PIPE_WIDTH, gapY, scored: false });\n        }\n\n        // Score: bird passed pipe center\n        for (const pipe of live.pipes) {\n          if (!pipe.scored && pipe.x + PIPE_WIDTH < birdX) {\n            pipe.scored = true;\n            live.score += 1;\n            live.blipAlpha = 1;\n            const sc = live.score;\n            setScore(sc);\n            onScoreChangeRef.current?.(sc);\n          }\n        }\n\n        // Decay blip\n        if (live.blipAlpha > 0) {\n          live.blipAlpha = Math.max(0, live.blipAlpha - dt * 4);\n        }\n\n        // Collision: ceiling\n        const hitCeiling = live.birdY - BIRD_RADIUS < 0;\n        // Collision: ground\n        const hitGround = live.birdY + BIRD_RADIUS > playH;\n        // Collision: pipes\n        let hitPipe = false;\n        for (const pipe of live.pipes) {\n          const birdL = birdX - BIRD_RADIUS + 3; // slight shrink for fairness\n          const birdR = birdX + BIRD_RADIUS - 3;\n          const birdT = live.birdY - BIRD_RADIUS + 3;\n          const birdB = live.birdY + BIRD_RADIUS - 3;\n          const pipeL = pipe.x;\n          const pipeR = pipe.x + PIPE_WIDTH;\n          const gapTop = pipe.gapY - PIPE_GAP / 2;\n          const gapBot = pipe.gapY + PIPE_GAP / 2;\n          const overlapX = birdR > pipeL && birdL < pipeR;\n          if (overlapX && (birdT < gapTop || birdB > gapBot)) {\n            hitPipe = true;\n            break;\n          }\n        }\n\n        if (hitCeiling || hitGround || hitPipe) {\n          const finalScore = live.score;\n          setStatus(\"gameover\");\n          submitHighRef.current(finalScore);\n          onGameOverRef.current?.({ score: finalScore, won: false });\n        }\n      } else {\n        last = now; // reset last so no huge dt burst after unpause\n      }\n\n      draw();\n    };\n\n    raf = requestAnimationFrame(frame);\n    return () => cancelAnimationFrame(raf);\n  }, []);\n\n  // Keyboard handling\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      if (e.key === \" \" || e.key === \"ArrowUp\" || e.key === \"w\" || e.key === \"W\") {\n        e.preventDefault();\n        flap();\n      } else if (e.key === \"Enter\") {\n        e.preventDefault();\n        if (statusRef.current !== \"playing\") flap();\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  }, [flap, captureGlobalKeys]);\n\n  // Touch / click — tap/click anywhere on canvas flaps\n  React.useEffect(() => {\n    const el = wrapperRef.current;\n    if (!el) return;\n    const onTap = (e: TouchEvent) => {\n      e.preventDefault();\n      flap();\n    };\n    el.addEventListener(\"touchstart\", onTap, { passive: false });\n    return () => el.removeEventListener(\"touchstart\", onTap);\n  }, [flap]);\n\n  // Canvas click\n  const handleCanvasClick = React.useCallback(() => {\n    flap();\n  }, [flap]);\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=\"Flappy bird 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 badge 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      {/* Canvas wrapper */}\n      <div\n        ref={playfieldRef}\n        className=\"relative aspect-[3/4] w-full overflow-hidden rounded-lg border bg-background\"\n      >\n        <canvas\n          ref={canvasRef}\n          className=\"absolute inset-0 block h-full w-full cursor-pointer touch-none\"\n          onClick={handleCanvasClick}\n        />\n\n        {/* Idle / Game-over overlay */}\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\">Flappy</p>\n                  <p className=\"text-muted-foreground text-sm\">\n                    Space, ↑, or tap to flap.\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                {status === \"gameover\" ? \"Play again\" : \"Start\"}\n              </button>\n            </div>\n          </div>\n        )}\n\n        {/* Aria-live region (hidden visually, readable by screen readers) */}\n        <div\n          aria-live=\"polite\"\n          aria-atomic=\"true\"\n          className=\"sr-only\"\n        >\n          {status === \"gameover\"\n            ? `Game over. You scored ${score}.${score >= high && score > 0 ? \" New best!\" : \"\"}`\n            : status === \"idle\"\n              ? \"Flappy bird. Press Space or tap to start.\"\n              : `Score ${score}`}\n        </div>\n      </div>\n    </div>\n  );\n}\n\nexport default Flappy;\n",
      "type": "registry:component",
      "target": "@/components/games/flappy.tsx"
    }
  ],
  "categories": [
    "games"
  ],
  "meta": {
    "iframeHeight": "520px",
    "surface": "canvas"
  }
}
