Files
two-keys/CLAUDE.md
T
joshuaandClaude Opus 5.5 52745935ee V1.3.0
Game pack 3: 22 new games (19-40), 40 in total.

- New engines: dial (Wavelength / Mind Meld), twin (Twin Rooms /
  Spot the Difference, incl. mirror mode) and blueprint (Describe &
  Draw, incl. peeks, overlap scoring and a rotated Builder canvas)
- Labyrinth: items and doors (Hedge Maze)
- Defuse: manualFrom, roles swapping every level behind a Ready card,
  and duplicate wire modules (Tick Tock: Switch)
- Generic time limits for the new engines; sync/defuse successEnding
- Library: engine-label chips and filters; Noto Color Emoji font
- Validation: Appendix A checks ported, with a per-game summary
- Quote comma/colon-split labels in five new YAMLs (wording unchanged)
- Tests for all new engines and sanitized payloads of all 22 games
- Docs: CLAUDE.md, GAMES.md (pack 3 spoiler guide), How to Play

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:21:24 +02:00

332 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: <game-id>`** (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