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

279 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```yaml
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)
```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. **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