Two Keys: co-op mystery games for couples. Eight games on four engines (story, defuse, sync, labyrinth), two-device and couch play, mobile-first UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 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 (8 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 |
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-3
players: { min: 2, max: 2 }
theme: { primary, accent, background, headingFont }
roles: { A: {name, blurb}, B: {name, blurb} }
intro: markdown
story engine (games 1–5)
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
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.
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. Confirm dialog before revealing tier 3 ("This basically solves it. Sure?").
- 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. - 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).
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.
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.
7. Socket events
Client → server:
room:create {gameId}, room:join {code, playerToken?}, room:pickRole {role}, room:ready,
lock:submit {lockId, value}, hint:request {lockId}, chapter:continue,
finale:propose {answers}, finale:agree, finale:submit,
defuse:action {moduleId, action}, sync:answer {questionId, optionId},
maze:move {dir}, 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 😅
- 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 🔑🔑🔑).
- 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
- Defuse: all fixtures solve to their expected solution
- Labyrinth: grid rows equal length; exactly one S and E; exactly 3 K; every K and E reachable from S without stepping on a trap (BFS)
- Sync: ≥15 questions per round type 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 8 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