# Two Keys: Co-op Mysteries for Couples > Build spec for Claude Code. Read this whole file before writing any code. > Game content lives in `content/games/*.yaml`. **Never hard-code puzzle answers in client code.** ## 1. What we're building A website where **two players (a couple)** play co-op mystery games together. Each player sees **different information**, so neither can solve anything alone. They talk it out, pool clues, and enter answers into shared locks. Every lock solved earns a **key**. Enough keys unravel the mystery. **Core rule of every game: Player A and Player B never see the same clue set.** Every lock needs info from both. ### Game library (40 games, 7 engines) | # | Game | Category | Engine | File | |---|------|----------|--------|------| | 1 | Death at Vineyard Hollow | Murder Mystery | `story` | `vineyard-hollow.yaml` | | 2 | The Last Set at the Blue Lantern | Murder Mystery | `story` | `blue-lantern.yaml` | | 3 | Murder on the Southern Star | Murder Mystery | `story` | `southern-star.yaml` | | 4 | The Clockmaker's Attic | Escape Room | `story` | `clockmakers-attic.yaml` | | 5 | Signal Lost: Station Echo | Escape Room | `story` | `station-echo.yaml` | | 6 | Tick Tock Together | Co-op Defuse | `defuse` | `tick-tock-together.yaml` | | 7 | In Sync | Couples Connection | `sync` | `in-sync.yaml` | | 8 | Labyrinth of Lanterns | Co-op Navigation | `labyrinth` | `labyrinth-of-lanterns.yaml` | | 9 | Dead Air | Murder Mystery | `story` | `dead-air.yaml` | | 10 | The Masquerade at Montague House | Murder Mystery | `story` | `masquerade-montague.yaml` | | 11 | The Kalahari Expedition | Murder Mystery | `story` | `kalahari-expedition.yaml` | | 12 | The Alchemist's Vault | Escape Room | `story` | `alchemists-vault.yaml` | | 13 | Submerged: Deep Lab Nereid | Escape Room | `story` | `deep-lab-nereid.yaml` | | 14 | The Kestrel Bank Job | Escape Room | `story` | `kestrel-heist.yaml` | | 15 | Tick Tock: Overtime | Co-op Defuse | `defuse` | `tick-tock-overtime.yaml` | | 16 | In Sync: Deep Cuts | Couples Connection | `sync` | `in-sync-deep-cuts.yaml` | | 17 | The Drowned Catacombs | Co-op Navigation | `labyrinth` | `drowned-catacombs.yaml` | | 18 | The Mirror Maze | Co-op Navigation | `labyrinth` | `mirror-maze.yaml` | | 19 | The Treehouse | Escape Room | `story` | `the-treehouse.yaml` | | 20 | Twin Rooms: Sunday Morning | Spot the Difference | `twin` | `twin-sunday-morning.yaml` | | 21 | Blueprint: First Sketch | Describe & Draw | `blueprint` | `blueprint-first-sketch.yaml` | | 22 | Wavelength: Tune In | Mind Meld | `dial` | `wavelength-tune-in.yaml` | | 23 | Death at the Braai | Murder Mystery | `story` | `death-at-the-braai.yaml` | | 24 | In Sync: Road Trip | Couples Connection | `sync` | `in-sync-road-trip.yaml` | | 25 | Wavelength: Date Night | Mind Meld | `dial` | `wavelength-date-night.yaml` | | 26 | Twin Rooms: The Twin Cafรฉs | Spot the Difference | `twin` | `twin-cafes.yaml` | | 27 | Curtain Call | Murder Mystery | `story` | `curtain-call.yaml` | | 28 | The Magician's Dressing Room | Escape Room | `story` | `magicians-dressing-room.yaml` | | 29 | Blueprint: Stained Glass | Describe & Draw | `blueprint` | `blueprint-stained-glass.yaml` | | 30 | The Hedge Maze at Midnight | Co-op Navigation | `labyrinth` | `hedge-maze-midnight.yaml` | | 31 | Snowbound at the Edelweiss | Murder Mystery | `story` | `snowbound-edelweiss.yaml` | | 32 | The Pharaoh's Tomb | Escape Room | `story` | `pharaohs-tomb.yaml` | | 33 | Twin Rooms: Through the Looking Glass | Spot the Difference | `twin` | `twin-looking-glass.yaml` | | 34 | Tick Tock: Switch | Co-op Defuse | `defuse` | `tick-tock-switch.yaml` | | 35 | The Hawthorn Park Cipher | Murder Mystery | `story` | `hawthorn-park-cipher.yaml` | | 36 | The Inventor's Paradox | Escape Room | `story` | `inventors-paradox.yaml` | | 37 | Blueprint: Blind Mirror | Describe & Draw | `blueprint` | `blueprint-blind-mirror.yaml` | | 38 | Wavelength: Static | Mind Meld | `dial` | `wavelength-static.yaml` | | 39 | 'Til Death Do Us Part | Murder Mystery | `story` | `til-death-do-us-part.yaml` | | 40 | The Last Lighthouse | Escape Room | `story` | `the-lighthouse.yaml` | ## 2. Tech stack - **Monorepo** with npm workspaces: `client/`, `server/`, `shared/`, `content/` - **Client:** Vite + React 18 + TypeScript + Tailwind CSS + React Router + `socket.io-client` + Framer Motion (subtle transitions only) - **Server:** Node 20 + TypeScript + Express + Socket.io + `js-yaml` + `zod` - **Shared:** TypeScript types + the answer normalizer (used by tests; the server is the only thing that checks answers) - **Tests:** Vitest (server + shared), Playwright smoke test (optional, phase 6) - **Storage:** In-memory room store, snapshotted to `server/data/rooms.json` every 10s and on shutdown so a restart doesn't kill a game in progress. Rooms expire after 24h idle. - **Deploy target:** single Node process serves the built client + websocket (Render / Railway / Fly.io). No serverless, as we need persistent websockets. ### Folder structure ``` two-keys/ CLAUDE.md GAMES.md # spoiler guide (designer reference, never served) package.json # workspaces content/games/*.yaml # all game content (source of truth) shared/src/ types.ts # Game, Chapter, Clue, Lock, Room, etc. normalize.ts # answer normalizer server/src/ index.ts # express + socket.io bootstrap content/loader.ts # loads + zod-validates YAML at boot content/sanitize.ts # strips answers, filters clues by role rooms/store.ts # in-memory store + snapshot rooms/codes.ts # 5-char room codes (no 0/O/1/I) engines/story.ts engines/defuse.ts # generator + solver engines/sync.ts engines/labyrinth.ts engines/dial.ts twin.ts blueprint.ts socket/handlers.ts scripts/validate-content.ts client/src/ pages/Home.tsx Library.tsx Lobby.tsx Play.tsx Results.tsx HowToPlay.tsx engines/StoryGame/ DefuseGame/ SyncGame/ LabyrinthGame/ DialGame/ TwinGame/ BlueprintGame/ components/ClueCard.tsx LockPanel.tsx KeyRing.tsx HintButton.tsx Notepad.tsx PartnerStatus.tsx Timer.tsx PassDeviceScreen.tsx lib/socket.ts session.ts theme.ts ``` ## 3. Rooms, roles, and play modes ### Two-device mode (default) 1. Player creates a game from the library โ†’ server creates a room with a 5-char code (e.g. `K7MRX`) and a share link `/join/K7MRX`. 2. Creator picks role **A** or **B** (each game names its roles, e.g. "Inspector" / "Estate Insider"). Partner joins and gets the other role. 3. Game starts when both are in and both tap **Ready**. 4. Each browser stores `{roomCode, playerToken}` in `localStorage` (wrapped in try/catch) for **reconnect**. Refreshing the page rejoins the same seat. ### Couch mode (one device) - One phone/laptop, both players. Role views alternate behind a **"Pass to [Role B name] ๐Ÿ‘€ (no peeking)"** cover screen with a tap-to-reveal. - Locks and the key ring are visible in both views. ### Partner presence - Show partner online/offline dot, which chapter they're viewing, and a **"Ping"** button (sends a little ๐Ÿ‘‹ / ๐Ÿ” / ๐Ÿ’ก / โค๏ธ reaction that floats on the partner's screen). - **Shared notepad** synced live (debounced 300ms, last-write-wins is fine). - No in-app chat. Couples are on the couch or on a call. Add a line on the lobby: "Best played on a call or side by side." ## 4. Content model (YAML โ†’ zod) All game files share a header: ```yaml id: string # matches filename engine: story | defuse | sync | labyrinth | dial | twin | blueprint category: murder-mystery | escape-room | co-op title, tagline: string estimatedMinutes: number difficulty: 1-5 players: { min: 2, max: 2 } theme: { primary, accent, background, headingFont } roles: { A: {name, blurb}, B: {name, blurb} } intro: markdown ``` ### `story` engine (games 1โ€“5, 9โ€“14, 19, 23, 27, 28, 31, 32, 35, 36, 39, 40) ```yaml rules: # optional hintPenaltySeconds: number # default 120 timeLimitMinutes: number # hard limit on elapsed + penalties; running out ends the game timeLimitLabel: string # what the countdown is called, e.g. "Water level" suspects: # optional (murder mysteries) - { id, name, role, description } chapters: - id, title, narrative (markdown, both players see it) clues: A: [Clue] # only Player A ever receives these B: [Clue] # only Player B shared: [Clue] # both lock: id, prompt inputType: code | text | choice format: string # shown under the input, e.g. "4 digits", "HH:MM" options: [...] # only for choice answers: [string] # SERVER ONLY, any match = correct hints: [string, string, string] # tier 1 nudge โ†’ tier 3 near-solution successText: markdown # revealed to both on unlock keyName: string # name of the key earned, e.g. "Cellar Key" finale: type: accusation | escape prompt # accusation only: questions: [{ id, label, options: [{id, label}], answer }] # answer SERVER ONLY maxAttempts: number failEnding, reveal: markdown # escape only: clues: { A: [Clue], B: [Clue] } # finale gets its own split clues lock: Lock # same shape as a chapter lock (no keyName needed) # both: successEnding: markdown failEnding: markdown # optional on escape: shown when the time limit runs out ``` Use a zod discriminated union on `finale.type`. **Clue object:** `{ id, title, type, body, signal? }` - `type` โˆˆ `note | letter | document | report | log | table | photo | map | transcript | signal | diagram` - `body` is markdown (tables allowed). Render each type with its own card style: - `note`: handwritten-ish (Caveat font), slight rotation - `letter`: paper texture, serif - `report` / `document` / `log`: typewriter / monospace header strip - `photo`: polaroid frame; body text is the photo description in italics (no image assets needed) - `map` / `diagram`: monospace block, preserve whitespace, grid tables - `signal`: animated blinking light driven by `signal` field (see ยง6.3) - Clues from **previous** chapters stay accessible in an "Evidence Locker" drawer. The locker searches clue titles and bodies (client-side, own clues only), filters by chapter, and lets each player star clues (`clue:pin`). The partner only ever gets the **count** of stars, never which clues. - Clue text uses `Noto Sans Symbols 2` as a fallback (Braille, alchemy symbols). Code fences (stencil grids, box-drawing keypads, SQL) are monospace at `line-height: 1.15`; wide tables and blocks scroll inside their card, never the page. ### Other engines See each YAML for its schema. Summaries in ยง6. ## 5. Security & answer checking (important) - The server loads YAML and keeps the full version in memory. - `sanitize.ts` builds the **per-role payload**: removes every `answers`, `answer`, `solution`, finale `answer` field, and removes the *other* role's clues. Write a unit test asserting no sanitized payload contains any answer string. - Also strip per engine: **defuse** โ†’ Technician never gets `manual`, Expert never gets the device state; nobody gets `fixtures` or computed solutions. **labyrinth** โ†’ Explorer never gets `grid`, `glyphs`, `lanterns` positions; Navigator never gets the Explorer's coordinates. **sync** โ†’ never send the partner's answer until both have answered. **dial** โ†’ the guesser never gets the target until they lock in; the clue-giver never gets the guesser's live needle. **twin** โ†’ `changes` never leave the server; each player gets only their own room, and the partner only learns *that* a square was tapped, never which. **blueprint** โ†’ the Builder never gets `rows` or `title` until the picture is scored; the Architect never gets the canvas except during a peek or after scoring. **labyrinth items/doors** โ†’ positions never go to the Explorer. **signal clues** โ†’ send the `signal` pattern only for clues of type `signal`, and the client must never print it as text. - Hints are only sent once revealed (never ship unrevealed hint tiers to the client). - **Only the server checks answers.** Client sends `lock:submit {lockId, value}`. - **Normalizer** (`shared/normalize.ts`): uppercase โ†’ strip accents โ†’ remove everything except `A-Z0-9`. So `"20:40"`, `"20 40"` โ†’ `2040`; `"Fire Swart, Friday!"` โ†’ `FIRESWARTFRIDAY`. Compare normalized input to each normalized entry in `answers`. - Rate-limit submissions: max 1 per 2s per room per lock; after 5 wrong answers on one lock, show "Need a hint?" nudge. ## 6. Engines ### 6.1 Story engine (games 1โ€“5) - State: `currentChapter`, `keys[]`, `hintsUsed{lockId: tier}`, `wrongAttempts{}`, `startedAt`, `penaltySeconds`, `finaleAttempts`. - Chapter view: narrative on top โ†’ my clues โ†’ lock panel โ†’ key ring. - Correct answer โ†’ both players see the lock spring open (animation), `successText` reveals, the key flies onto the key ring, **Continue** button (either player can press) advances both. - **Hints:** either player can request; tiers unlock in order; both players see revealed hints. Each hint adds **+2:00** penalty (or the game's `rules.hintPenaltySeconds`). Confirm dialog before revealing tier 3 ("This basically solves it. Sure?"). - **Time limit:** games with `rules.timeLimitMinutes` show a countdown instead of a stopwatch. When elapsed + penalty time passes the limit, the server ends the game with `failEnding` (plus `reveal` for an accusation) and the results page shows **Out of time** instead of a rank. A `timeLimitLabel` containing "water" (Deep Lab Nereid) also draws the countdown as water rising behind the header (no wave with `prefers-reduced-motion`). - **Finale:** when all chapter keys are collected, the finale opens. For `accusation`, both players must agree: Player A proposes answers, Player B sees them and taps **Agree** or **Change**. Submit only when both agree. Wrong submission shows "Not quite. N attempts left" without revealing which part was wrong. After `maxAttempts`, show `failEnding` + `reveal`. Any number of questions works. On the last attempt (always, with `maxAttempts: 1`) submitting asks "One shot. Are you both sure?" first. - For `escape` finales, it's just the last lock; on success show `successEnding`. ### 6.2 Defuse engine (game 6). See `tick-tock-together.yaml` - Player A (**Technician**) sees the device. Player B (**Expert**) sees only the manual. - Server **generates** each device from a seeded RNG and computes the solution with a **solver implementing the manual rules exactly**. Never generate a device the solver can't solve. - Implement the fixtures in the YAML as unit tests. They must pass. - Strikes: 3 per level. Timer per level. Clock speeds up 25% after each strike. - Each level cleared = 1 key. Fail a level โ†’ retry with a new seed (no key lost). - **Rulesets:** each manual's rules are code in `engines/defuse.ts`, picked by the YAML's `ruleset` (`classic` = Tick Tock Together, `overtime` = Tick Tock: Overtime). A new manual needs a new ruleset. - **`manualFrom: `** (Tick Tock: Switch): the loader takes `ruleset`, `device`, `modules`, `manual` and `fixtures` from that game's file, so it uses that game's solver. The file itself only has `levels`, `strikes` and the options below. Loading fails if the source game is missing. - **`swapRolesEachLevel`**: A is the Technician on levels 1, 3, 5, B on 2 and 4. Before each level both see a "๐Ÿ”„ Swap! You're now the Technician" card and tap Ready (`level:ready`); the clock starts when both have. - A level may list `wires` more than once: each becomes its own module (`wires-1`, `wires-2`, shown as "Wires A", "Wires B"), generated and solved separately. No other module may repeat. `strikesOverride: 1` is valid. - **Overtime** adds indicator lights, a per-level `strikesOverride`, and three modules: `passwords` (cycle five letter columns, submit the one listed word they spell), `morse` (read a blinking light, tune 3.500โ€“3.600 MHz, transmit) and `simon` (press the mapped colour for every flash; the mapping moves with the strike count). The Technician gets the Morse pattern only as a light, and only the flashes of the current stage. ### 6.3 Signal clues (game 5) `signal: "./-.-./..../---"`: `.` = 250ms on, `-` = 750ms on, 250ms gap between symbols, `/` = 1000ms gap between letters, 3000ms pause, then loop. Render as a glowing circle. Add a "Replay" button. Never render the dot/dash text to that player (they must read the light). ### 6.4 Sync engine (game 7). See `in-sync.yaml` - Rounds of 5 questions. Both answer privately; answers reveal simultaneously with a flip animation. - `match` rounds: both pick from the same options. Match = point. - `predict` rounds: the `subject` role answers about themselves, the other guesses. Match = point. - 3+ points in a round = key. Failing a round lets them replay it once with fresh questions. - Draw questions randomly from the bank, never repeating within a session. (When a `predict` bank is too small for that, a question may come back once, asked about the other partner.) - `telepathy` rounds (Deep Cuts): no options, both type one word (max 30 characters). Same word = point; case, spaces, punctuation and a trailing S are ignored. - `rank` rounds (Deep Cuts): the subject (cycled through `subjectPattern`) orders four items (drag, or up/down buttons), the partner predicts. Point only if **both** the top and the bottom match; the reveal highlights both. - A round can set its own `pointsForKey`. ### 6.5 Labyrinth engine (game 8). See `labyrinth-of-lanterns.yaml` - Player B (**Explorer**) sees only their current cell: four direction buttons (disabled where wall), glyph on the floor if any, key if present. - Player A (**Navigator**) sees the full map with glyphs, keys, traps, and exit, but **NOT** the Explorer's position. The Navigator has to work out where they are from what the Explorer describes. - Traps are invisible to the Explorer. Stepping on one sends them back to Start (keys kept) and adds 1:00 penalty. - Each lantern key collected = key. Exit only opens with all 3. - Server validates every move. - `lanternsRequired` sets how many lanterns the maze has (default 3). - `oneWay` gates (Drowned Catacombs): passable from `{x, y}` in `dir` only. The Navigator sees an arrow; the Explorer sees the way back as a gate, not a wall. - `explorer.compass: relative` (Mirror Maze): the Explorer moves forward/left/right/back from the way they face (their last step), and never sees N/E/S/W. - `navigatorView.mapTransform: mirror-x` (Mirror Maze): the Navigator's map, and everything on it, is served flipped left to right. - `items` and `doors` (Hedge Maze): items (on plain `.` floor) are picked up on entry and kept after traps; a door cell is shut until the Explorer carries the item it `needs`. Bumping into it shows `lockedText` (no move), the first pass shows `openText`. The Explorer has an inventory strip of item names; the Navigator sees ๐Ÿšช and ๐Ÿ— on the map, items dimmed once collected, and gets no toast about pickups. ### 6.6 Dial engine (Wavelength ยท "Mind Meld", games 22, 25, 38) - Each round: an unused spectrum (two opposite labels) and a random integer target at least `targetMargin` inside `range`. The clue-giver alternates: **A on odd rounds, B on even**. - The clue-giver sees the target as wedges (the scoring bands) and sends `dial:clue`. The server rejects, with a toast: empty clues, more than `maxWords` words or `maxChars` characters, any word from either label (`banLabelWords`), and any word used earlier in the game (`noRepeatWords`). Words are compared uppercase, without accents or punctuation, minus one trailing S. - The guesser sees the clue ("๐Ÿคซ Clue only!") and drags the needle (arrow keys ยฑ1, shift ยฑ5). `dial:needle` is throttled, kept for a refresh and never broadcast; `dial:lock` scores it: the first band whose `within` covers the distance, else 0. - `guesserSeesLabels: left` replaces the right label with "โ–’โ–’โ–’ static โ–’โ–’โ–’" (`none` hides both); `guesserSeesNumbers: false` hides the tick numbers. Both show again at the reveal. - `keys` are cumulative point thresholds, awarded the moment the total reaches them. After `rounds` the game ends: success only with every key. ### 6.7 Twin engine (Twin Rooms ยท "Spot the Difference", games 20, 26, 33) - Each scene's `grid` is A's room; rows split on single spaces (emoji can be ZWJ sequences, never split by character). B's room is the grid with `changes` applied, and with `mirrorB` it's served flipped left to right (B's taps are un-mirrored with `x โ†’ width โˆ’ 1 โˆ’ x`). - Cells render in **Noto Color Emoji**, with U+FE0F added to text-default symbols (๐Ÿ› ๐Ÿ•ฏ ๐Ÿ–ผ โ˜‚ โœ‰ โœ โ˜˜โ€ฆ) so both phones match. - A tap (`twin:tap`) marks the square on your own screen and waits `claimWindowSeconds` for the partner, who's told "Your partner tapped a square". Same square in time: a difference is found (โœ… on both, counter "3 / 6"), anything else costs `wrongClaimPenaltySeconds` off the scene clock (and onto the final time). A different square replaces the earlier tap. - Every difference found = the scene's key. A scene that times out can be retried once with the clock reset; after that it moves on with no key. `rules.timeLimitMinutes` (Looking Glass) still applies on top. Success only with every key. ### 6.8 Blueprint engine (Describe & Draw, games 21, 29, 37) - The **Architect** (A, or alternating A, B, Aโ€ฆ with `swapRolesEachPattern`) sees the pixel picture; the **Builder** has a blank `size`ร—`size` canvas, the palette (swatch plus name) and an eraser. Tap or drag to paint, Undo (20 steps), Clear, Submit (with a confirm). Paints go to the server in debounced batches (`blueprint:paint`), so a refresh keeps the canvas. - **Peek** (`blueprint:peek`, `peeks` per picture) shows the Architect the canvas for 3 seconds. - **Scoring (`overlap`)**: cells painted on both with the same colour รท cells painted on either. A blank canvas scores 0. Pass at `threshold`; with `submitsPerPattern: 2` the best of two counts. Running out of time scores the canvas as it is. The reveal overlays the picture on the canvas (green right, red wrong, dashed missed). - **`rotateBuilder: 90`**: the canvas is stored and scored in target orientation and shown to the Builder turned 90ยฐ clockwise: displayed (column c, row r) on an nร—n grid is stored (x = r, y = n โˆ’ 1 โˆ’ c). - Each passed picture = its key. Success only with every key. ## 7. Socket events Client โ†’ server: `room:create {gameId}`, `room:join {code, playerToken?}`, `room:pickRole {role}`, `room:ready`, `lock:submit {lockId, value}`, `hint:request {lockId}`, `clue:pin {clueId}`, `chapter:continue`, `finale:propose {answers}`, `finale:agree`, `finale:submit`, `defuse:action {moduleId, action}`, `sync:answer {questionId, optionId | text | order}`, `maze:move {dir}` (`n/e/s/w`, or `f/l/r/b` / `forward/left/right/back` with a relative compass), `level:ready` (defuse swap card), `dial:clue {text}`, `dial:needle {value}` (guesser only, never broadcast), `dial:lock {value}`, `twin:tap {x, y}`, `blueprint:paint {cells: [{x, y, color | null}]}`, `blueprint:peek`, `blueprint:submit`, `notepad:update {text}`, `ping {emoji}` Server โ†’ client: `room:state` (full sanitized per-role state, sent on every change), `lock:result {lockId, correct}`, `toast {message}`, `ping {from, emoji}`, `error {code, message}` Keep it simple: after any mutation, broadcast a fresh per-role `room:state` to each socket. ## 8. Scoring & results - `finalTime = elapsed + penaltySeconds` - Rank badges: **Soulmate Sleuths** (no hints, under estimate) / **Dream Team** / **Getting There** / **Love Is Patient** ๐Ÿ˜… / **Out of time** (a time limit ran out) - **Nightmare Survivors โ˜ ๏ธ**: a difficulty-5 game finished with zero hints. Shown on the results page and, once earned, on that game's library card. - Results page: time, hints used, keys earned, wrong guesses, plus a "Play another" carousel. Store best results per game in localStorage. ## 9. Design direction - Vibe: **date-night cozy meets noir**. Dark mode default, warm candlelit accents, per-game theme from YAML `theme`. - Fonts: Playfair Display / Inter / Caveat (handwritten notes) / JetBrains Mono (logs, maps) / Noto Color Emoji (Twin Rooms) from Google Fonts. - Home page: hero "Two players. One mystery. Every key needs both of you." + game library grid (cards show category chip, time, difficulty ๐Ÿ”‘ out of 5; difficulty 5 adds a **โ˜ ๏ธ Nightmare** chip). Difficulty: 1 Easy ยท 2 Medium ยท 3 Hard ยท 4 Expert ยท 5 Nightmare. The library sorts easiest first and filters by category and difficulty. The dial, twin and blueprint games (`category: co-op`) show their engine label as the chip ("Mind Meld", "Spot the Difference", "Describe & Draw") and each has its own filter. - **Mobile-first.** Most couples will play on two phones. Clue cards stack vertically; lock panel is sticky at the bottom; evidence locker is a bottom sheet. - Accessibility: all locks keyboard-operable, `prefers-reduced-motion` respected, colorblind-safe (defuse wires also show their color name as text label). - Role color-coding: A = warm (amber), B = cool (teal), shown as a badge on every clue card ("Only you can see this"). ## 10. Content validation script `npm run validate-content` must check: - Every YAML parses and passes its engine's zod schema - Story: every chapter has โ‰ฅ1 clue for A **and** โ‰ฅ1 for B; every lock has โ‰ฅ1 answer and exactly 3 hints; finale answers exist in options; clue ids are unique; every signal clue has a `signal`; escape finales have A and B clues - Defuse: all fixtures solve to their expected solution (with the game's ruleset); every module a level uses is defined (in the `manualFrom` game, if set, which must exist); only `wires` may repeat in a level; for `overtime`, the keypad columns, password words and Morse frequencies match the solver's tables (`OVERTIME_TABLES`) and every password fixture column has exactly `lettersPerColumn` letters - Labyrinth: grid rows equal length; exactly one S and E; exactly `lanternsRequired` K; every K and E reachable from S **without stepping on a trap** (BFS, respecting one-way gates); no cell where a gate leaves the Explorer stuck for good; glyphs and gates never on walls or traps; every `lanterns` entry sits on a K; items and doors on plain floor, every door needs a known item, and with doors the reachability and stuck checks play the maze out carrying items - Sync: โ‰ฅ15 questions per round type in use; every rank question has exactly 4 items; no option that looks split by an unquoted comma (quote any option or label containing a comma) - Choice options (`{ id, label }`) are strict, so a stray key from an unquoted comma fails validation - Dial: spectra โ‰ฅ max(30, rounds ร— 2) with unique ids; bands widen and score less as they do; key thresholds go up and are reachable - Twin: rows have equal cell counts; every change is on the grid, actually changes the cell, and has its own cell; `differences` matches the change count - Blueprint: `rows` are `size`ร—`size`; every colour is on the palette; the picture isn't empty; `0 < threshold โ‰ค 1` The report prints a one-line summary per game (the TypeScript port of GAMES-PACK-3.md Appendix A). Run it in CI and as a pre-start step. ## 11. Build phases (do in order, commit after each) 1. **Scaffold** monorepo, shared types, normalizer + tests, YAML loader + zod schemas + validate script. 2. **Rooms**: create/join/role/ready/reconnect, lobby UI, home + library pages. 3. **Story engine** end-to-end with `vineyard-hollow`. Then confirm games 2โ€“5 load and play with no code changes. 4. **Hints, timer, penalties, finale agreement flow, results page.** 5. **Defuse, Sync, Labyrinth** engines. 6. Couch mode, notepad, pings, polish, theming, mobile QA, Playwright smoke test (two browser contexts complete Clockmaker's Attic). ## 12. Acceptance criteria - [ ] Two phones can play any of the 40 games start to finish - [ ] Player A cannot find B's clues or any answer via DevTools / network tab - [ ] Refreshing mid-game rejoins the same role and progress - [ ] All answers listed in the YAML are accepted; normalizer handles spaces/case/punctuation - [ ] `npm run validate-content` passes; all unit tests pass - [ ] Works at 375px wide with no horizontal scroll