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>
332 lines
28 KiB
Markdown
332 lines
28 KiB
Markdown
# 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
|