- Quote options and labels containing commas so they no longer split (In Sync: Deep Cuts d12/d15; accusation options in Blue Lantern, Dead Air, Kalahari Expedition and Masquerade) - Validation: strict choice options; flag sync options that look split - Hint sheet: timed games say hints take time off the countdown - How to Play: hint cost covers timed games Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
20 KiB
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 (18 games, 4 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 |
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.jsonevery 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
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/
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)
- 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. - Creator picks role A or B (each game names its roles, e.g. "Inspector" / "Estate Insider"). Partner joins and gets the other role.
- Game starts when both are in and both tap Ready.
- Each browser stores
{roomCode, playerToken}inlocalStorage(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:
id: string # matches filename
engine: story | defuse | sync | labyrinth
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)
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 | diagrambodyis markdown (tables allowed). Render each type with its own card style:note: handwritten-ish (Caveat font), slight rotationletter: paper texture, serifreport/document/log: typewriter / monospace header stripphoto: polaroid frame; body text is the photo description in italics (no image assets needed)map/diagram: monospace block, preserve whitespace, grid tablessignal: animated blinking light driven bysignalfield (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 2as a fallback (Braille, alchemy symbols). Code fences (stencil grids, box-drawing keypads, SQL) are monospace atline-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.tsbuilds the per-role payload: removes everyanswers,answer,solution, finaleanswerfield, 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 getsfixturesor computed solutions. labyrinth → Explorer never getsgrid,glyphs,lanternspositions; Navigator never gets the Explorer's coordinates. sync → never send the partner's answer until both have answered. signal clues → send thesignalpattern only for clues of typesignal, 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 exceptA-Z0-9. So"20:40","20 40"→2040;"Fire Swart, Friday!"→FIRESWARTFRIDAY. Compare normalized input to each normalized entry inanswers. - 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),
successTextreveals, 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.timeLimitMinutesshow a countdown instead of a stopwatch. When elapsed + penalty time passes the limit, the server ends the game withfailEnding(plusrevealfor an accusation) and the results page shows Out of time instead of a rank. AtimeLimitLabelcontaining "water" (Deep Lab Nereid) also draws the countdown as water rising behind the header (no wave withprefers-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. AftermaxAttempts, showfailEnding+reveal. Any number of questions works. On the last attempt (always, withmaxAttempts: 1) submitting asks "One shot. Are you both sure?" first. - For
escapefinales, it's just the last lock; on success showsuccessEnding.
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'sruleset(classic= Tick Tock Together,overtime= Tick Tock: Overtime). A new manual needs a new ruleset. - 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) andsimon(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.
matchrounds: both pick from the same options. Match = point.predictrounds: thesubjectrole 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
predictbank is too small for that, a question may come back once, asked about the other partner.) telepathyrounds (Deep Cuts): no options, both type one word (max 30 characters). Same word = point; case, spaces, punctuation and a trailing S are ignored.rankrounds (Deep Cuts): the subject (cycled throughsubjectPattern) 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.
lanternsRequiredsets how many lanterns the maze has (default 3).oneWaygates (Drowned Catacombs): passable from{x, y}indironly. 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.
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), 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) 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.
- 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-motionrespected, 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; for
overtime, the keypad columns, password words and Morse frequencies match the solver's tables (OVERTIME_TABLES) and every password fixture column has exactlylettersPerColumnletters - Labyrinth: grid rows equal length; exactly one S and E; exactly
lanternsRequiredK; 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; everylanternsentry sits on a K - 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 Run it in CI and as a pre-start step.
11. Build phases (do in order, commit after each)
- Scaffold monorepo, shared types, normalizer + tests, YAML loader + zod schemas + validate script.
- Rooms: create/join/role/ready/reconnect, lobby UI, home + library pages.
- Story engine end-to-end with
vineyard-hollow. Then confirm games 2–5 load and play with no code changes. - Hints, timer, penalties, finale agreement flow, results page.
- Defuse, Sync, Labyrinth engines.
- 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 18 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-contentpasses; all unit tests pass- Works at 375px wide with no horizontal scroll