Files
two-keys/CLAUDE.md
T
joshuaandClaude Opus 5.5 0de6617c82 V1.0.0
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>
2026-10-12 22:36:12 +02:00

15 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 (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.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-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 | 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.

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. 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. After maxAttempts, show failEnding + reveal.
  • 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).

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.

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

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