Files
two-keys/CLAUDE.md
T
joshuaandClaude Opus 5.5 4ff95e6018 V1.2.2
- 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>
2026-10-15 09:20:16 +02:00

20 KiB
Raw Blame History

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.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
    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)

  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:

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 | 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. 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.
  • 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.

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-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; 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
  • 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)

  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 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-content passes; all unit tests pass
  • Works at 375px wide with no horizontal scroll