{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "memory-match",
  "type": "registry:component",
  "title": "Memory Match",
  "description": "Flip cards to find matching pairs in as few moves as possible.",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/games/memory-match.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/* -------------------------------------------------------------------------- */\n/*                               Shared helpers                                */\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// 8 distinct symbols rendered as large bold characters — A through H.\nconst SYMBOLS = [\"A\", \"B\", \"C\", \"D\", \"E\", \"F\", \"G\", \"H\"] as const;\ntype Symbol = (typeof SYMBOLS)[number];\n\nconst GRID_SIZE = 16; // 4×4\n\ninterface Card {\n  id: number;\n  symbol: Symbol;\n  state: \"face-down\" | \"flipped\" | \"matched\";\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                Shuffle util                                 */\n/* -------------------------------------------------------------------------- */\n\nfunction buildDeck(): Card[] {\n  // Duplicate each symbol to form pairs, then shuffle.\n  const pairs: Symbol[] = [...SYMBOLS, ...SYMBOLS];\n  // Fisher-Yates shuffle with Math.random\n  for (let i = pairs.length - 1; i > 0; i--) {\n    const j = Math.floor(Math.random() * (i + 1));\n    const tmp = pairs[i];\n    const src = pairs[j];\n    if (tmp !== undefined && src !== undefined) {\n      pairs[i] = src;\n      pairs[j] = tmp;\n    }\n  }\n  return pairs.map((symbol, id) => ({ id, symbol, state: \"face-down\" as const }));\n}\n\n// Deterministic face-down deck for the idle screen so the board reserves its\n// full size before the game starts (and matches SSR markup — no random tiles).\nfunction placeholderDeck(): Card[] {\n  return Array.from({ length: GRID_SIZE }, (_, id) => ({\n    id,\n    symbol: SYMBOLS[id % SYMBOLS.length] as Symbol,\n    state: \"face-down\" as const,\n  }));\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                  Props                                      */\n/* -------------------------------------------------------------------------- */\n\nexport interface MemoryMatchProps {\n  className?: string;\n  autoFocus?: boolean;\n  persistHighScore?: boolean | string;\n  onScoreChange?: (score: number) => void;\n  onGameOver?: (r: { score: number; won: boolean }) => void;\n  onStart?: () => void;\n  width?: number;\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                 Component                                   */\n/* -------------------------------------------------------------------------- */\n\nexport function MemoryMatch({\n  className,\n  autoFocus = true,\n  persistHighScore = true,\n  onScoreChange,\n  onGameOver,\n  onStart,\n  width,\n}: MemoryMatchProps) {\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:memory-match:hi\";\n\n  // ── game state ──────────────────────────────────────────────────────────────\n  const [status, setStatus] = React.useState<\"idle\" | \"playing\" | \"won\">(\"idle\");\n  const [cards, setCards] = React.useState<Card[]>(placeholderDeck);\n  const [moves, setMoves] = React.useState(0);\n  const [best, setBest] = React.useState(0);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  // flipped indices (at most 2 during a turn)\n  const [flipped, setFlipped] = React.useState<[number, number] | [number] | []>([]);\n  // locked while mismatch timeout is running\n  const [locked, setLocked] = React.useState(false);\n\n  // ── load persisted best ─────────────────────────────────────────────────────\n  React.useEffect(() => {\n    if (!storageKey) return;\n    try {\n      const raw = window.localStorage.getItem(storageKey);\n      if (raw) {\n        const parsed = Number(raw);\n        if (parsed > 0) setBest(parsed);\n      }\n    } catch {\n      /* ignore */\n    }\n  }, [storageKey]);\n\n  // ── submit best (lower = better) ────────────────────────────────────────────\n  const submitBest = React.useCallback(\n    (m: number) => {\n      setBest((prev) => {\n        const isNewBest = prev === 0 || m < prev;\n        if (!isNewBest) return prev;\n        if (storageKey) {\n          try {\n            window.localStorage.setItem(storageKey, String(m));\n          } catch {\n            /* ignore */\n          }\n        }\n        return m;\n      });\n    },\n    [storageKey],\n  );\n\n  // ── start / restart ─────────────────────────────────────────────────────────\n  const start = React.useCallback(() => {\n    setCards(buildDeck());\n    setMoves(0);\n    setFlipped([]);\n    setLocked(false);\n    setStatus(\"playing\");\n    setAnnouncement(\"Game started. Flip cards to find matching pairs.\");\n    onStart?.();\n    onScoreChange?.(0);\n    wrapperRef.current?.focus();\n  }, [onStart, onScoreChange]);\n\n  // auto-focus\n  React.useEffect(() => {\n    if (autoFocus) wrapperRef.current?.focus();\n  }, [autoFocus]);\n\n  // ── flip logic ──────────────────────────────────────────────────────────────\n  const handleFlip = React.useCallback(\n    (index: number) => {\n      if (locked || status !== \"playing\") return;\n\n      setCards((prev) => {\n        const card = prev[index];\n        if (!card || card.state !== \"face-down\") return prev;\n        return prev.map((c, i) => (i === index ? { ...c, state: \"flipped\" as const } : c));\n      });\n\n      setFlipped((prev) => {\n        if (prev.length === 0) {\n          return [index];\n        }\n        if (prev.length === 1) {\n          const firstIdx = prev[0];\n          if (firstIdx === undefined || firstIdx === index) return prev;\n\n          // We need to check outside of this setter; use a ref trick below.\n          return [firstIdx, index] as [number, number];\n        }\n        return prev;\n      });\n    },\n    [locked, status],\n  );\n\n  // ── check for match whenever flipped has 2 entries ──────────────────────────\n  React.useEffect(() => {\n    if (flipped.length !== 2) return;\n\n    const [a, b] = flipped as [number, number];\n    const cardA = cards[a];\n    const cardB = cards[b];\n\n    if (!cardA || !cardB) return;\n\n    const newMoves = moves + 1;\n    setMoves(newMoves);\n    onScoreChange?.(newMoves);\n\n    if (cardA.symbol === cardB.symbol) {\n      // Match!\n      const nextCards = cards.map((c, i) =>\n        i === a || i === b ? { ...c, state: \"matched\" as const } : c,\n      );\n      setCards(nextCards);\n      setFlipped([]);\n\n      const allMatched = nextCards.every((c) => c.state === \"matched\");\n      if (allMatched) {\n        setStatus(\"won\");\n        submitBest(newMoves);\n        const isNewBest = best === 0 || newMoves < best;\n        setAnnouncement(\n          `You won in ${newMoves} moves!${isNewBest ? \" New best!\" : \"\"}`,\n        );\n        onGameOver?.({ score: newMoves, won: true });\n      } else {\n        setAnnouncement(`Match! ${newMoves} moves so far.`);\n      }\n    } else {\n      // No match — lock and flip back after delay.\n      setLocked(true);\n      setAnnouncement(`No match. ${newMoves} moves so far.`);\n      const delay = reduce ? 0 : 700;\n      const timer = setTimeout(() => {\n        setCards((prev) =>\n          prev.map((c, i) =>\n            i === a || i === b ? { ...c, state: \"face-down\" as const } : c,\n          ),\n        );\n        setFlipped([]);\n        setLocked(false);\n      }, delay);\n      return () => clearTimeout(timer);\n    }\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [flipped]);\n  // NOTE: intentionally not including `cards`, `moves`, `best`, etc. in deps\n  // to avoid double-firing; this effect is keyed entirely on the `flipped` pair.\n\n  // ── keyboard: wrapper handles Enter/Space on idle/won ───────────────────────\n  const handleWrapperKey = React.useCallback(\n    (e: React.KeyboardEvent<HTMLDivElement>) => {\n      if ((e.key === \"Enter\" || e.key === \" \") && status !== \"playing\") {\n        e.preventDefault();\n        start();\n      }\n    },\n    [status, start],\n  );\n\n  // ── card bg / text classes ──────────────────────────────────────────────────\n  function cardClasses(state: Card[\"state\"]): string {\n    if (state === \"face-down\") {\n      return \"bg-primary text-primary-foreground\";\n    }\n    if (state === \"flipped\") {\n      return \"bg-secondary text-secondary-foreground\";\n    }\n    // matched\n    return \"bg-accent text-accent-foreground opacity-60\";\n  }\n\n  // ── flip animation class ────────────────────────────────────────────────────\n  // We use a simple scale trick when not reduce. CSS-only via Tailwind.\n  function transitionClass(state: Card[\"state\"]): string {\n    if (reduce) return \"\";\n    if (state === \"face-down\") return \"transition-transform duration-200\";\n    return \"transition-transform duration-200 scale-95\";\n  }\n\n  const isNewBestOnWin = status === \"won\" && (best === 0 || moves <= best);\n\n  return (\n    <div\n      ref={wrapperRef}\n      tabIndex={0}\n      role=\"application\"\n      aria-label=\"Memory Match game\"\n      onKeyDown={handleWrapperKey}\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-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\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          Moves {moves}\n        </span>\n        <span className=\"text-muted-foreground tabular-nums\">\n          Best {best === 0 ? \"—\" : best}\n        </span>\n      </div>\n\n      {/* ── board ─────────────────────────────────────────────────────────── */}\n      <div className=\"relative overflow-hidden rounded-lg border bg-muted p-2\">\n        <div\n          className=\"@container grid grid-cols-4 grid-rows-4 w-full aspect-square gap-[3%]\"\n        >\n          {cards.map((card, index) => (\n            <button\n              key={card.id}\n              type=\"button\"\n              disabled={card.state !== \"face-down\" || locked || status !== \"playing\"}\n              aria-label={\n                card.state === \"face-down\"\n                  ? `Card ${index + 1}, face down`\n                  : card.state === \"matched\"\n                    ? `Card ${index + 1}, matched ${card.symbol}`\n                    : `Card ${index + 1}, showing ${card.symbol}`\n              }\n              onClick={() => handleFlip(index)}\n              className={cn(\n                \"size-full flex items-center justify-center rounded-md\",\n                \"text-[clamp(1rem,14cqi,2.5rem)] font-bold\",\n                \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                \"disabled:cursor-default\",\n                cardClasses(card.state),\n                transitionClass(card.state),\n              )}\n            >\n              {card.state !== \"face-down\" ? card.symbol : null}\n            </button>\n          ))}\n        </div>\n\n        {/* ── overlay ─────────────────────────────────────────────────────── */}\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 === \"won\" ? (\n                <>\n                  <p className=\"text-lg font-semibold\">You won!</p>\n                  <p className=\"text-sm text-muted-foreground\">\n                    {moves} moves\n                    {isNewBestOnWin ? \" — new best!\" : \"\"}\n                  </p>\n                </>\n              ) : (\n                <>\n                  <p className=\"text-lg font-semibold\">Memory Match</p>\n                  <p className=\"text-sm text-muted-foreground\">\n                    Flip cards to find all 8 matching pairs.\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 === \"won\" ? \"Play again\" : \"Start\"}\n              </button>\n            </div>\n          </div>\n        )}\n      </div>\n\n      {/* ── aria-live region for screen readers ──────────────────────────── */}\n      <div aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {announcement}\n      </div>\n    </div>\n  );\n}\n\nexport default MemoryMatch;\n",
      "type": "registry:component",
      "target": "@/components/games/memory-match.tsx"
    }
  ],
  "categories": [
    "games"
  ],
  "meta": {
    "iframeHeight": "520px",
    "surface": "dom"
  }
}
