V1.3.0
Game pack 3: 22 new games (19-40), 40 in total. - New engines: dial (Wavelength / Mind Meld), twin (Twin Rooms / Spot the Difference, incl. mirror mode) and blueprint (Describe & Draw, incl. peeks, overlap scoring and a rotated Builder canvas) - Labyrinth: items and doors (Hedge Maze) - Defuse: manualFrom, roles swapping every level behind a Ready card, and duplicate wire modules (Tick Tock: Switch) - Generic time limits for the new engines; sync/defuse successEnding - Library: engine-label chips and filters; Noto Color Emoji font - Validation: Appendix A checks ported, with a per-game summary - Quote comma/colon-split labels in five new YAMLs (wording unchanged) - Tests for all new engines and sanitized payloads of all 22 games - Docs: CLAUDE.md, GAMES.md (pack 3 spoiler guide), How to Play Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ A website where **two players (a couple)** play co-op mystery games together. Ea
|
||||
|
||||
**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 library (40 games, 7 engines)
|
||||
|
||||
| # | Game | Category | Engine | File |
|
||||
|---|------|----------|--------|------|
|
||||
@@ -31,6 +31,28 @@ A website where **two players (a couple)** play co-op mystery games together. Ea
|
||||
| 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` |
|
||||
| 19 | The Treehouse | Escape Room | `story` | `the-treehouse.yaml` |
|
||||
| 20 | Twin Rooms: Sunday Morning | Spot the Difference | `twin` | `twin-sunday-morning.yaml` |
|
||||
| 21 | Blueprint: First Sketch | Describe & Draw | `blueprint` | `blueprint-first-sketch.yaml` |
|
||||
| 22 | Wavelength: Tune In | Mind Meld | `dial` | `wavelength-tune-in.yaml` |
|
||||
| 23 | Death at the Braai | Murder Mystery | `story` | `death-at-the-braai.yaml` |
|
||||
| 24 | In Sync: Road Trip | Couples Connection | `sync` | `in-sync-road-trip.yaml` |
|
||||
| 25 | Wavelength: Date Night | Mind Meld | `dial` | `wavelength-date-night.yaml` |
|
||||
| 26 | Twin Rooms: The Twin Cafés | Spot the Difference | `twin` | `twin-cafes.yaml` |
|
||||
| 27 | Curtain Call | Murder Mystery | `story` | `curtain-call.yaml` |
|
||||
| 28 | The Magician's Dressing Room | Escape Room | `story` | `magicians-dressing-room.yaml` |
|
||||
| 29 | Blueprint: Stained Glass | Describe & Draw | `blueprint` | `blueprint-stained-glass.yaml` |
|
||||
| 30 | The Hedge Maze at Midnight | Co-op Navigation | `labyrinth` | `hedge-maze-midnight.yaml` |
|
||||
| 31 | Snowbound at the Edelweiss | Murder Mystery | `story` | `snowbound-edelweiss.yaml` |
|
||||
| 32 | The Pharaoh's Tomb | Escape Room | `story` | `pharaohs-tomb.yaml` |
|
||||
| 33 | Twin Rooms: Through the Looking Glass | Spot the Difference | `twin` | `twin-looking-glass.yaml` |
|
||||
| 34 | Tick Tock: Switch | Co-op Defuse | `defuse` | `tick-tock-switch.yaml` |
|
||||
| 35 | The Hawthorn Park Cipher | Murder Mystery | `story` | `hawthorn-park-cipher.yaml` |
|
||||
| 36 | The Inventor's Paradox | Escape Room | `story` | `inventors-paradox.yaml` |
|
||||
| 37 | Blueprint: Blind Mirror | Describe & Draw | `blueprint` | `blueprint-blind-mirror.yaml` |
|
||||
| 38 | Wavelength: Static | Mind Meld | `dial` | `wavelength-static.yaml` |
|
||||
| 39 | 'Til Death Do Us Part | Murder Mystery | `story` | `til-death-do-us-part.yaml` |
|
||||
| 40 | The Last Lighthouse | Escape Room | `story` | `the-lighthouse.yaml` |
|
||||
|
||||
## 2. Tech stack
|
||||
|
||||
@@ -63,11 +85,12 @@ two-keys/
|
||||
engines/defuse.ts # generator + solver
|
||||
engines/sync.ts
|
||||
engines/labyrinth.ts
|
||||
engines/dial.ts twin.ts blueprint.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/
|
||||
engines/StoryGame/ DefuseGame/ SyncGame/ LabyrinthGame/ DialGame/ TwinGame/ BlueprintGame/
|
||||
components/ClueCard.tsx LockPanel.tsx KeyRing.tsx HintButton.tsx
|
||||
Notepad.tsx PartnerStatus.tsx Timer.tsx PassDeviceScreen.tsx
|
||||
lib/socket.ts session.ts theme.ts
|
||||
@@ -96,7 +119,7 @@ All game files share a header:
|
||||
|
||||
```yaml
|
||||
id: string # matches filename
|
||||
engine: story | defuse | sync | labyrinth
|
||||
engine: story | defuse | sync | labyrinth | dial | twin | blueprint
|
||||
category: murder-mystery | escape-room | co-op
|
||||
title, tagline: string
|
||||
estimatedMinutes: number
|
||||
@@ -107,7 +130,7 @@ roles: { A: {name, blurb}, B: {name, blurb} }
|
||||
intro: markdown
|
||||
```
|
||||
|
||||
### `story` engine (games 1–5, 9–14)
|
||||
### `story` engine (games 1–5, 9–14, 19, 23, 27, 28, 31, 32, 35, 36, 39, 40)
|
||||
|
||||
```yaml
|
||||
rules: # optional
|
||||
@@ -167,7 +190,7 @@ See each YAML for its schema. Summaries in §6.
|
||||
|
||||
- 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.
|
||||
- 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. **dial** → the guesser never gets the target until they lock in; the clue-giver never gets the guesser's live needle. **twin** → `changes` never leave the server; each player gets only their own room, and the partner only learns *that* a square was tapped, never which. **blueprint** → the Builder never gets `rows` or `title` until the picture is scored; the Architect never gets the canvas except during a peek or after scoring. **labyrinth items/doors** → positions never go to the Explorer. **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`.
|
||||
@@ -191,6 +214,9 @@ See each YAML for its schema. Summaries in §6.
|
||||
- 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.
|
||||
- **`manualFrom: <game-id>`** (Tick Tock: Switch): the loader takes `ruleset`, `device`, `modules`, `manual` and `fixtures` from that game's file, so it uses that game's solver. The file itself only has `levels`, `strikes` and the options below. Loading fails if the source game is missing.
|
||||
- **`swapRolesEachLevel`**: A is the Technician on levels 1, 3, 5, B on 2 and 4. Before each level both see a "🔄 Swap! You're now the Technician" card and tap Ready (`level:ready`); the clock starts when both have.
|
||||
- A level may list `wires` more than once: each becomes its own module (`wires-1`, `wires-2`, shown as "Wires A", "Wires B"), generated and solved separately. No other module may repeat. `strikesOverride: 1` is valid.
|
||||
- **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)
|
||||
@@ -216,6 +242,27 @@ See each YAML for its schema. Summaries in §6.
|
||||
- `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.
|
||||
- `items` and `doors` (Hedge Maze): items (on plain `.` floor) are picked up on entry and kept after traps; a door cell is shut until the Explorer carries the item it `needs`. Bumping into it shows `lockedText` (no move), the first pass shows `openText`. The Explorer has an inventory strip of item names; the Navigator sees 🚪 and 🗝 on the map, items dimmed once collected, and gets no toast about pickups.
|
||||
|
||||
### 6.6 Dial engine (Wavelength · "Mind Meld", games 22, 25, 38)
|
||||
- Each round: an unused spectrum (two opposite labels) and a random integer target at least `targetMargin` inside `range`. The clue-giver alternates: **A on odd rounds, B on even**.
|
||||
- The clue-giver sees the target as wedges (the scoring bands) and sends `dial:clue`. The server rejects, with a toast: empty clues, more than `maxWords` words or `maxChars` characters, any word from either label (`banLabelWords`), and any word used earlier in the game (`noRepeatWords`). Words are compared uppercase, without accents or punctuation, minus one trailing S.
|
||||
- The guesser sees the clue ("🤫 Clue only!") and drags the needle (arrow keys ±1, shift ±5). `dial:needle` is throttled, kept for a refresh and never broadcast; `dial:lock` scores it: the first band whose `within` covers the distance, else 0.
|
||||
- `guesserSeesLabels: left` replaces the right label with "▒▒▒ static ▒▒▒" (`none` hides both); `guesserSeesNumbers: false` hides the tick numbers. Both show again at the reveal.
|
||||
- `keys` are cumulative point thresholds, awarded the moment the total reaches them. After `rounds` the game ends: success only with every key.
|
||||
|
||||
### 6.7 Twin engine (Twin Rooms · "Spot the Difference", games 20, 26, 33)
|
||||
- Each scene's `grid` is A's room; rows split on single spaces (emoji can be ZWJ sequences, never split by character). B's room is the grid with `changes` applied, and with `mirrorB` it's served flipped left to right (B's taps are un-mirrored with `x → width − 1 − x`).
|
||||
- Cells render in **Noto Color Emoji**, with U+FE0F added to text-default symbols (🛏 🕯 🖼 ☂ ✉ ✏ ☘…) so both phones match.
|
||||
- A tap (`twin:tap`) marks the square on your own screen and waits `claimWindowSeconds` for the partner, who's told "Your partner tapped a square". Same square in time: a difference is found (✅ on both, counter "3 / 6"), anything else costs `wrongClaimPenaltySeconds` off the scene clock (and onto the final time). A different square replaces the earlier tap.
|
||||
- Every difference found = the scene's key. A scene that times out can be retried once with the clock reset; after that it moves on with no key. `rules.timeLimitMinutes` (Looking Glass) still applies on top. Success only with every key.
|
||||
|
||||
### 6.8 Blueprint engine (Describe & Draw, games 21, 29, 37)
|
||||
- The **Architect** (A, or alternating A, B, A… with `swapRolesEachPattern`) sees the pixel picture; the **Builder** has a blank `size`×`size` canvas, the palette (swatch plus name) and an eraser. Tap or drag to paint, Undo (20 steps), Clear, Submit (with a confirm). Paints go to the server in debounced batches (`blueprint:paint`), so a refresh keeps the canvas.
|
||||
- **Peek** (`blueprint:peek`, `peeks` per picture) shows the Architect the canvas for 3 seconds.
|
||||
- **Scoring (`overlap`)**: cells painted on both with the same colour ÷ cells painted on either. A blank canvas scores 0. Pass at `threshold`; with `submitsPerPattern: 2` the best of two counts. Running out of time scores the canvas as it is. The reveal overlays the picture on the canvas (green right, red wrong, dashed missed).
|
||||
- **`rotateBuilder: 90`**: the canvas is stored and scored in target orientation and shown to the Builder turned 90° clockwise: displayed (column c, row r) on an n×n grid is stored (x = r, y = n − 1 − c).
|
||||
- Each passed picture = its key. Success only with every key.
|
||||
|
||||
## 7. Socket events
|
||||
|
||||
@@ -224,7 +271,9 @@ Client → server:
|
||||
`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}`
|
||||
`maze:move {dir}` (`n/e/s/w`, or `f/l/r/b` / `forward/left/right/back` with a relative compass), `level:ready` (defuse swap card),
|
||||
`dial:clue {text}`, `dial:needle {value}` (guesser only, never broadcast), `dial:lock {value}`, `twin:tap {x, y}`,
|
||||
`blueprint:paint {cells: [{x, y, color | null}]}`, `blueprint:peek`, `blueprint:submit`, `notepad:update {text}`, `ping {emoji}`
|
||||
|
||||
Server → client:
|
||||
`room:state` (full sanitized per-role state, sent on every change), `lock:result {lockId, correct}`,
|
||||
@@ -242,8 +291,8 @@ Keep it simple: after any mutation, broadcast a fresh per-role `room:state` to e
|
||||
## 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.
|
||||
- Fonts: Playfair Display / Inter / Caveat (handwritten notes) / JetBrains Mono (logs, maps) / Noto Color Emoji (Twin Rooms) 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. The dial, twin and blueprint games (`category: co-op`) show their engine label as the chip ("Mind Meld", "Spot the Difference", "Describe & Draw") and each has its own filter.
|
||||
- **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").
|
||||
@@ -253,10 +302,14 @@ Keep it simple: after any mutation, broadcast a fresh per-role `room:state` to e
|
||||
`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
|
||||
- Defuse: all fixtures solve to their expected solution (with the game's ruleset); every module a level uses is defined (in the `manualFrom` game, if set, which must exist); only `wires` may repeat in a level; 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; items and doors on plain floor, every door needs a known item, and with doors the reachability and stuck checks play the maze out carrying items
|
||||
- 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
|
||||
- Dial: spectra ≥ max(30, rounds × 2) with unique ids; bands widen and score less as they do; key thresholds go up and are reachable
|
||||
- Twin: rows have equal cell counts; every change is on the grid, actually changes the cell, and has its own cell; `differences` matches the change count
|
||||
- Blueprint: `rows` are `size`×`size`; every colour is on the palette; the picture isn't empty; `0 < threshold ≤ 1`
|
||||
The report prints a one-line summary per game (the TypeScript port of GAMES-PACK-3.md Appendix A).
|
||||
Run it in CI and as a pre-start step.
|
||||
|
||||
## 11. Build phases (do in order, commit after each)
|
||||
@@ -270,7 +323,7 @@ Run it in CI and as a pre-start step.
|
||||
|
||||
## 12. Acceptance criteria
|
||||
|
||||
- [ ] Two phones can play any of the 18 games start to finish
|
||||
- [ ] Two phones can play any of the 40 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
|
||||
|
||||
Reference in New Issue
Block a user