{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "whack-a-mole",
  "type": "registry:component",
  "title": "Whack-a-Mole",
  "description": "Tap the moles before they duck. 30-second pointer-only arcade game.",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/games/whack-a-mole.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\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/*                                  Game types                                 */\n/* -------------------------------------------------------------------------- */\n\ntype GameStatus = \"idle\" | \"playing\" | \"gameover\";\n\ninterface HoleState {\n  active: boolean;   // mole is up\n  hit: boolean;      // brief accent burst after whack\n}\n\nconst HOLE_COUNT = 9;\nconst GAME_DURATION = 30; // seconds\n\n/* -------------------------------------------------------------------------- */\n/*                                   Props                                     */\n/* -------------------------------------------------------------------------- */\n\nexport interface WhackAMoleProps {\n  className?: string;\n  width?: number;\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 WhackAMole({\n  className,\n  width,\n  autoFocus = true,\n  captureGlobalKeys = true,\n  persistHighScore = true,\n  onScoreChange,\n  onGameOver,\n  onStart,\n}: WhackAMoleProps) {\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:whack-a-mole:hi\";\n\n  const [status, setStatus] = React.useState<GameStatus>(\"idle\");\n  const [score, setScore] = React.useState(0);\n  const [high, setHigh] = React.useState(0);\n  const [timeLeft, setTimeLeft] = React.useState(GAME_DURATION);\n  const [holes, setHoles] = React.useState<HoleState[]>(() =>\n    Array.from({ length: HOLE_COUNT }, () => ({ active: false, hit: false })),\n  );\n  const [offscreen, setOffscreen] = React.useState(false);\n\n  // Mutable refs so interval callbacks don't go stale.\n  const statusRef = React.useRef<GameStatus>(\"idle\");\n  const offscreenRef = React.useRef(false);\n  const scoreRef = React.useRef(0);\n  const timeLeftRef = React.useRef(GAME_DURATION);\n  const holesRef = React.useRef<HoleState[]>(\n    Array.from({ length: HOLE_COUNT }, () => ({ active: false, hit: false })),\n  );\n  statusRef.current = status;\n  offscreenRef.current = offscreen;\n\n  // Timer refs.\n  const tickRef = React.useRef<ReturnType<typeof setInterval> | null>(null);\n  const spawnRef = React.useRef<ReturnType<typeof setInterval> | null>(null);\n  const moleTimersRef = React.useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());\n  const hitTimersRef = React.useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());\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\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  // Clear all active timers.\n  const clearAllTimers = React.useCallback(() => {\n    if (tickRef.current) clearInterval(tickRef.current);\n    if (spawnRef.current) clearInterval(spawnRef.current);\n    tickRef.current = null;\n    spawnRef.current = null;\n    for (const id of moleTimersRef.current.values()) clearTimeout(id);\n    moleTimersRef.current.clear();\n    for (const id of hitTimersRef.current.values()) clearTimeout(id);\n    hitTimersRef.current.clear();\n  }, []);\n\n  // Pop a mole up at index i, then auto-retract after durationMs.\n  const popMole = React.useCallback(\n    (i: number) => {\n      // Don't pop if already active.\n      if (holesRef.current[i]?.active) return;\n\n      const next = holesRef.current.map((h, idx) =>\n        idx === i ? { active: true, hit: false } : h,\n      );\n      holesRef.current = next;\n      setHoles([...next]);\n\n      // Stay up for 700–1100ms.\n      const stayMs = 700 + Math.random() * 400;\n      const existingTimer = moleTimersRef.current.get(i);\n      if (existingTimer !== undefined) clearTimeout(existingTimer);\n\n      const timer = setTimeout(() => {\n        moleTimersRef.current.delete(i);\n        const retracted = holesRef.current.map((h, idx) =>\n          idx === i ? { ...h, active: false } : h,\n        );\n        holesRef.current = retracted;\n        setHoles([...retracted]);\n      }, stayMs);\n      moleTimersRef.current.set(i, timer);\n    },\n    [],\n  );\n\n  const endGame = React.useCallback(() => {\n    clearAllTimers();\n    const finalScore = scoreRef.current;\n    setStatus(\"gameover\");\n    statusRef.current = \"gameover\";\n    // Retract all moles.\n    const cleared = holesRef.current.map(() => ({ active: false, hit: false }));\n    holesRef.current = cleared;\n    setHoles([...cleared]);\n    submitHigh(finalScore);\n    onGameOver?.({ score: finalScore, won: false });\n  }, [clearAllTimers, submitHigh, onGameOver]);\n\n  // Start spawning and tick intervals.\n  const startIntervals = React.useCallback(() => {\n    // Countdown tick every second.\n    tickRef.current = setInterval(() => {\n      if (offscreenRef.current) return;\n      timeLeftRef.current -= 1;\n      setTimeLeft(timeLeftRef.current);\n      if (timeLeftRef.current <= 0) {\n        endGame();\n      }\n    }, 1000);\n\n    // Spawn moles every 600–900ms.\n    const spawnTick = () => {\n      if (offscreenRef.current || statusRef.current !== \"playing\") return;\n      // Pick 1–2 random holes to pop.\n      const count = Math.random() < 0.3 ? 2 : 1;\n      const indices = Array.from({ length: HOLE_COUNT }, (_, idx) => idx);\n      for (let c = 0; c < count; c++) {\n        const remaining = indices.filter((idx) => !holesRef.current[idx]?.active);\n        if (remaining.length === 0) break;\n        const pick = Math.floor(Math.random() * remaining.length);\n        const chosen = remaining[pick];\n        if (chosen !== undefined) popMole(chosen);\n      }\n    };\n\n    const baseInterval = 750;\n    spawnRef.current = setInterval(spawnTick, baseInterval);\n  }, [endGame, popMole]);\n\n  const start = React.useCallback(() => {\n    clearAllTimers();\n    scoreRef.current = 0;\n    timeLeftRef.current = GAME_DURATION;\n    const fresh = Array.from({ length: HOLE_COUNT }, () => ({ active: false, hit: false }));\n    holesRef.current = fresh;\n    setScore(0);\n    setTimeLeft(GAME_DURATION);\n    setHoles([...fresh]);\n    setStatus(\"playing\");\n    statusRef.current = \"playing\";\n    onStart?.();\n    onScoreChange?.(0);\n    startIntervals();\n  }, [clearAllTimers, startIntervals, onStart, onScoreChange]);\n\n  // Cleanup on unmount.\n  React.useEffect(() => {\n    return () => clearAllTimers();\n  }, [clearAllTimers]);\n\n  const handleHoleClick = React.useCallback(\n    (i: number) => {\n      if (statusRef.current !== \"playing\") return;\n      const hole = holesRef.current[i];\n      if (!hole?.active || hole.hit) return;\n\n      // Cancel auto-retract.\n      const moleTimer = moleTimersRef.current.get(i);\n      if (moleTimer !== undefined) {\n        clearTimeout(moleTimer);\n        moleTimersRef.current.delete(i);\n      }\n\n      // Apply hit burst.\n      const withHit = holesRef.current.map((h, idx) =>\n        idx === i ? { active: true, hit: true } : h,\n      );\n      holesRef.current = withHit;\n      setHoles([...withHit]);\n\n      // Update score.\n      scoreRef.current += 1;\n      const newScore = scoreRef.current;\n      setScore(newScore);\n      onScoreChange?.(newScore);\n\n      // Retract after hit flash.\n      const flashMs = reduce ? 0 : 150;\n      const existingHit = hitTimersRef.current.get(i);\n      if (existingHit !== undefined) clearTimeout(existingHit);\n      const hitTimer = setTimeout(() => {\n        hitTimersRef.current.delete(i);\n        const retracted = holesRef.current.map((h, idx) =>\n          idx === i ? { active: false, hit: false } : h,\n        );\n        holesRef.current = retracted;\n        setHoles([...retracted]);\n      }, flashMs);\n      hitTimersRef.current.set(i, hitTimer);\n    },\n    [onScoreChange, reduce],\n  );\n\n  React.useEffect(() => {\n    if (autoFocus) wrapperRef.current?.focus();\n  }, [autoFocus]);\n\n  // Keyboard: Enter/Space to start/restart.\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 === \"Enter\") {\n        if (statusRef.current !== \"playing\") {\n          e.preventDefault();\n          start();\n        }\n      }\n    };\n    // Single-game pages can opt into window-level capture so keys work without\n    // focusing the game; otherwise listen on the wrapper (focus-scoped).\n    const target: Window | HTMLElement | null = captureGlobalKeys\n      ? window\n      : wrapperRef.current;\n    target?.addEventListener(\"keydown\", onKey as EventListener);\n    return () => {\n      target?.removeEventListener(\"keydown\", onKey as EventListener);\n    };\n  }, [start, captureGlobalKeys]);\n\n  const isNewBest = score >= high && score > 0 && status === \"gameover\";\n\n  return (\n    <div\n      ref={wrapperRef}\n      tabIndex={0}\n      role=\"application\"\n      aria-label=\"Whack-a-Mole 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 / timer bar */}\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\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          {timeLeft}s\n        </span>\n        <span className=\"text-muted-foreground tabular-nums\">Best {high}</span>\n      </div>\n\n      {/* Hidden live region for accessibility announcements */}\n      <div\n        aria-live=\"polite\"\n        aria-atomic=\"true\"\n        className=\"sr-only\"\n      >\n        {status === \"gameover\"\n          ? `Game over. You scored ${score}.${isNewBest ? \" New best!\" : \"\"}`\n          : status === \"playing\"\n            ? `Score ${score}, ${timeLeft} seconds remaining`\n            : \"Whack-a-Mole. Press Start to play.\"}\n      </div>\n\n      {/* Game grid */}\n      <div className=\"relative overflow-hidden rounded-lg border bg-background p-4\">\n        <div className=\"grid w-full aspect-square grid-cols-3 grid-rows-3 gap-[4%]\">\n          {holes.map((hole, i) => (\n            <HoleButton\n              key={i}\n              index={i}\n              hole={hole}\n              reduce={reduce}\n              onClick={handleHoleClick}\n              disabled={status !== \"playing\"}\n            />\n          ))}\n        </div>\n\n        {/* Overlay for idle / game over */}\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                    {isNewBest ? \" — new best!\" : \"\"}\n                  </p>\n                </>\n              ) : (\n                <>\n                  <p className=\"font-semibold text-lg\">Whack-a-Mole</p>\n                  <p className=\"text-muted-foreground text-sm\">\n                    Tap the moles before they disappear!\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      </div>\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                              HoleButton sub-component                       */\n/* -------------------------------------------------------------------------- */\n\ninterface HoleButtonProps {\n  index: number;\n  hole: HoleState;\n  reduce: boolean;\n  onClick: (i: number) => void;\n  disabled: boolean;\n}\n\nfunction HoleButton({ index, hole, reduce, onClick, disabled }: HoleButtonProps) {\n  const handleClick = React.useCallback(() => {\n    onClick(index);\n  }, [index, onClick]);\n\n  return (\n    <button\n      type=\"button\"\n      onClick={handleClick}\n      disabled={disabled}\n      aria-label={\n        hole.active\n          ? `Hole ${index + 1} — mole present, click to whack`\n          : `Hole ${index + 1} — empty`\n      }\n      className={cn(\n        // Hole container: aspect-square fills its grid cell, full width\n        \"relative size-full overflow-hidden rounded-full border-2 border-border bg-muted\",\n        \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1\",\n        !disabled && \"cursor-pointer\",\n        disabled && \"cursor-default\",\n      )}\n    >\n      {/* Mole — sized relative to the hole; slides up from the bottom when active */}\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          \"absolute inset-x-0 bottom-0 flex justify-center\",\n          hole.active || hole.hit ? \"translate-y-[14%]\" : \"translate-y-full\",\n          !reduce && \"transition-transform duration-150 ease-out\",\n        )}\n      >\n        {/* Mole head — 62% of the hole width, square */}\n        <span\n          className={cn(\n            \"relative block aspect-square w-[62%] rounded-t-full rounded-b-[45%]\",\n            hole.hit ? \"bg-accent ring-2 ring-accent ring-offset-1 ring-offset-background\" : \"bg-primary\",\n          )}\n        >\n          {/* Eyes */}\n          <span className=\"absolute top-[28%] left-1/2 flex -translate-x-1/2 gap-[22%]\">\n            <span\n              className={cn(\n                \"block size-[3px] rounded-full sm:size-[4px]\",\n                hole.hit ? \"bg-accent-foreground/70\" : \"bg-primary-foreground/70\",\n              )}\n            />\n            <span\n              className={cn(\n                \"block size-[3px] rounded-full sm:size-[4px]\",\n                hole.hit ? \"bg-accent-foreground/70\" : \"bg-primary-foreground/70\",\n              )}\n            />\n          </span>\n          {/* Snout */}\n          <span\n            className={cn(\n              \"absolute bottom-[18%] left-1/2 block h-[22%] w-[46%] -translate-x-1/2 rounded-full\",\n              hole.hit ? \"bg-accent-foreground/30\" : \"bg-primary-foreground/30\",\n            )}\n          />\n        </span>\n      </span>\n    </button>\n  );\n}\n\nexport default WhackAMole;\n",
      "type": "registry:component",
      "target": "@/components/games/whack-a-mole.tsx"
    }
  ],
  "categories": [
    "games"
  ],
  "meta": {
    "iframeHeight": "500px",
    "surface": "dom"
  }
}
