- 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>
279 lines
20 KiB
Markdown
279 lines
20 KiB
Markdown
# 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
|