3 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 52745935ee 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>
2026-10-15 10:21:24 +02:00
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
joshuaandClaude Opus 5.5 db2653c1ab V1.2.1
Point the Overtime fixtures comment at the real solver: GAMES-NEW.md Appendix A
and its TypeScript port in server/src/engines/defuse.ts, not the missing
tools/reference/overtime_solver.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:48:20 +02:00
67 changed files with 7092 additions and 123 deletions
+66 -12
View File
@@ -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,9 +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
- Sync: ≥15 questions per round type in use; every rank question has exactly 4 items
- 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)
@@ -269,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
+197
View File
@@ -232,3 +232,200 @@ Telepathy (type the same word, 3/5) · Spotlight A (4/5) · Spotlight B (4/5) ·
#KT...#......E#
###############
```
---
# Pack 3: games 19–40
Every lock below needs info from **both** players. "A has / B has" shows the split.
## 19. The Treehouse (escape room · 15 min · 🔑)
Roles: **Up the Tree** (A) / **Down Below** (B).
| Lock | A has | B has | Answer |
|------|-------|-------|--------|
| Lockbox | Comics #4 red, #9 blue, #2 green | "Rainbow order" | `429` |
| Password | Charter order: Sam, Ava, Jo, Ben | Pets: Peanut, Ace, Luna, Socks | `PALS` |
| Treasure | Garden grid (rock at B1) | Map: 3 squares east of the rock | `BIRDBATH` |
| Trapdoor (finale) | "Year we built it, +1 every digit" | Trunk: BUILT 2009 | `3110` |
---
## 23. Death at the Braai (murder mystery · 20 min · 🔑🔑)
Roles: **Detective** (A) / **Neighbour** (B).
| Ch | Lock | A has | B has | Answer |
|----|------|-------|-------|--------|
| 1 | Collapse time | Meat on when coals ready, 20 min | Fire lit 17:10, coals 45 min | `18:15` |
| 2 | Who turned the meat | Someone in the Bulls apron at ~18:00 | Thandi borrowed the Bulls apron at 17:50 | Thandi |
| 3 | Skewer plant | Lab: oleandrin, from the skewer stick | Oleander hedge, branches cut | `OLEANDER` |
| 4 | Gerrit's note | A=1 number note | "A=1, B=2" + Thandi does the books | `AUDIT MONDAY THE TILL IS SHORT` |
**Killer:** Thandi Mokoena · sosatie on an oleander stick swapped onto Gerrit's side · to stop the audit exposing her theft from the butchery.
---
## 27. Curtain Call (murder mystery · 30 min · 🔑🔑🔑)
Roles: **Critic** (A) / **Stage Manager** (B). Hint penalty 3:00.
| Ch | Lock | A has | B has | Answer |
|----|------|-------|-------|--------|
| 1 | Weapon source | Forensic: 21 cm steel, crown-over-B, dusty | Inventory | `FOYER` (1952 display dagger) |
| 2 | Who handled it | Witness: grey gown on audience's RIGHT | Prompt book: Margot (Vivienne) DSL, Ros (Mia) DSR | Vivienne |
| 3 | Speech card | `DJR OD PIY GTOFSU` | Typed one key too far right + keyboard | `SHE IS OUT FRIDAY` |
| 4 | Key sign-out | Room 1 took the display key 19:40–19:55 | Room list; Owen says hairpin opens it | Owen (red herring; Margot's hairpin missing) |
**Killer:** Vivienne Shaw · hairpin opened the foyer case, swapped the 1952 dagger for the prop in the banquet scene · Desmond was firing her at the curtain call.
---
## 28. The Magician's Dressing Room (escape room · 25 min · 🔑🔑🔑)
Roles: **Assistant** (A) / **Stagehand** (B).
| Lock | A has | B has | Answer |
|------|-------|-------|--------|
| Drawer | Magic square with gaps | "Every line adds to the same number" | `7913` (square 276 / 951 / 438) |
| Hat | 5♥ A♠ 2♠ 2♠ 9♠ 7♥ | Card cipher (♠ A–M, ♥ N–Z) | `RABBIT` |
| Picture | Row clues | Column clues | `HEART` (5×5 nonogram, unique) |
| Stage door (finale) | ♠ ♥ ♦ ♣ dial order | Card table | `3521` |
---
## 31. Snowbound at the Edelweiss (murder mystery · 40 min · 🔑🔑🔑🔑)
Roles: **Inspector** (A) / **Concierge** (B). Hint penalty 3:00. **1 attempt, 4 questions.**
| Ch | Lock | A has | B has | Answer |
|----|------|-------|-------|--------|
| 1 | Ski track time | 9 cm in Bruno's prints, 7 cm in ski tracks | Snowfall rates | `00:20` (Bruno walked out 23:40) |
| 2 | Wax colour | Wax rated −10 to −18 °C | Wax chart + wax log | `GREEN` (Hans, Sophie) |
| 3 | Pole signals | Porter's sketch (porter's left/right) | Chart from the signaller's view | `BINDINGS` (swap left/right) |
| 4 | No alibi | Rooms; Sophie poured the pill away | Room 4 (Hans) on the phone 00:05–00:40; others at cards | Sophie |
| 5 | Paper strip | `THCHWCEAICSDRNEAONSAT` | 3 letters per wrap; clipping: S. Weiss prepared Hans's skis | `THE CRASH WAS NO ACCIDENT` |
**Killer:** Sophie Weiss · skied out at 00:20 on green-waxed skis and struck Bruno with a pole · he was about to reveal she loosened Hans's binding. Bruno was signalling to Hans.
---
## 32. The Pharaoh's Tomb (escape room · 35 min · 🔑🔑🔑🔑)
Roles: **Archaeologist** (A) / **Translator** (B). **40-minute "Torch" limit.**
| Lock | A has | B has | Answer |
|------|-------|-------|--------|
| God's name | 🔒 👄 🔒 | Consonant chart + gods list | `OSIRIS` (S-R-S) |
| Two kings | Khaf-Ra numerals (2347) | Number signs + Djed-Ka-Ra (1921) | `426` |
| Floor | Tile grid with symbols + letters | △ = life | `PHARAOH` (only △ path) |
| Sarcophagus (finale) | 🌊 🐍 👄 🍞 👄 | Royals list | `NEFERTARI` (not Nefertiti) |
Floor (verified unique △ path, bottom-left up):
```
○K □D △H ◇M △T
◇E ○S △O ○L □N
□P △R △A ◇F ○B
△H △A ○I □C △X
△P ◇W □V ○G ◇Y
```
---
## 35. The Hawthorn Park Cipher (murder mystery · 60 min · ☠️)
Roles: **Security Officer** (A) / **Hut Supervisor** (B). Hint penalty 4:00. **75-minute limit. 1 attempt, 4 questions.**
| Ch | Lock | A has | B has | Answer |
|----|------|-------|-------|--------|
| 1 | Codename | Intercept + rotor method | Key for 14 JAN = R | `NIGHTINGALE` ("NIGHTINGALE REPORTS FROM HUT SEVEN") |
| 2 | Circled letters | Clues; CO poisoning | Answer slots + circles | `FLUE` (OFFICER, COLD, UBOAT, ENIGMA) |
| 3 | Typewriter | Note's three faults | Machine inventory | `4` (Teleprinter Room: Kemp, Greaves until midnight) |
| 4 | Silent teleprinter | Kemp: "never left"; Greaves alibi | Send log; canteen book (Harrow on phone) | `45` (01:20–02:05) |
| 5 | Eleanor's tape | ●○ rows | 5-bit binary, A=1 | `SHE SINGS` |
| 6 | D. K. | Concert programme: Nightingale solo by D. K. | Choir roster | Dorothy Kemp |
**Killer:** Dorothy "Dot" Kemp (Nightingale) · blocked the stove flue with a rag during the teleprinter silence · forged the note on machine 4 to frame Nowak · Eleanor had uncovered the spy.
---
## 36. The Inventor's Paradox (escape room · 50 min · ☠️)
Roles: **Inside the Lab** (A) / **Archivist** (B). Hint penalty 4:00. **55-minute limit.**
| Lock | A has | B has | Answer |
|------|-------|-------|--------|
| Switchboard | Circuit in private symbols | Symbol legend (⊗ AND, ⊘ OR, ⊕ XOR, ¬ NOT) | `1001` (unique) |
| Chalk grid | Half the 4×4 givens | Other half | `1441` (unique only combined) |
| Return date | "First Friday 13th after her 60th birthday" | Born 14 Apr 1871 + 1931 calendar | `13111931` |
| Fuel mix | Vial colours | Prime vials, average, round down | `#667744` |
| Brass clock | Reflection reads ~4:35 | "Seen in brass", 1↔11 | `07:25` |
| Year dial (finale) | Built 1912 | Age riddle (3× then 2× after 18 years) | `1858` |
---
## 30. The Hedge Maze at Midnight (co-op navigation · 15 min · 🔑🔑🔑)
15×11, 4 traps, 3 lanterns, **1 iron gate + key** (new items/doors feature).
```
############### S (1,1) E (13,9)
#S....#K.T.T..# K (1,8) (5,8) (7,1)
#.#.#.#.#####.# T (3,5) (9,1) (9,3) (11,1)
#...#.#..T#...# D = iron gate (5,7), needs gate-key
#.#.#.##.##.#.# G = gate-key item (5,9)
#.#T..#.....#.#
#.#####.#####.# Verified by state BFS: all reachable
#.#..D..#...#.# without traps, gate only after key,
#K###K###.#.#.# no softlocks. (D and G are '.' in the
#....G#......E# YAML grid; they're defined in lists.)
###############
```
## 24. In Sync: Road Trip (couples connection · 15 min · 🔑🔑)
Match · Telepathy · Spotlight Driver · Spotlight Navigator. 3/5 per key. 4 keys.
## 34. Tick Tock: Switch (co-op defuse · 20 min · 🔑🔑🔑🔑)
Overtime's manual and solver (`manualFrom`). 5 levels, **roles swap every level**, 2 strikes (level 5: 1 strike). Level 4 has two separate wire modules.
## 22 / 25 / 38. Wavelength (Mind Meld)
| Game | Diff | Rounds | Clue | Labels for guesser | Bands (pts 4/3/2/1) | Keys at |
|------|------|--------|------|--------------------|---------------------|---------|
| Tune In | 1 | 6 | ≤5 words | both, numbers | ±5 / 10 / 15 / 20 | 8, 14 |
| Date Night | 2 | 8 | ≤3 words | both, numbers | ±4 / 9 / 14 | 8, 14, 20 |
| Static | 5 | 10 | 1 word, no repeats | left only, no numbers | ±2 / 5 / 8 | 12, 20, 26 |
## 20 / 26 / 33. Twin Rooms (Spot the Difference)
| Game | Diff | Grid | Diffs per room | Coordinates | Mirror | Wrong claim |
|------|------|------|----------------|-------------|--------|-------------|
| Sunday Morning | 1 | 6×5 | 4 | yes | no | −15 s |
| The Twin Cafés | 2 | 8×6 | 6 | no | no | −20 s |
| Through the Looking Glass | 4 | 10×8 | 8 (very subtle: 🍎/🍏, 🌑/🌚, 🕐/🕜) | no | **B mirrored** | −30 s, 25-min limit |
Exact differences are in each YAML's `changes` list (x, y from the top-left of A's grid).
## 21 / 29 / 37. Blueprint (Describe & Draw)
| Game | Diff | Grid | Pictures | Colours | Threshold | Peeks | Submits | Twist |
|------|------|------|----------|---------|-----------|-------|---------|-------|
| First Sketch | 1 | 6×6 | heart, house, smiley | 1 each | 80% | 2 | 2 | none |
| Stained Glass | 3 | 8×8 | rose, sailboat, key, mug | 1–3 | 90% | 1 | 1 | roles swap |
| Blind Mirror | 5 | 10×10 | lighthouse, black cat, apple tree, ring | 3–6 | 95% | 0 | 1 | **Builder canvas rotated 90°**, roles swap, 30-min limit |
---
## 39. 'Til Death Do Us Part (murder mystery · 30 min · 🔑🔑🔑)
Roles: **Detective** (A) / **Photographer's Assistant** (B). Hint penalty 3:00.
| Ch | Lock | A has | B has | Answer |
|----|------|-------|-------|--------|
| 1 | Empty seat 3 | Seating rules 1–4 | Rules 5–8 + video: seats 3 and 5 empty | Sipho (seating verified unique: Anita, Johan, Sipho, Kayla, Jason). Red herring: on the phone 21:30–21:47 |
| 2 | Real photo time | Camera times; IMG_2219 = Jason + woman in navy at 23:45 | Camera is 2 h ahead + 7 min fast; navy = planner team | `21:38` |
| 3 | Toast card | Bold/normal letters | Bacon cipher table (26-letter) | `WIDOW` |
| 4 | The link | Celeste: swallow tattoo, left wrist | Uncle Leon's 2019 wedding photo, same tattoo | `SWALLOW` |
**Killer:** Celeste Moreau (the wedding planner) · pushed Jason down the garden stairs at 21:38 during the first dance · he'd recognised her as the "widow" who married and robbed his uncle.
---
## 40. The Last Lighthouse (escape room · 20 min · 🔑🔑)
Roles: **Lookout** (A) / **Keeper's Helper** (B). **25-minute limit.**
| Lock | A has | B has | Answer |
|------|-------|-------|--------|
| Flags | Four flag descriptions | Signal flag book | `HOPE` (P vs S is the trap) |
| Lens | 4 bullseye panels | One turn per 40 s | `10` |
| Light across the bay | Red light flashing in groups of 3 (signal clue) | Notice to Mariners list | `BIRD ROCK` |
| Storm door (finale) | "Steps from the 2nd floor up" | Tower cross-section | `60` (24 + 20 + 16) |
+1 -1
View File
@@ -9,7 +9,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Caveat:wght@500;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&family=Noto+Sans+Symbols+2&family=Playfair+Display:ital,wght@0,500;0,700;1,500&display=swap"
href="https://fonts.googleapis.com/css2?family=Caveat:wght@500;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;700&family=Noto+Color+Emoji&family=Noto+Sans+Symbols+2&family=Playfair+Display:ital,wght@0,500;0,700;1,500&display=swap"
rel="stylesheet"
/>
<title>Two Keys · Co-op mysteries for couples</title>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@two-keys/client",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"type": "module",
"scripts": {
+5 -2
View File
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { formatClock, type GameSummary, type PlayMode } from '@two-keys/shared';
import { getBest, saveSession } from '../lib/session';
import { request } from '../lib/socket';
import { CATEGORY_LABEL, DIFFICULTY_LABEL, MAX_DIFFICULTY, difficultyKeys, themeVars } from '../lib/theme';
import { DIFFICULTY_LABEL, MAX_DIFFICULTY, chipLabel, difficultyKeys, themeVars } from '../lib/theme';
import { pushToast } from '../lib/toasts';
import { RoleBadge, Sheet } from './ui';
@@ -12,6 +12,9 @@ const ENGINE_BLURB: Record<GameSummary['engine'], string> = {
defuse: 'One sees it, one has the manual',
sync: 'How well do you know each other?',
labyrinth: 'One map, one explorer',
dial: 'One clue, one dial',
twin: 'Same room... almost',
blueprint: 'One describes, one draws',
};
/** A library card, themed with its game's own colours. Opens the "how do you want to play" sheet. */
@@ -37,7 +40,7 @@ export function GameCard({ game, className = '' }: { game: GameSummary; classNam
className="group flex h-full w-full flex-col rounded-2xl border border-white/10 bg-bg bg-gradient-to-br from-primary/60 via-primary/15 to-transparent p-5 text-left shadow-lg shadow-black/30 transition hover:-translate-y-0.5 hover:border-accent/60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
>
<div className="flex flex-wrap items-center gap-2">
<span className="chip text-accent">{CATEGORY_LABEL[game.category]}</span>
<span className="chip text-accent">{chipLabel(game)}</span>
<span className="text-xs text-stone-300">⏱ {game.estimatedMinutes} min</span>
<span
className="text-xs"
+13 -3
View File
@@ -10,7 +10,8 @@ export function HintButton({ lock, send }: { lock: LockView; send: Send }) {
const used = lock.hints.length;
const left = lock.hintsTotal - used;
const nextIsLast = used === lock.hintsTotal - 1;
const cost = `+${formatClock(lock.hintPenaltySeconds)}`;
// A countdown loses time; a stopwatch gains it.
const cost = `${lock.timeLimited ? '−' : '+'}${formatClock(lock.hintPenaltySeconds)}`;
const reveal = () => {
setConfirming(false);
@@ -30,8 +31,17 @@ export function HintButton({ lock, send }: { lock: LockView; send: Send }) {
<Sheet open={open} onClose={() => { setOpen(false); setConfirming(false); }} title="Hints">
<p className="mb-4 text-sm text-stone-400">
Hints go from a gentle nudge to nearly the answer. Each one adds <strong className="text-stone-200">{cost}</strong> to
your time, and you both see it.
Hints go from a gentle nudge to nearly the answer.{' '}
{lock.timeLimited ? (
<>
Each one takes <strong className="text-stone-200">{formatClock(lock.hintPenaltySeconds)}</strong> off the time you
have left, and you both see it.
</>
) : (
<>
Each one adds <strong className="text-stone-200">{cost}</strong> to your time, and you both see it.
</>
)}
</p>
<ol className="space-y-2">
{lock.hints.map((hint, i) => (
+391
View File
@@ -0,0 +1,391 @@
import { useEffect, useRef, useState } from 'react';
import type { BlueprintColor, BlueprintView, Role, RoomState } from '@two-keys/shared';
import { otherRole } from '@two-keys/shared';
import { useNow } from '../lib/hooks';
import type { Send } from '../lib/socket';
interface Props {
view: BlueprintView;
state: RoomState;
role: Role;
receivedAt: number;
send: Send;
}
const EMPTY = '.';
const UNDO_LIMIT = 20;
type Cell = { x: number; y: number; color: string | null };
const formatSeconds = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
const percent = (score: number) => `${Math.round(score * 100)}%`;
/**
* The Builder's canvas is shown rotated clockwise. Displayed (column c, row r) on an n×n grid is
* stored cell (x = r, y = n − 1 − c) for each 90°. The server stores and scores in target orientation.
*/
function toStored(n: number, rotate: number, c: number, r: number) {
let p = { x: c, y: r };
for (let turn = 0; turn < (rotate / 90) % 4; turn++) p = { x: p.y, y: n - 1 - p.x };
return p;
}
function useRemaining(view: BlueprintView, receivedAt: number) {
const now = useNow(250);
return view.status === 'building' ? Math.max(0, view.remaining - (now - receivedAt) / 1000) : view.remaining;
}
/** One square grid. `cellAt` gets displayed coordinates; numbers run along the edges. */
function Grid({
size,
label,
cellAt,
onPaint,
gridlines = true,
}: {
size: number;
label: string;
cellAt: (c: number, r: number) => { fill: string | null; mark?: 'right' | 'wrong' | 'missed' };
onPaint?: (c: number, r: number, start: boolean) => void;
gridlines?: boolean;
}) {
const painting = useRef(false);
const cellFromPoint = (x: number, y: number) => {
const el = document.elementFromPoint(x, y) as HTMLElement | null;
const c = Number(el?.dataset.c);
const r = Number(el?.dataset.r);
return Number.isInteger(c) && Number.isInteger(r) ? { c, r } : null;
};
return (
<div className="mx-auto w-full" style={{ maxWidth: `${size * 44 + 20}px` }}>
<div
className={`grid gap-px ${onPaint ? 'touch-none select-none' : ''}`}
style={{ gridTemplateColumns: `16px repeat(${size}, minmax(0, 1fr))` }}
role="grid"
aria-label={label}
onPointerUp={() => (painting.current = false)}
onPointerLeave={() => (painting.current = false)}
onPointerCancel={() => (painting.current = false)}
onPointerMove={(e) => {
if (!painting.current || !onPaint) return;
const hit = cellFromPoint(e.clientX, e.clientY);
if (hit) onPaint(hit.c, hit.r, false);
}}
>
<span aria-hidden />
{Array.from({ length: size }, (_, c) => (
<span key={c} className="text-center font-mono text-[10px] text-stone-500" aria-hidden>
{c + 1}
</span>
))}
{Array.from({ length: size }, (_, r) => (
<div key={r} role="row" className="contents">
<span className="flex items-center font-mono text-[10px] text-stone-500" aria-hidden>
{r + 1}
</span>
{Array.from({ length: size }, (_, c) => {
const { fill, mark } = cellAt(c, r);
const ring =
mark === 'right' ? 'ring-2 ring-inset ring-emerald-400' : mark === 'wrong' ? 'ring-2 ring-inset ring-red-500' : mark === 'missed' ? 'outline outline-2 -outline-offset-2 outline-dashed outline-amber-300' : '';
return (
<button
key={c}
type="button"
role="gridcell"
data-c={c}
data-r={r}
tabIndex={onPaint ? 0 : -1}
disabled={!onPaint}
aria-label={`Row ${r + 1}, column ${c + 1}${fill ? '' : ', empty'}`}
onPointerDown={(e) => {
if (!onPaint) return;
e.preventDefault();
// A drag keeps painting: moves are hit-tested by position, wherever the pointer is captured.
painting.current = true;
onPaint(c, r, true);
}}
onKeyDown={(e) => {
if (onPaint && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onPaint(c, r, true);
}
}}
className={`aspect-square min-h-[24px] ${gridlines ? 'border border-white/10' : ''} ${ring} disabled:cursor-default`}
style={{ backgroundColor: fill ?? 'rgb(255 255 255 / 0.04)' }}
/>
);
})}
</div>
))}
</div>
</div>
);
}
function Palette({ palette, color, onPick }: { palette: BlueprintColor[]; color: string | null; onPick: (c: string | null) => void }) {
return (
<div className="flex flex-wrap justify-center gap-2" role="radiogroup" aria-label="Colour">
{palette.map((p) => (
<button
key={p.key}
role="radio"
aria-checked={color === p.key}
onClick={() => onPick(p.key)}
className={`flex min-h-[44px] items-center gap-2 rounded-xl border px-3 py-1.5 text-sm font-semibold ${color === p.key ? 'border-accent bg-accent/20' : 'border-white/15 bg-white/5'}`}
>
<span className="h-5 w-5 rounded border border-white/30" style={{ backgroundColor: p.hex }} aria-hidden />
{p.name}
</button>
))}
<button
role="radio"
aria-checked={color === null}
onClick={() => onPick(null)}
className={`flex min-h-[44px] items-center gap-2 rounded-xl border px-3 py-1.5 text-sm font-semibold ${color === null ? 'border-accent bg-accent/20' : 'border-white/15 bg-white/5'}`}
>
<span aria-hidden>🧽</span> Eraser
</button>
</div>
);
}
/** The Builder's side: the canvas lives here and goes to the server in small debounced batches. */
function Builder({ view, send, remaining }: { view: BlueprintView; send: Send; remaining: number }) {
const n = view.pattern.size;
const [canvas, setCanvasState] = useState<string[]>(() => view.canvas ?? Array.from({ length: n }, () => EMPTY.repeat(n)));
// Pointer events can arrive faster than renders, so painting reads the latest canvas from a ref.
const latest = useRef(canvas);
const setCanvas = (next: string[]) => {
latest.current = next;
setCanvasState(next);
};
const [color, setColor] = useState<string | null>(view.palette[0]?.key ?? null);
const [undo, setUndo] = useState<string[][]>([]);
const [confirming, setConfirming] = useState(false);
const queue = useRef(new Map<string, Cell>());
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
/** Whether this stroke has already saved an undo step. */
const stroke = useRef(false);
const hex = Object.fromEntries(view.palette.map((p) => [p.key, p.hex]));
const pushUndo = (snapshot: string[]) => setUndo((u) => [...u.slice(-(UNDO_LIMIT - 1)), snapshot]);
const flush = () => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
if (queue.current.size === 0) return;
send('blueprint:paint', { cells: [...queue.current.values()] });
queue.current.clear();
};
useEffect(() => () => flush(), []); // eslint-disable-line react-hooks/exhaustive-deps
const apply = (cells: Cell[], next: string[]) => {
for (const cell of cells) queue.current.set(`${cell.x},${cell.y}`, cell);
setCanvas(next);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(flush, 250);
};
const paintAt = (c: number, r: number) => {
const { x, y } = toStored(n, view.rotateBuilder, c, r);
const value = color ?? EMPTY;
const current = latest.current;
if (current[y][x] === value) return;
// One undo step per stroke.
if (!stroke.current) pushUndo(current);
stroke.current = true;
const next = current.map((row, ry) => (ry === y ? row.slice(0, x) + value + row.slice(x + 1) : row));
apply([{ x, y, color }], next);
};
const restore = (target: string[]) => {
const current = latest.current;
const cells: Cell[] = [];
target.forEach((row, y) => [...row].forEach((v, x) => v !== current[y][x] && cells.push({ x, y, color: v === EMPTY ? null : v })));
if (cells.length > 0) apply(cells, target);
};
const submit = () => {
flush();
setConfirming(false);
send('blueprint:submit');
};
return (
<div className="space-y-4">
<Grid
size={n}
label={`Your canvas${view.rotateBuilder ? `, turned ${view.rotateBuilder}° clockwise` : ''}`}
cellAt={(c, r) => {
const { x, y } = toStored(n, view.rotateBuilder, c, r);
const v = canvas[y][x];
return { fill: v === EMPTY ? null : hex[v] };
}}
onPaint={(c, r, start) => {
if (start) stroke.current = false;
paintAt(c, r);
}}
/>
<Palette palette={view.palette} color={color} onPick={setColor} />
<div className="flex gap-2">
<button className="btn flex-1" disabled={undo.length === 0} onClick={() => { restore(undo[undo.length - 1]); setUndo((u) => u.slice(0, -1)); }}>
↶ Undo
</button>
<button
className="btn flex-1"
onClick={() => {
pushUndo(latest.current);
restore(Array.from({ length: n }, () => EMPTY.repeat(n)));
}}
>
Clear
</button>
</div>
{confirming ? (
<div className="rounded-2xl border border-accent/50 bg-accent/10 p-4 text-center">
<p className="font-semibold">
Submit this canvas?{' '}
{view.submitsLeft > 1 ? `You have ${view.submitsLeft} tries; the best counts.` : "It's your only try."}
</p>
<div className="mt-3 flex justify-center gap-2">
<button className="btn-primary" onClick={submit}>Yes, score it</button>
<button className="btn" onClick={() => setConfirming(false)}>Keep painting</button>
</div>
</div>
) : (
<button className="btn-primary w-full" onClick={() => setConfirming(true)} disabled={remaining <= 0}>
Submit ({view.submitsLeft} {view.submitsLeft === 1 ? 'try' : 'tries'} left)
</button>
)}
</div>
);
}
export function BlueprintGame({ view, state, role, receivedAt, send }: Props) {
const roles = state.game.roles;
const partnerName = roles[otherRole(role)].name;
const architect = view.seat === 'architect';
const remaining = useRemaining(view, receivedAt);
const now = useNow(200);
const n = view.pattern.size;
const hex = Object.fromEntries(view.palette.map((p) => [p.key, p.hex]));
// A peek lasts until the server's peekUntil, measured on this device's clock.
const peekOn = view.peekUntil !== null && view.canvas !== null && now - receivedAt < view.peekUntil - state.timer.serverNow;
const seatName = architect ? 'Architect' : 'Builder';
const header = (
<div className="text-center">
<div className="text-xs font-bold uppercase tracking-widest text-accent">
Picture {view.pattern.index + 1} of {view.pattern.total}
</div>
<div className="font-display text-3xl font-bold uppercase tracking-wide">
You're the <span className="text-accent">{seatName}</span>
</div>
</div>
);
if (view.status === 'ready') {
return (
<div className="space-y-5 text-center">
{header}
<div className="text-6xl" aria-hidden>{architect ? '📐' : '🖌️'}</div>
<p className="text-stone-200">
{architect
? `You see the picture. Describe it to ${partnerName}, square by square. You can't see their canvas${view.peeksLeft > 0 ? `, except with a peek (${view.peeksLeft} for this picture)` : ''}.`
: `${partnerName} sees a picture you can't. Paint it from their description alone.${view.rotateBuilder ? ` Your canvas is turned ${view.rotateBuilder}° clockwise!` : ''}`}
</p>
<p className="text-sm text-stone-400">
{n}×{n} grid, {formatSeconds(view.timeSeconds)}. Match {percent(view.threshold)} of the painted squares to earn the {view.pattern.keyName}.
</p>
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
Start the clock ⏱
</button>
</div>
);
}
const status = (
<div className="flex items-center justify-between gap-3 text-sm">
<div>
Need <strong>{percent(view.threshold)}</strong>
{view.bestScore !== null && <span className="text-stone-400"> · best {percent(view.bestScore)}</span>}
</div>
<div
className={`rounded-lg border px-3 py-1 font-mono text-xl font-bold tabular-nums ${remaining <= 20 && view.status === 'building' ? 'border-red-400 text-red-300' : 'border-white/15'}`}
role="timer"
aria-label={`${formatSeconds(remaining)} left for this picture`}
>
{formatSeconds(remaining)}
</div>
</div>
);
if (view.status === 'scored' && view.target && view.canvas) {
const target = view.target;
const canvas = view.canvas;
// On the Builder's screen the reveal keeps the Builder's orientation.
const rotate = architect ? 0 : view.rotateBuilder;
return (
<div className="space-y-4" aria-live="polite">
{header}
<div className={`rounded-2xl border-2 p-4 text-center ${view.passed ? 'border-emerald-500/70 bg-emerald-950/50' : 'border-red-500/70 bg-red-950/50'}`}>
<div className="text-xs font-bold uppercase tracking-widest text-stone-300">It was…</div>
<h3 className="font-display text-2xl font-bold">{view.title}</h3>
<p className="mt-1 text-3xl font-bold">{percent(view.bestScore ?? 0)}</p>
<p className="text-stone-300">{view.passed ? `🔑 ${view.pattern.keyName}!` : `Needed ${percent(view.threshold)}. No key this time.`}</p>
</div>
<Grid
size={n}
label="The picture over the canvas: green is right, red is wrong, dashed was missed"
cellAt={(c, r) => {
const { x, y } = toStored(n, rotate, c, r);
const t = target[y][x];
const p = canvas[y][x];
if (p !== EMPTY) return { fill: hex[p], mark: p === t ? 'right' : 'wrong' };
return { fill: null, mark: t !== EMPTY ? 'missed' : undefined };
}}
/>
<p className="text-center text-xs text-stone-400">Green: right. Red: wrong colour or shouldn't be painted. Dashed: missed.</p>
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
{view.pattern.index === view.pattern.total - 1 ? 'See how we did →' : 'Next picture →'}
</button>
</div>
);
}
return (
<div className="space-y-4">
{header}
{status}
{view.lastScore !== null && (
<p className="text-center text-sm text-amber-200">Last try: {percent(view.lastScore)}. Keep going!</p>
)}
{architect && view.target ? (
<>
<Grid size={n} label="The picture to describe" cellAt={(c, r) => ({ fill: view.target![r][c] === EMPTY ? null : hex[view.target![r][c]] })} />
<ul className="flex flex-wrap justify-center gap-2 text-sm">
{view.palette.map((p) => (
<li key={p.key} className="chip">
<span className="mr-1 inline-block h-3 w-3 rounded-sm align-middle" style={{ backgroundColor: p.hex }} aria-hidden />
{p.name}
</li>
))}
</ul>
{peekOn && view.canvas ? (
<div className="rounded-2xl border border-accent/50 bg-accent/10 p-3">
<p className="mb-2 text-center text-sm font-semibold text-accent">👀 Their canvas right now</p>
<Grid size={n} label="The Builder's canvas" cellAt={(c, r) => ({ fill: view.canvas![r][c] === EMPTY ? null : hex[view.canvas![r][c]] })} />
</div>
) : (
<button className="btn w-full" disabled={view.peeksLeft === 0} onClick={() => send('blueprint:peek')}>
👀 Peek at their canvas for 3s ({view.peeksLeft} left)
</button>
)}
<p className="text-center text-sm text-stone-400">
Describe it out loud: rows, columns, colours. {partnerName} submits when you're both happy.
</p>
</>
) : (
<Builder key={`${view.pattern.index}`} view={view} send={send} remaining={remaining} />
)}
</div>
);
}
+36 -4
View File
@@ -11,6 +11,7 @@ import type {
TechnicianView,
WiresModuleView,
} from '@two-keys/shared';
import { otherRole } from '@two-keys/shared';
import { SignalLamp } from '../components/ClueCard';
import { Markdown } from '../components/ui';
import { useNow } from '../lib/hooks';
@@ -99,7 +100,7 @@ function ModuleFrame({ title, solved, children }: { title: string; solved: boole
function Wires({ module, act }: { module: WiresModuleView; act: (a: DefuseAction) => void }) {
return (
<ModuleFrame title="Wires" solved={module.solved}>
<ModuleFrame title={module.title} solved={module.solved}>
<ol className="space-y-2">
{module.wires.map((color, i) => (
<li key={i} className="flex items-center gap-2">
@@ -359,7 +360,7 @@ function Device({ view, send }: { view: TechnicianView; send: Send }) {
{modules.map((m) => {
switch (m.id) {
case 'wires':
return <Wires key={m.id} module={m} act={act(m.id)} />;
return <Wires key={m.instance} module={m} act={act(m.instance)} />;
case 'button':
return <BigButton key={m.id} module={m} act={act(m.id)} />;
case 'keypad':
@@ -379,10 +380,41 @@ function Device({ view, send }: { view: TechnicianView; send: Send }) {
export function DefuseGame({ view, state, receivedAt, send }: Props) {
const remaining = useRemaining(view, receivedAt);
const technician = view.seat === 'technician';
const partner = state.game.roles[technician ? 'B' : 'A'].name;
// Roles can swap every level, so the partner is whoever doesn't hold the seat you hold now.
const partner = state.game.roles[technician ? otherRole(view.technician) : view.technician].name;
const seatName = technician ? 'Technician' : 'Expert';
let banner = null;
if (view.status === 'ready') {
if (view.status === 'ready' && view.swapRoles) {
const swapped = view.level.index > 0;
banner = (
<div className="card flex min-h-[60vh] flex-col items-center justify-center text-center" aria-live="polite">
<div className="text-6xl" aria-hidden>{swapped ? '🔄' : technician ? '🧰' : '📖'}</div>
<h2 className="mt-3 font-display text-3xl font-bold">
{swapped ? 'Swap! ' : ''}You're {swapped ? 'now ' : ''}the <span className="text-accent">{seatName}</span>
</h2>
<p className="mt-2 text-stone-300">
Level {view.level.index + 1} of {view.level.total}: {view.level.title}. {timerText(view.level.timeSeconds)} on the clock,{' '}
{view.maxStrikes} {view.maxStrikes === 1 ? 'strike' : 'strikes'} and it's over.
</p>
<p className="mt-3 text-sm text-stone-400">
{technician
? `You'll see the device. Describe everything to the ${partner}.`
: `You have the manual. Ask the ${partner} for the serial number and battery count first.`}
</p>
<button
className={`${view.swapReady.you ? 'btn' : 'btn-primary'} mt-5 w-full max-w-xs`}
aria-pressed={view.swapReady.you}
onClick={() => send('level:ready')}
>
{view.swapReady.you ? '✓ Ready (tap to undo)' : 'Ready'}
</button>
<p className="mt-2 text-sm text-stone-400">
{view.swapReady.partner ? `${partner} is ready.` : `Waiting for ${partner}…`} The clock starts when you both are.
</p>
</div>
);
} else if (view.status === 'ready') {
banner = (
<div className="card text-center">
<div className="text-5xl" aria-hidden>💣</div>
+342
View File
@@ -0,0 +1,342 @@
import { useEffect, useRef, useState, type FormEvent, type KeyboardEvent, type PointerEvent } from 'react';
import { motion } from 'framer-motion';
import type { DialView, Role, RoomState } from '@two-keys/shared';
import { otherRole } from '@two-keys/shared';
import type { Send } from '../lib/socket';
interface Props {
view: DialView;
state: RoomState;
role: Role;
send: Send;
}
// The dial is a half circle: the range's low end on the left, high end on the right.
const W = 300;
const CX = 150;
const CY = 150;
const R = 130;
const STATIC = '▒▒▒ static ▒▒▒';
// Narrowest band first: 4 points, 3, 2, 1.
const BAND_COLORS = ['rgb(var(--accent))', 'rgb(var(--accent) / 0.6)', 'rgb(var(--accent) / 0.35)', 'rgb(var(--accent) / 0.18)'];
function useDial(range: [number, number]) {
const [lo, hi] = range;
const angle = (v: number) => Math.PI * (1 - (v - lo) / (hi - lo));
const point = (v: number, r = R) => ({ x: CX + r * Math.cos(angle(v)), y: CY - r * Math.sin(angle(v)) });
/** A pie slice from value a to value b. */
const wedge = (a: number, b: number) => {
const [from, to] = [Math.max(lo, a), Math.min(hi, b)];
const p1 = point(from);
const p2 = point(to);
return `M ${CX} ${CY} L ${p1.x} ${p1.y} A ${R} ${R} 0 0 1 ${p2.x} ${p2.y} Z`;
};
const clamp = (v: number) => Math.min(hi, Math.max(lo, Math.round(v)));
return { lo, hi, point, wedge, clamp };
}
function Dial({
view,
needle,
onNeedle,
interactive,
}: {
view: DialView;
needle: number | null;
onNeedle?: (value: number) => void;
interactive: boolean;
}) {
const { lo, hi, point, wedge, clamp } = useDial(view.range);
const svg = useRef<SVGSVGElement>(null);
const [dragging, setDragging] = useState(false);
const showTarget = view.target !== null;
const fromPointer = (e: PointerEvent) => {
const box = svg.current?.getBoundingClientRect();
if (!box || !onNeedle) return;
const x = ((e.clientX - box.left) / box.width) * W - CX;
const y = CY - ((e.clientY - box.top) / box.width) * W;
const a = Math.atan2(Math.max(y, 0), x); // 0 (right) .. π (left)
onNeedle(clamp(lo + (1 - a / Math.PI) * (hi - lo)));
};
const onKey = (e: KeyboardEvent) => {
if (!onNeedle || needle === null) return;
const step = e.shiftKey ? 5 : 1;
const next = { ArrowLeft: -step, ArrowDown: -step, ArrowRight: step, ArrowUp: step, Home: lo - needle, End: hi - needle }[e.key];
if (next === undefined) return;
e.preventDefault();
onNeedle(clamp(needle + next));
};
const tip = needle !== null ? point(needle, R - 6) : null;
const ticks = [0, 0.25, 0.5, 0.75, 1].map((f) => lo + f * (hi - lo));
return (
<div className="mx-auto w-full max-w-md">
<svg
ref={svg}
viewBox={`0 0 ${W} ${CY + 24}`}
className={`w-full select-none ${interactive ? 'cursor-pointer touch-none' : ''}`}
role={interactive ? 'slider' : 'img'}
tabIndex={interactive ? 0 : undefined}
aria-label={interactive ? 'Dial' : showTarget ? `Dial, target at ${view.target}` : 'Dial'}
aria-valuemin={interactive ? lo : undefined}
aria-valuemax={interactive ? hi : undefined}
aria-valuenow={interactive && needle !== null ? needle : undefined}
onKeyDown={interactive ? onKey : undefined}
onPointerDown={(e) => {
if (!interactive) return;
(e.currentTarget as Element).setPointerCapture(e.pointerId);
setDragging(true);
fromPointer(e);
}}
onPointerMove={(e) => dragging && fromPointer(e)}
onPointerUp={() => setDragging(false)}
onPointerCancel={() => setDragging(false)}
>
<path d={wedge(lo, hi)} fill="rgb(255 255 255 / 0.06)" stroke="rgb(255 255 255 / 0.2)" />
{showTarget &&
[...view.bands]
.map((b, i) => ({ ...b, color: BAND_COLORS[i] ?? BAND_COLORS[BAND_COLORS.length - 1] }))
.reverse()
.map((b) => (
<motion.path
key={b.within}
d={wedge(view.target! - b.within - 0.5, view.target! + b.within + 0.5)}
fill={b.color}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6 }}
/>
))}
{showTarget &&
view.bands.map((b, i) => {
const prev = i === 0 ? 0 : view.bands[i - 1].within;
const mid = view.target! + (prev + b.within) / 2 + 0.5;
if (mid > hi) return null;
const p = point(mid, R * 0.82);
return (
<text key={b.within} x={p.x} y={p.y} textAnchor="middle" dominantBaseline="middle" className="fill-stone-950 text-[9px] font-bold">
{b.points}
</text>
);
})}
{view.showNumbers &&
ticks.map((t) => {
const p = point(t, R + 11);
return (
<text key={t} x={p.x} y={Math.min(p.y, CY + 14)} textAnchor="middle" className="fill-stone-400 text-[10px]">
{Math.round(t)}
</text>
);
})}
{tip && (
<g>
<line x1={CX} y1={CY} x2={tip.x} y2={tip.y} stroke="#f5f5f4" strokeWidth={4} strokeLinecap="round" />
<circle cx={CX} cy={CY} r={9} fill="#f5f5f4" />
</g>
)}
</svg>
<div className="mt-1 flex justify-between gap-4 text-sm font-semibold">
<span className="max-w-[45%]">← {view.left ?? STATIC}</span>
<span className="max-w-[45%] text-right">{view.right ?? STATIC} →</span>
</div>
</div>
);
}
function KeyProgress({ view }: { view: DialView }) {
const pct = (n: number) => `${Math.min(100, (n / view.maxTotal) * 100)}%`;
return (
<div aria-label={`${view.total} points. Keys at ${view.keys.map((k) => k.at).join(', ')}.`}>
<div className="flex items-baseline justify-between text-sm">
<span className="font-bold">{view.total} points</span>
<span className="text-stone-400">Round {view.round + 1} of {view.rounds}</span>
</div>
<div className="relative mt-1 h-2.5 rounded-full bg-white/10">
<motion.div className="h-full rounded-full bg-accent" animate={{ width: pct(view.total) }} />
{view.keys.map((k) => (
<span
key={k.at}
className={`absolute -top-2 -translate-x-1/2 text-base ${k.earned ? '' : 'opacity-40 grayscale'}`}
style={{ left: pct(k.at) }}
title={`${k.name}: ${k.at} points`}
aria-hidden
>
🔑
</span>
))}
</div>
</div>
);
}
function ClueForm({ view, send }: { view: DialView; send: Send }) {
const [text, setText] = useState('');
const submit = (e: FormEvent) => {
e.preventDefault();
if (text.trim()) send('dial:clue', { text: text.trim() });
};
const { maxWords, maxChars, banLabelWords, noRepeatWords } = view.rules;
return (
<form onSubmit={submit} className="space-y-2">
<label htmlFor="dial-clue" className="block text-sm font-semibold">
Your clue: something that sits right at the target
</label>
<div className="flex gap-2">
<input
id="dial-clue"
className="field text-lg"
value={text}
onChange={(e) => setText(e.target.value)}
maxLength={maxChars}
autoComplete="off"
spellCheck={false}
placeholder={maxWords === 1 ? 'One word' : `Up to ${maxWords} words`}
/>
<button type="submit" className="btn-primary shrink-0" disabled={!text.trim()}>
Send
</button>
</div>
<p className="text-xs text-stone-400">
{maxWords === 1 ? 'Exactly one word' : `Up to ${maxWords} words`}, {maxChars} characters.
{banLabelWords && ' No words from the dial labels.'}
{noRepeatWords && ' No word used earlier in the game.'}
</p>
{noRepeatWords && view.usedWords.length > 0 && (
<p className="text-xs text-stone-500">Used: {view.usedWords.join(', ').toLowerCase()}</p>
)}
</form>
);
}
export function DialGame({ view, state, role, send }: Props) {
const roles = state.game.roles;
const partnerName = roles[otherRole(role)].name;
const giverName = roles[view.giver].name;
const [needle, setNeedle] = useState<number>(view.needle ?? Math.round((view.range[0] + view.range[1]) / 2));
const lastSent = useRef(0);
const pending = useRef<number | null>(null);
// A new round starts the needle in the middle again (or where the server says it was).
useEffect(() => {
setNeedle(view.needle ?? Math.round((view.range[0] + view.range[1]) / 2));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view.round]);
// Throttled: the server keeps the needle for a refresh, and never shows it to the clue-giver.
const moveNeedle = (value: number) => {
setNeedle(value);
pending.current = value;
const now = Date.now();
if (now - lastSent.current > 400) {
lastSent.current = now;
send('dial:needle', { value });
pending.current = null;
}
};
useEffect(() => {
const t = setInterval(() => {
if (pending.current !== null) {
send('dial:needle', { value: pending.current });
lastSent.current = Date.now();
pending.current = null;
}
}, 600);
return () => clearInterval(t);
}, [send]);
const last = view.round === view.rounds - 1;
return (
<div className="space-y-5">
<div className="text-center">
<div className="text-xs font-bold uppercase tracking-widest text-accent">
{view.iAmGiver ? 'You give the clue' : `${giverName} gives the clue`}
</div>
</div>
<KeyProgress view={view} />
{view.status === 'clue' && view.iAmGiver && (
<>
<Dial view={view} needle={null} interactive={false} />
<p className="text-center text-sm text-stone-400">Only you can see the target. {partnerName} will turn the dial from your clue.</p>
<ClueForm key={view.round} view={view} send={send} />
</>
)}
{view.status === 'clue' && !view.iAmGiver && (
<>
<Dial view={view} needle={null} interactive={false} />
<p className="card text-center text-stone-300" aria-live="polite">
{giverName} is thinking of a clue… 🤔
</p>
</>
)}
{view.status === 'guess' && (
<div className="card text-center">
<div className="text-xs font-semibold uppercase tracking-widest text-stone-400">The clue</div>
<p className="mt-1 font-display text-3xl font-bold">“{view.clue}”</p>
</div>
)}
{view.status === 'guess' && view.iAmGiver && (
<>
<Dial view={view} needle={null} interactive={false} />
<p className="text-center text-sm text-stone-400" aria-live="polite">
{partnerName} is turning the dial. 🤫 No hints, no faces!
</p>
</>
)}
{view.status === 'guess' && !view.iAmGiver && (
<>
<p className="text-center text-sm font-semibold text-accent">🤫 Clue only! Drag the needle (or use the arrow keys).</p>
<Dial view={view} needle={needle} onNeedle={moveNeedle} interactive />
{view.showNumbers && <p className="text-center font-mono text-2xl font-bold tabular-nums">{needle}</p>}
<button className="btn-primary w-full" onClick={() => send('dial:lock', { value: needle })}>
Lock in 🔒
</button>
</>
)}
{view.status === 'reveal' && (
<div className="space-y-4" aria-live="polite">
<Dial view={view} needle={view.guess} interactive={false} />
<motion.div
className="text-center"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
>
<p className="font-display text-3xl font-bold">
{view.points ? `+${view.points} point${view.points === 1 ? '' : 's'}` : 'No points'}
</p>
<p className="mt-1 text-stone-300">
Target {view.target}, guess {view.guess}: {Math.abs((view.target ?? 0) - (view.guess ?? 0))} away.
</p>
</motion.div>
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
{last ? 'See how we did →' : 'Next round →'}
</button>
</div>
)}
{view.history.length > 0 && (
<details className="card text-sm">
<summary className="cursor-pointer font-semibold">Rounds so far</summary>
<ol className="mt-2 space-y-1 text-stone-300">
{view.history.map((h, i) => (
<li key={i}>
{i + 1}. {h.left} ↔ {h.right}: “{h.clue}” ({roles[h.giver].name}). Target {h.target}, guess {h.guess},{' '}
<strong>{h.points} pts</strong>
</li>
))}
</ol>
</details>
)}
</div>
);
}
+27 -2
View File
@@ -35,6 +35,8 @@ function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerNam
const gateAt = new Map(view.oneWay.map((g) => [`${g.x},${g.y}`, g]));
const glyphAt = new Map(view.glyphs.map((g) => [`${g.x},${g.y}`, g]));
const lanternAt = new Map(view.lanterns.map((l) => [`${l.x},${l.y}`, l]));
const itemAt = new Map(view.items.map((it) => [`${it.x},${it.y}`, it]));
const doorAt = new Map(view.doors.map((d) => [`${d.x},${d.y}`, d]));
return (
<div className="space-y-4">
@@ -66,8 +68,10 @@ function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerNam
let tone = 'bg-stone-800/40';
if (cell === '#') tone = 'bg-stone-700';
else if (cell === 'T') { content = '☠'; tone = 'bg-red-950 text-red-400'; }
else if (cell === 'E') { content = '🚪'; tone = 'bg-emerald-950'; }
else if (cell === 'E') { content = view.doors.length > 0 ? '🟩' : '🚪'; tone = 'bg-emerald-950'; }
else if (lantern) { content = '🏮'; tone = lantern.collected ? 'bg-stone-800/40 opacity-30 grayscale' : 'bg-amber-950'; }
else if (doorAt.has(key)) { content = '🚪'; tone = 'bg-amber-900/60'; }
else if (itemAt.has(key)) { content = '🗝'; tone = itemAt.get(key)!.collected ? 'bg-stone-800/40 opacity-30 grayscale' : 'bg-yellow-900/60'; }
else if (glyph) content = splitLabel(glyph.label)[0];
const gate = gateAt.get(key);
if (gate && cell !== '#') {
@@ -106,11 +110,24 @@ function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerNam
{l.collected && <span className="no-underline"> ✓</span>}
</li>
))}
{view.items.map((it) => (
<li key={it.name} className={`flex gap-2 ${it.collected ? 'text-stone-500 line-through' : ''}`}>
<span className="w-6 text-center" aria-hidden>🗝</span>
{it.name}
{it.collected && <span className="no-underline"> ✓</span>}
</li>
))}
{view.doors.map((d) => (
<li key={d.name} className="flex gap-2">
<span className="w-6 text-center" aria-hidden>🚪</span>
{d.name}: shut until they carry its key
</li>
))}
{view.oneWay.length > 0 && (
<li className="flex gap-2 text-accent"><span className="w-6 text-center" aria-hidden>→</span>One-way gate. Only opens the way the arrow points.</li>
)}
<li className="flex gap-2 text-red-300"><span className="w-6 text-center" aria-hidden>☠</span>Trap. They can't see it!</li>
<li className="flex gap-2"><span className="w-6 text-center" aria-hidden>🚪</span>Exit (needs all {view.totalKeys} lanterns)</li>
<li className="flex gap-2"><span className="w-6 text-center" aria-hidden>{view.doors.length > 0 ? '🟩' : '🚪'}</span>Exit (needs all {view.totalKeys} lanterns)</li>
<li className="flex gap-2 text-stone-400"><span className="w-6 text-center" aria-hidden>▪</span>Most floor is bare stone</li>
</ul>
</div>
@@ -163,6 +180,14 @@ function ExplorerCell({ view, send, navigatorName }: { view: ExplorerView; send:
<Lanterns carried={view.keysCarried} total={view.totalKeys} />
<div className="text-sm text-stone-300">{view.moves} moves</div>
</div>
{view.inventory.length > 0 && (
<div className="flex flex-wrap items-center gap-2 text-sm" aria-label="Inventory">
<span className="text-xs font-bold uppercase tracking-widest text-stone-400">Carrying</span>
{view.inventory.map((name) => (
<span key={name} className="chip">🗝 {name}</span>
))}
</div>
)}
<div
className="rounded-2xl border border-accent/30 bg-[radial-gradient(circle_at_center,rgb(var(--accent)/0.22),transparent_70%)] p-6 text-center"
+171
View File
@@ -0,0 +1,171 @@
import type { Role, RoomState, TwinView } from '@two-keys/shared';
import { otherRole } from '@two-keys/shared';
import { useNow } from '../lib/hooks';
import type { Send } from '../lib/socket';
interface Props {
view: TwinView;
state: RoomState;
role: Role;
receivedAt: number;
send: Send;
}
// Symbols that render as plain text unless asked for their emoji form.
const TEXT_DEFAULT = new Set(['🛏', '🕯', '🖼', '☂', '✉', '✏', '☘', '🛋', '🕰', '🗝', '⏱', '☕', '⏰', '🍽', '🪑', '🕶', '☀', '❄', '⚱', '🏺', '♠', '♥', '♦', '♣']);
/** Ask for the emoji form, so both phones draw the same picture. */
const asEmoji = (cell: string) => (TEXT_DEFAULT.has(cell) ? `${cell}️` : cell);
const formatSeconds = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
function useCountdown(seconds: number, receivedAt: number, running: boolean): number {
const now = useNow(250);
return running ? Math.max(0, seconds - (now - receivedAt) / 1000) : seconds;
}
export function TwinGame({ view, state, role, receivedAt, send }: Props) {
const roles = state.game.roles;
const partnerName = roles[otherRole(role)].name;
const playing = view.status === 'playing';
const remaining = useCountdown(view.remaining, receivedAt, playing);
// Taps expire on their own, so the rings fade out here without waiting for the server.
const myTapLeft = useCountdown(view.myTap?.secondsLeft ?? 0, receivedAt, playing);
const partnerTapLeft = useCountdown(view.partnerTap?.secondsLeft ?? 0, receivedAt, playing);
const myTap = view.myTap && myTapLeft > 0 ? view.myTap : null;
const found = new Set(view.found.map((f) => `${f.x},${f.y}`));
const width = view.grid[0]?.length ?? 0;
const rows = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const header = (
<div className="text-center">
<div className="text-xs font-bold uppercase tracking-widest text-accent">
Room {view.scene.index + 1} of {view.scene.total}
{view.isRetry && ' · Retry'}
</div>
<h2 className="font-display text-3xl font-bold">{view.scene.title}</h2>
</div>
);
if (view.status === 'ready') {
return (
<div className="space-y-5 text-center">
{header}
<p className="text-lg text-stone-200">
{view.scene.differences} differences, {formatSeconds(view.timeSeconds)} on the clock.
</p>
<p className="text-sm text-stone-400">
Describe your room to {partnerName}, square by square. When you spot a difference, you <strong>both</strong> tap
that square within {view.claimWindowSeconds} seconds. A wrong claim costs {view.wrongClaimPenaltySeconds}s.
</p>
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
Open the room 🚪
</button>
</div>
);
}
return (
<div className="space-y-4">
{header}
<div className="flex items-center justify-between gap-3">
<div className="font-display text-xl font-bold" aria-label={`${view.found.length} of ${view.scene.differences} differences found`}>
{view.found.length} / {view.scene.differences}
</div>
<div
className={`rounded-lg border px-3 py-1 font-mono text-xl font-bold tabular-nums ${remaining <= 30 && playing ? 'border-red-400 text-red-300' : 'border-white/15'}`}
role="timer"
aria-label={`${formatSeconds(remaining)} left in this room`}
>
{formatSeconds(remaining)}
</div>
</div>
{/* Scrolls inside its card if it ever has to, never the page. */}
<div className="overflow-x-auto rounded-xl border border-accent/30 bg-black/40 p-1.5">
<div
className="mx-auto grid w-full gap-0.5 font-emoji"
// Cells shrink to 30px at the narrowest (10 columns fit a 375px phone) and stop growing at 48px.
style={{
gridTemplateColumns: `${view.showCoordinates ? 'auto ' : ''}repeat(${width}, minmax(30px, 1fr))`,
maxWidth: `${width * 48 + (view.showCoordinates ? 20 : 0)}px`,
}}
role="grid"
aria-label="Your room. Tap a square to claim a difference."
>
{view.showCoordinates && (
<>
<span aria-hidden />
{Array.from({ length: width }, (_, x) => (
<span key={x} className="text-center font-mono text-[11px] text-stone-400" aria-hidden>
{x + 1}
</span>
))}
</>
)}
{view.grid.map((row, y) => (
<div key={y} role="row" className="contents">
{view.showCoordinates && (
<span className="flex items-center pr-1 font-mono text-[11px] text-stone-400" aria-hidden>
{rows[y]}
</span>
)}
{row.map((cell, x) => {
const isFound = found.has(`${x},${y}`);
const isMine = myTap?.x === x && myTap?.y === y;
const name = view.showCoordinates ? `${rows[y]}${x + 1}` : `row ${y + 1}, column ${x + 1}`;
return (
<button
key={x}
role="gridcell"
disabled={!playing || isFound}
onClick={() => send('twin:tap', { x, y })}
aria-label={`${name}: ${cell}${isFound ? ', found' : isMine ? ', tapped' : ''}`}
className={`relative flex aspect-square items-center justify-center rounded-md text-[clamp(18px,6vw,28px)] leading-none transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
isFound ? 'bg-emerald-500/30 ring-2 ring-emerald-400' : 'bg-white/5 hover:bg-white/10'
}`}
>
{asEmoji(cell)}
{isMine && <span className="absolute inset-0 rounded-md ring-4 ring-accent motion-safe:animate-pulse" aria-hidden />}
{isFound && <span className="absolute -right-0.5 -top-1 text-xs" aria-hidden>✅</span>}
</button>
);
})}
</div>
))}
</div>
</div>
<p className="text-center text-sm text-stone-400" aria-live="polite">
{!playing
? ''
: myTap
? `You tapped a square. ${partnerName} has ${Math.ceil(myTapLeft)}s to tap the same one.`
: view.partnerTap && partnerTapLeft > 0
? `${partnerName} tapped a square! Which one? Ask, then tap it within ${Math.ceil(partnerTapLeft)}s.`
: 'Both tap the same square to claim a difference. No screen-peeking!'}
</p>
{view.status === 'won' && (
<div className="rounded-2xl border-2 border-emerald-500/70 bg-emerald-950/50 p-5 text-center" aria-live="polite">
<div className="text-5xl" aria-hidden>🔑</div>
<h3 className="mt-2 font-display text-2xl font-bold">Every difference found! {view.scene.keyName}</h3>
<button className="btn-primary mt-4 w-full" onClick={() => send('chapter:continue')}>
{view.scene.index === view.scene.total - 1 ? 'See how we did →' : 'Next room →'}
</button>
</div>
)}
{view.status === 'failed' && (
<div className="rounded-2xl border-2 border-red-500/70 bg-red-950/50 p-5 text-center" aria-live="polite">
<div className="text-5xl" aria-hidden>⏰</div>
<h3 className="mt-2 font-display text-2xl font-bold">Out of time in this room</h3>
<p className="mt-1 text-stone-300">
{view.canRetry ? 'You get one retry, with the clock reset.' : 'No key for this room.'}
</p>
<button className="btn-primary mt-4 w-full" onClick={() => send('chapter:continue')}>
{view.canRetry ? 'Try this room again ↻' : view.scene.index === view.scene.total - 1 ? 'See how we did →' : 'Next room →'}
</button>
</div>
)}
</div>
);
}
+20 -1
View File
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import type { CSSProperties } from 'react';
import type { Category, Role, Theme } from '@two-keys/shared';
import type { Category, EngineId, Role, Theme } from '@two-keys/shared';
/** "#7b1e3a" → "123 30 58", the form Tailwind's rgb(var(--x) / alpha) wants. */
export function hexToRgb(hex: string): string {
@@ -37,6 +37,25 @@ export const CATEGORY_LABEL: Record<Category, string> = {
'co-op': 'Co-op',
};
/** The newer game types show their own label instead of plain "Co-op", and get their own filter. */
const LABELLED_ENGINES = ['dial', 'twin', 'blueprint'] as const;
/** A library filter: a category, or one of the engines that has its own label. */
export type LibraryFilter = Category | (typeof LABELLED_ENGINES)[number];
export const filterOf = (game: { category: Category; engine: EngineId }): LibraryFilter =>
(LABELLED_ENGINES as readonly string[]).includes(game.engine) ? (game.engine as LibraryFilter) : game.category;
export const FILTER_LABEL: Record<LibraryFilter, string> = {
...CATEGORY_LABEL,
dial: 'Mind Meld',
twin: 'Spot the Difference',
blueprint: 'Describe & Draw',
};
/** What a game's chip says. */
export const chipLabel = (game: { category: Category; engine: EngineId }) => FILTER_LABEL[filterOf(game)];
/** A is warm (amber), B is cool (teal). */
export const ROLE_TEXT: Record<Role, string> = { A: 'text-roleA', B: 'text-roleB' };
export const ROLE_BORDER: Record<Role, string> = { A: 'border-roleA/60', B: 'border-roleB/60' };
+2 -1
View File
@@ -25,7 +25,8 @@ const STEPS = [
];
const TIPS = [
['💡 Hints', 'Three per lock, from a nudge to nearly the answer. Each costs two minutes on your final time, and you both see it.'],
['💡 Hints', 'Three per lock, from a nudge to nearly the answer. Each costs two minutes: added to your final time, or taken off the countdown in timed games. You both see it.'],
['🎯 Mind Meld, Spot the Difference, Describe & Draw', 'No locks here: one of you gives a clue for a hidden spot on a dial, you both hunt for the differences between two rooms, or one describes a picture the other paints.'],
['📝 Shared notepad', 'Jot down times, names and theories. You both see it update live.'],
['👋 Pings', "Tap your partner's name to send a little wave, a magnifying glass, a lightbulb or a heart."],
['🔄 Lost connection?', 'Just refresh or reopen the link. You land back in the same seat with all your progress.'],
+5 -6
View File
@@ -1,11 +1,10 @@
import { useState, type ReactNode } from 'react';
import type { Category } from '@two-keys/shared';
import { GameCard } from '../components/GameCard';
import { SiteShell } from '../components/SiteShell';
import { useGames } from '../lib/hooks';
import { CATEGORY_LABEL, DIFFICULTY_LABEL, MAX_DIFFICULTY } from '../lib/theme';
import { DIFFICULTY_LABEL, FILTER_LABEL, MAX_DIFFICULTY, filterOf, type LibraryFilter } from '../lib/theme';
const FILTERS: (Category | 'all')[] = ['all', 'murder-mystery', 'escape-room', 'co-op'];
const FILTERS: (LibraryFilter | 'all')[] = ['all', 'murder-mystery', 'escape-room', 'co-op', 'dial', 'twin', 'blueprint'];
const LEVELS = Array.from({ length: MAX_DIFFICULTY }, (_, i) => i + 1);
function Chip({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
@@ -24,11 +23,11 @@ function Chip({ active, onClick, children }: { active: boolean; onClick: () => v
export function Library() {
const games = useGames();
const [filter, setFilter] = useState<Category | 'all'>('all');
const [filter, setFilter] = useState<LibraryFilter | 'all'>('all');
const [level, setLevel] = useState<number | null>(null);
// Easiest first; ties keep the library's own order.
const shown = (games ?? [])
.filter((g) => (filter === 'all' || g.category === filter) && (level === null || g.difficulty === level))
.filter((g) => (filter === 'all' || filterOf(g) === filter) && (level === null || g.difficulty === level))
.map((g, i) => ({ g, i }))
.sort((a, b) => a.g.difficulty - b.g.difficulty || a.i - b.i)
.map(({ g }) => g);
@@ -43,7 +42,7 @@ export function Library() {
<div className="mt-5 flex flex-wrap gap-2" role="group" aria-label="Filter by category">
{FILTERS.map((f) => (
<Chip key={f} active={filter === f} onClick={() => setFilter(f)}>
{f === 'all' ? 'All games' : CATEGORY_LABEL[f]}
{f === 'all' ? 'All games' : FILTER_LABEL[f]}
</Chip>
))}
</div>
+2 -2
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
import type { Role, RoomState } from '@two-keys/shared';
import { Markdown, RoleBadge } from '../components/ui';
import { socket } from '../lib/socket';
import { CATEGORY_LABEL, MAX_DIFFICULTY, ROLE_BORDER, difficultyKeys } from '../lib/theme';
import { MAX_DIFFICULTY, chipLabel, ROLE_BORDER, difficultyKeys } from '../lib/theme';
function ShareBox({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
@@ -49,7 +49,7 @@ export function Lobby({ state }: { state: RoomState }) {
<div className="mx-auto w-full max-w-2xl space-y-5 px-4 py-6">
<header className="text-center">
<div className="flex flex-wrap items-center justify-center gap-2 text-xs text-stone-300">
<span className="chip text-accent">{CATEGORY_LABEL[game.category]}</span>
<span className="chip text-accent">{chipLabel(game)}</span>
<span>⏱ {game.estimatedMinutes} min</span>
<span aria-label={`Difficulty ${game.difficulty} of ${MAX_DIFFICULTY}`}>{difficultyKeys(game.difficulty)}</span>
{game.timeLimitMinutes !== null && <span className="text-red-300">⏳ {game.timeLimitMinutes} min limit</span>}
+8
View File
@@ -7,10 +7,13 @@ import { PartnerStatus, PingOverlay } from '../components/PartnerStatus';
import { PassDeviceScreen } from '../components/PassDeviceScreen';
import { Timer, WaterGauge, isWaterLevel } from '../components/Timer';
import { RoleBadge } from '../components/ui';
import { BlueprintGame } from '../engines/BlueprintGame';
import { DefuseGame } from '../engines/DefuseGame';
import { DialGame } from '../engines/DialGame';
import { LabyrinthGame } from '../engines/LabyrinthGame';
import { StoryGame } from '../engines/StoryGame';
import { SyncGame } from '../engines/SyncGame';
import { TwinGame } from '../engines/TwinGame';
import { socket, type Send } from '../lib/socket';
const ROLE_COLOR: Record<Role, string> = { A: '#f5b942', B: '#2dd4bf' };
@@ -82,6 +85,11 @@ export function Play({ state, receivedAt }: { state: RoomState; receivedAt: numb
{view?.engine === 'defuse' && <DefuseGame view={view} state={state} receivedAt={receivedAt} send={send} />}
{view?.engine === 'sync' && <SyncGame view={view} state={state} role={activeRole} send={send} />}
{view?.engine === 'labyrinth' && <LabyrinthGame view={view} state={state} send={send} />}
{view?.engine === 'dial' && <DialGame view={view} state={state} role={activeRole} send={send} />}
{view?.engine === 'twin' && <TwinGame view={view} state={state} role={activeRole} receivedAt={receivedAt} send={send} />}
{view?.engine === 'blueprint' && (
<BlueprintGame view={view} state={state} role={activeRole} receivedAt={receivedAt} send={send} />
)}
</main>
{!couch && <PingOverlay roles={game.roles} />}
+2
View File
@@ -19,6 +19,8 @@ export default {
sans: ['Inter', 'system-ui', 'Noto Sans Symbols 2', 'sans-serif'],
hand: ['Caveat', 'Noto Sans Symbols 2', 'cursive'],
mono: ['JetBrains Mono', 'ui-monospace', 'Noto Sans Symbols 2', 'monospace'],
// Twin Rooms: the same emoji glyphs on both phones.
emoji: ['Noto Color Emoji', 'Apple Color Emoji', 'Segoe UI Emoji', 'sans-serif'],
},
},
},
+2 -2
View File
@@ -232,9 +232,9 @@ finale:
- id: how
label: How?
options:
- { id: gunsolo, label: Shot with Vincent's .38 under the drum solo, gun hidden in the piano }
- { id: gunsolo, label: "Shot with Vincent's .38 under the drum solo, gun hidden in the piano" }
- { id: alley, label: Shot from the alley through the office window }
- { id: poison, label: Poison in his whiskey, gunshot staged later }
- { id: poison, label: "Poison in his whiskey, gunshot staged later" }
answer: gunsolo
- id: why
label: Why?
+128
View File
@@ -0,0 +1,128 @@
id: blueprint-blind-mirror
engine: blueprint
category: co-op
title: "Blueprint: Blind Mirror"
tagline: 10×10. Six colours. No peeks. And the Builder's canvas is turned on its side.
estimatedMinutes: 30
difficulty: 5
players: { min: 2, max: 2 }
theme: { primary: "#212529", accent: "#ff006e", background: "#060607", headingFont: "Playfair Display" }
rules:
timeLimitMinutes: 30
roles:
A:
name: Partner A
blurb: Architect for pictures 1 and 3, Builder for 2 and 4.
B:
name: Partner B
blurb: Builder for pictures 1 and 3, Architect for 2 and 4.
intro: |
☠️ **Nightmare rules.**
- **The Builder's canvas is rotated 90° clockwise.** The Architect's "top row" is the Builder's right-hand column.
You'll have to agree how to translate, fast.
- 10×10 grids, up to six colours.
- **No peeks.** One submit. **95%** to earn each key.
- Roles swap every picture. 30 minutes for all four.
blueprint:
scoring: overlap
rotateBuilder: 90 # Builder's canvas is shown rotated 90° clockwise; server un-rotates before scoring
swapRolesEachPattern: true
submitsPerPattern: 1
patterns:
- id: lighthouse10
title: "A Lighthouse" # SERVER ONLY until the pattern is finished (the Builder never sees the title)
size: 10
timeSeconds: 420
threshold: 0.95
peeks: 0
keyName: Lighthouse Key
palette:
B: { name: Blue, hex: "#3a86ff" }
G: { name: Green, hex: "#2a9d8f" }
R: { name: Red, hex: "#e63946" }
U: { name: Navy, hex: "#1d3557" }
W: { name: White, hex: "#f1f1f1" }
Y: { name: Yellow, hex: "#ffd60a" }
rows: # '.' = empty. Architect sees this; Builder never does.
- "....YY...."
- "...BBBB..."
- "....RR...."
- "....WW...."
- "....RR...."
- "...WWWW..."
- "...RRRR..."
- "..WWWWWW.."
- "GGGGGGGGGG"
- "UUUUUUUUUU"
- id: cat10
title: "A Black Cat" # SERVER ONLY until the pattern is finished (the Builder never sees the title)
size: 10
timeSeconds: 420
threshold: 0.95
peeks: 0
keyName: Cat Key
palette:
G: { name: Green, hex: "#2a9d8f" }
K: { name: Black, hex: "#2b2b2b" }
P: { name: Pink, hex: "#ff70a6" }
rows: # '.' = empty. Architect sees this; Builder never does.
- ".K......K."
- ".KK....KK."
- ".KKKKKKKK."
- "KKGKKKKGKK"
- "KKKKKKKKKK"
- "KKKKPPKKKK"
- ".KKKKKKKK."
- "..KKKKKK.."
- "...K..K..."
- "..KK..KK.."
- id: tree10
title: "An Apple Tree" # SERVER ONLY until the pattern is finished (the Builder never sees the title)
size: 10
timeSeconds: 420
threshold: 0.95
peeks: 0
keyName: Orchard Key
palette:
G: { name: Green, hex: "#2a9d8f" }
N: { name: Brown, hex: "#7f5539" }
R: { name: Red, hex: "#e63946" }
rows: # '.' = empty. Architect sees this; Builder never does.
- "....GG...."
- "...GGGG..."
- "..GGRGGG.."
- ".GGGGGGRG."
- "GGRGGGGGGG"
- "..GGGGGG.."
- "....NN...."
- "....NN...."
- "....NN...."
- "..NNNNNN.."
- id: ring10
title: "A Ring" # SERVER ONLY until the pattern is finished (the Builder never sees the title)
size: 10
timeSeconds: 420
threshold: 0.95
peeks: 0
keyName: Ring Key
palette:
B: { name: Blue, hex: "#3a86ff" }
W: { name: White, hex: "#f1f1f1" }
Y: { name: Yellow, hex: "#ffd60a" }
rows: # '.' = empty. Architect sees this; Builder never does.
- "....WW...."
- "...WBBW..."
- "....WW...."
- "...YYYY..."
- "..YY..YY.."
- ".YY....YY."
- ".Y......Y."
- ".YY....YY."
- "..YY..YY.."
- "...YYYY..."
successEnding: |
Every picture finished. Turns out you speak the same visual language.
**Masterpiece.** 🎨🔑
+82
View File
@@ -0,0 +1,82 @@
id: blueprint-first-sketch
engine: blueprint
category: co-op
title: "Blueprint: First Sketch"
tagline: One of you sees the picture. The other has the paintbrush. Describe it, square by square.
estimatedMinutes: 10
difficulty: 1
players: { min: 2, max: 2 }
theme: { primary: "#118ab2", accent: "#ffd166", background: "#071219", headingFont: "Playfair Display" }
roles:
A:
name: Architect
blurb: You see the target picture. You can't see what your partner paints.
B:
name: Builder
blurb: You have a blank grid and a palette. You can't see the picture. Listen carefully.
intro: |
**Architect:** describe the picture. Rows, columns, shapes, colours, whatever works.
**Builder:** paint what you hear, then hit **Submit**.
Match at least **80%** to earn the key. The Architect gets **2 peeks** per picture: a 3-second look at the Builder's canvas.
Small grids, one colour each. A gentle start.
blueprint:
scoring: overlap # see requirements: matched painted cells ÷ cells painted in either grid
rotateBuilder: 0
swapRolesEachPattern: false
submitsPerPattern: 2 # a second try is allowed; best score counts
patterns:
- id: heart6
title: "A Heart" # SERVER ONLY until the pattern is finished (the Builder never sees the title)
size: 6
timeSeconds: 180
threshold: 0.8
peeks: 2
keyName: Heart Key
palette:
R: { name: Red, hex: "#e63946" }
rows: # '.' = empty. Architect sees this; Builder never does.
- "......"
- ".R..R."
- "RRRRRR"
- "RRRRRR"
- ".RRRR."
- "..RR.."
- id: house6
title: "A House" # SERVER ONLY until the pattern is finished (the Builder never sees the title)
size: 6
timeSeconds: 180
threshold: 0.8
peeks: 2
keyName: House Key
palette:
B: { name: Blue, hex: "#3a86ff" }
rows: # '.' = empty. Architect sees this; Builder never does.
- "..BB.."
- ".BBBB."
- "BBBBBB"
- ".B..B."
- ".B..B."
- ".BBBB."
- id: smile6
title: "A Smiley" # SERVER ONLY until the pattern is finished (the Builder never sees the title)
size: 6
timeSeconds: 180
threshold: 0.8
peeks: 2
keyName: Smile Key
palette:
Y: { name: Yellow, hex: "#ffd60a" }
rows: # '.' = empty. Architect sees this; Builder never does.
- ".YYYY."
- "YY.Y.Y"
- "YYYYYY"
- "Y.YY.Y"
- "YY..YY"
- ".YYYY."
successEnding: |
Every picture finished. Turns out you speak the same visual language.
**Masterpiece.** 🎨🔑
+111
View File
@@ -0,0 +1,111 @@
id: blueprint-stained-glass
engine: blueprint
category: co-op
title: "Blueprint: Stained Glass"
tagline: Restore four stained-glass windows from description alone. Swap roles every window.
estimatedMinutes: 25
difficulty: 3
players: { min: 2, max: 2 }
theme: { primary: "#9b2226", accent: "#94d2bd", background: "#0d0a0a", headingFont: "Playfair Display" }
roles:
A:
name: Partner A
blurb: Architect for windows 1 and 3, Builder for windows 2 and 4.
B:
name: Partner B
blurb: Builder for windows 1 and 3, Architect for windows 2 and 4.
intro: |
An old chapel's stained-glass windows were smashed in a storm. Only the sketches survive, and only one of
you can see each sketch.
**Roles swap after every window.** 8×8 grids, up to three colours, **90%** to earn each key, and the Architect
gets **1 peek** per window. You get one submit, so check before you hit it.
blueprint:
scoring: overlap
rotateBuilder: 0
swapRolesEachPattern: true
submitsPerPattern: 1
patterns:
- id: rose8
title: "A Rose" # SERVER ONLY until the pattern is finished (the Builder never sees the title)
size: 8
timeSeconds: 240
threshold: 0.9
peeks: 1
keyName: Rose Key
palette:
G: { name: Green, hex: "#2a9d8f" }
P: { name: Pink, hex: "#ff70a6" }
R: { name: Red, hex: "#e63946" }
rows: # '.' = empty. Architect sees this; Builder never does.
- "..RRRR.."
- ".RRPPRR."
- ".RPRRPR."
- "..RRRR.."
- "...GG..."
- ".GGG...."
- "...G.GG."
- "...G...."
- id: boat8
title: "A Sailboat" # SERVER ONLY until the pattern is finished (the Builder never sees the title)
size: 8
timeSeconds: 240
threshold: 0.9
peeks: 1
keyName: Sail Key
palette:
B: { name: Blue, hex: "#3a86ff" }
U: { name: Navy, hex: "#1d3557" }
W: { name: White, hex: "#f1f1f1" }
rows: # '.' = empty. Architect sees this; Builder never does.
- "...W...."
- "...WW..."
- "...WWW.."
- "...WWWW."
- "...W...."
- "BBBBBBBB"
- ".BBBBBB."
- "..UUUU.."
- id: key8
title: "A Key" # SERVER ONLY until the pattern is finished (the Builder never sees the title)
size: 8
timeSeconds: 240
threshold: 0.9
peeks: 1
keyName: Skeleton Key
palette:
Y: { name: Yellow, hex: "#ffd60a" }
rows: # '.' = empty. Architect sees this; Builder never does.
- "........"
- ".YYY...."
- "Y...Y..."
- "Y...YYYY"
- "Y...Y.Y."
- ".YYY..Y."
- "........"
- "........"
- id: mug8
title: "A Mug of Cocoa" # SERVER ONLY until the pattern is finished (the Builder never sees the title)
size: 8
timeSeconds: 240
threshold: 0.9
peeks: 1
keyName: Cocoa Key
palette:
R: { name: Red, hex: "#e63946" }
W: { name: White, hex: "#f1f1f1" }
rows: # '.' = empty. Architect sees this; Builder never does.
- "..W.W..."
- "...W.W.."
- "RRRRRR.."
- "RWWWWRRR"
- "RWWWWR.R"
- "RWWWWRRR"
- "RRRRRR.."
- ".RRRR..."
successEnding: |
Every picture finished. Turns out you speak the same visual language.
**Masterpiece.** 🎨🔑
+283
View File
@@ -0,0 +1,283 @@
id: curtain-call
engine: story
category: murder-mystery
title: Curtain Call
tagline: The stage dagger was supposed to fold. Opening night. Act Three. It didn't.
estimatedMinutes: 30
difficulty: 3
players: { min: 2, max: 2 }
theme: { primary: "#8b0000", accent: "#ffd166", background: "#0d0606", headingFont: "Playfair Display" }
rules:
hintPenaltySeconds: 180
roles:
A:
name: The Critic
blurb: You watched from row C with a notebook. You have the police file and the audience's view.
B:
name: The Stage Manager
blurb: You ran the show from the wings. Prompt book, props, keys, and backstage gossip.
intro: |
**The Alhambra Theatre, Cape Town. Opening night of "The Raven King".**
In Act Three, the usurper stabs the old king with a folding stage dagger. Every night, the king
falls, the audience gasps, and the actor gets up for his bow.
Tonight, **Desmond Ashcroft** didn't get up. The dagger didn't fold. It was real.
The understudy who held it is in shock. But he didn't bring a real dagger on stage. **Someone switched it.**
⚠️ *Hard case: the locks give you facts. You'll connect them in the accusation.*
suspects:
- id: felix
name: Felix Moore
role: The usurper (Desmond's understudy)
description: Held the dagger. Gets Desmond's role if anything happens to him.
- id: vivienne
name: Vivienne Shaw
role: Leading lady, plays Queen Margot
description: Desmond's co-star of fifteen years. Their dressing-room arguments are legendary.
- id: mia
name: Mia Daniels
role: Rising star, plays Lady Ros
description: Twenty-two, brilliant, and rumoured to be Desmond's next big discovery.
- id: owen
name: Owen Price
role: Director
description: Desmond embarrassed him in front of the cast at every rehearsal.
- id: greta
name: Greta Lindqvist
role: Props master
description: Sets every prop herself. Swears her table was perfect at curtain up.
chapters:
- id: ch1
title: Two Daggers
narrative: |
The theatre owns more than one dagger. Which one killed Desmond, and where did it come from?
clues:
A:
- id: a1-forensic
title: Forensic note on the weapon
type: report
body: |
Blade **21 cm, double-edged, steel**. Hallmark on the hilt: **a crown over the letter B**.
Light dust in the crossguard grooves. This blade had **not been handled in a long time**.
B:
- id: b1-inventory
title: Props and display inventory
type: table
body: |
| Item | Where it lives | Blade | Notes |
|------|----------------|-------|-------|
| Stage dagger (folding) | Props table, stage left wings | 21 cm aluminium, retracts | Used nightly |
| Spare stage dagger | Props cupboard (locked) | 21 cm aluminium, retracts | Still in cupboard |
| 1952 "Raven King" dagger | **Foyer display case** | 21 cm double-edged steel, crown-over-B hallmark | Display only. Case "sticky", opens with a hairpin |
| Director's letter opener | Owen's office | 15 cm brass | On his desk |
- id: b1-greta
title: Greta's props note
type: note
body: |
*Checked my table at 19:30, all present and correct. Stage dagger folds. Set it on the BANQUET TABLE prop at the start of Act 3 myself.*
lock:
id: lock-dagger
prompt: Where did the murder weapon come from?
inputType: text
format: The location
answers: ["FOYER", "THEFOYER", "FOYERDISPLAYCASE", "DISPLAYCASE", "FOYERCASE", "THEDISPLAYCASE"]
hints:
- Match the blade description to the inventory.
- Steel, double-edged, crown over B, and dusty.
- The 1952 display dagger in the foyer case.
successText: |
**The foyer display case.** The 1952 dagger was taken from the case (sticky lock, opens with a hairpin)
and swapped for the folding prop. The stage dagger lay on the banquet table during Act 3,
within reach of the actors in that scene.
keyName: Foyer Key
- id: ch2
title: Stage Left
narrative: |
During the banquet scene, a woman in grey lifted the dagger from the banquet table and put it back.
Two actresses wear grey gowns in that scene.
clues:
A:
- id: a2-witness
title: Your notebook, row C, centre
type: note
body: |
*Banquet scene. The lady in grey on the **RIGHT-hand side of the stage** (as I look at it) picks up
the dagger, turns her back to us, puts it down again. Odd. Not in last night's preview.*
B:
- id: b2-promptbook
title: Prompt book, Act 3 banquet scene blocking
type: document
body: |
*All directions are from the ACTOR's point of view, facing the audience.*
- **Queen Margot** (Vivienne, grey gown): **DSL**, downstage left, at the end of the banquet table.
- **Lady Ros** (Mia, grey gown): **DSR**, downstage right, by the candelabra.
- The King (Desmond): centre, seated.
- The Usurper (Felix): enters **UC**, upstage centre, after the toast.
lock:
id: lock-stageleft
prompt: Which actress handled the dagger?
inputType: choice
options:
- { id: vivienne, label: Vivienne Shaw }
- { id: mia, label: Mia Daniels }
- { id: greta, label: Greta Lindqvist }
answers: ["vivienne"]
hints:
- Stage directions are from the actor's view. The critic was looking from the audience.
- The audience's right is the actor's left.
- The audience's right is stage left, and that's Queen Margot. That's Vivienne.
successText: |
**Vivienne Shaw.** The audience's right is stage left, and Queen Margot stands at stage left,
right at the end of the banquet table where the dagger lay.
keyName: Spotlight Key
- id: ch3
title: The Typewriter Card
narrative: |
In Desmond's dressing room, his curtain-call speech card. He typed it himself on his ancient typewriter.
It looks like nonsense.
clues:
A:
- id: a3-card
title: Desmond's speech card
type: note
body: |
*"Thank you all. And tonight, a little announcement:"*
## DJR OD PIY GTOFSU
*"...and please welcome my new leading lady!"*
B:
- id: b3-typist
title: Backstage gossip about Desmond's typewriter
type: note
body: |
"He typed everything without looking, and **his hands always started one key too far to the RIGHT.**
Every letter comes out as the key to the right of the one he meant. He never noticed."
- id: b3-keyboard
title: Keyboard layout (stage-door office typewriter)
type: diagram
body: |
```
Q W E R T Y U I O P
A S D F G H J K L
Z X C V B N M
```
lock:
id: lock-card
prompt: What did Desmond mean to type?
inputType: text
format: The message (4 words)
answers: ["SHEISOUTFRIDAY"]
hints:
- Each typed letter is one key to the right of the intended one. Move every letter one key LEFT.
- D → S, J → H, R → E.
- SHE IS OUT FRIDAY.
successText: |
**"SHE IS OUT FRIDAY."** At the curtain call, Desmond was going to announce that his leading lady
of fifteen years was finished, and introduce **Mia** as his new lead.
keyName: Typewriter Key
- id: ch4
title: The Display Key
narrative: |
The foyer case has a proper key too. Someone signed it out tonight.
clues:
A:
- id: a4-signout
title: Key cabinet sign-out sheet
type: log
body: |
| Key | Out | In | Dressing room |
|-----|-----|----|---------------|
| Foyer display case | 19:40 | 19:55 | **Room 1** |
| Props cupboard | 18:30 | 18:45 | Room 6 |
B:
- id: b4-rooms
title: Dressing room list (by billing)
type: table
body: |
| Room | Who |
|------|-----|
| 1 | Director's office (Owen Price) |
| 2 | Desmond Ashcroft |
| 3 | Vivienne Shaw |
| 4 | Felix Moore |
| 5 | Mia Daniels |
| 6 | Props (Greta Lindqvist) |
- id: b4-owen
title: Owen, cornered in the bar
type: transcript
body: |
"Yes, I took the key! I showed a reviewer the old dagger before curtain up. It was in the case
when I locked it at five to eight. Ask her. Anyway, that lock's so sticky you could open it with a hairpin."
lock:
id: lock-key
prompt: Who signed out the display-case key?
inputType: choice
options:
- { id: felix, label: Felix Moore }
- { id: vivienne, label: Vivienne Shaw }
- { id: mia, label: Mia Daniels }
- { id: owen, label: Owen Price }
- { id: greta, label: Greta Lindqvist }
answers: ["owen"]
hints:
- The sign-out sheet uses dressing room numbers.
- Room 1 belongs to the director.
- Owen Price.
successText: |
**Owen**, from 19:40 to 19:55. The reviewer confirms the dagger was in the case at 19:55.
So the dagger left the case **after** 19:55, and the key wasn't needed: a hairpin would do.
Greta notes that one of **Queen Margot's** jewelled hairpins went missing during the first interval.
keyName: Cabinet Key
finale:
type: accusation
prompt: The house lights are up. Name the killer.
maxAttempts: 2
questions:
- id: who
label: Who killed Desmond Ashcroft?
options:
- { id: felix, label: Felix Moore }
- { id: vivienne, label: Vivienne Shaw }
- { id: mia, label: Mia Daniels }
- { id: owen, label: Owen Price }
- { id: greta, label: Greta Lindqvist }
answer: vivienne
- id: how
label: How?
options:
- { id: swap, label: Took the 1952 dagger from the foyer case with a hairpin and swapped it for the prop on the banquet table }
- { id: felix, label: Felix brought a real dagger on stage on purpose }
- { id: props, label: Greta set the wrong dagger on the props table before the show }
- { id: key, label: Used the signed-out key to take the dagger before curtain up }
answer: swap
- id: why
label: Why?
options:
- { id: role, label: To take over his role }
- { id: fired, label: He was about to fire her at the curtain call and announce a new leading lady }
- { id: humiliation, label: Revenge for years of humiliation in rehearsals }
- { id: money, label: An unpaid debt }
answer: fired
successEnding: |
Vivienne is still in Queen Margot's grey gown. She takes a jewelled hairpin out of her sleeve
and lays it on the table.
"Fifteen years I carried him. And he was going to replace me in front of a full house."
In the interval she slipped into the foyer, opened the sticky case with her hairpin, and hid the 1952 dagger
in her gown. In the banquet scene, at stage left, back to the audience, she swapped it for the prop.
Felix never knew what he was holding.
**Case closed.** The understudy will go on tomorrow. 🎭🔑
failEnding: |
The wrong person is arrested. The play closes after one night, and Queen Margot takes the lead in the next one.
reveal: |
**Vivienne Shaw** used a hairpin to take the **1952 dagger from the foyer case** and **swapped it for the prop
on the banquet table**, because Desmond was about to **fire her and announce a new leading lady**.
+1 -1
View File
@@ -311,7 +311,7 @@ finale:
label: How?
options:
- { id: tea, label: Peanut in the tea Zanele served him at 23:24 }
- { id: honey, label: Peanut oil in the REX ONLY honey during the camera outage, EpiPen taken using Kyle's stolen card }
- { id: honey, label: "Peanut oil in the REX ONLY honey during the camera outage, EpiPen taken using Kyle's stolen card" }
- { id: epipen, label: Swapped his EpiPen for an empty one earlier in the day }
- { id: lozenge, label: A peanut-laced throat lozenge from a fan's flowers }
answer: honey
+243
View File
@@ -0,0 +1,243 @@
id: death-at-the-braai
engine: story
category: murder-mystery
title: Death at the Braai
tagline: A Saturday braai in Pretoria. Boerewors, brandy, and one guest who never got dessert.
estimatedMinutes: 20
difficulty: 2
players: { min: 2, max: 2 }
theme: { primary: "#a23b1d", accent: "#f2a541", background: "#160b07", headingFont: "Playfair Display" }
roles:
A:
name: The Detective
blurb: Off duty, but you were invited. You have the police notes and everyone's statements.
B:
name: The Neighbour
blurb: You live next door and see everything over the wall. Photos, gossip, and the garden.
intro: |
**Waterkloof, Pretoria. Saturday, 18:20.**
Gerrit Botha throws the best braai in the street. Tonight: boerewors, lamb sosaties, and his famous
"never-fail" fire. Then, halfway through dishing up, Gerrit grabs his chest and goes down by the braai stand.
The paramedics can't save him. And the doctor says it wasn't his heart. Not on its own, anyway.
suspects:
- id: marlene
name: Marlene Botha
role: Gerrit's wife
description: Hostess. Running between the kitchen and the salads all evening.
- id: dawie
name: Dawie van Wyk
role: Neighbour on the other side
description: Three-year feud with Gerrit over a boundary wall and a jacaranda.
- id: thandi
name: Thandi Mokoena
role: Business partner
description: Co-owns Botha & Mokoena Butchery with Gerrit. Does the books.
- id: riaan
name: Riaan Botha
role: Nephew
description: Twenty-four, broke, and suspiciously keen to help with the cooking.
- id: annelie
name: Annelie Smit
role: Marlene's sister
description: Never liked Gerrit. Says so often.
chapters:
- id: ch1
title: The Fire
narrative: |
Gerrit collapsed the moment he took the meat off the grid. When exactly was that?
clues:
A:
- id: a1-marlene
title: Marlene's statement
type: transcript
body: |
"Gerrit has rules. The meat goes on the **second the coals are ready**, not a minute later.
Sosaties and wors together, **exactly 20 minutes**. He took them off and then he just... fell."
B:
- id: b1-wall
title: What you saw over the wall
type: note
body: |
- Gerrit lit the fire at **17:10**. You checked your watch because the smoke came straight into your lounge, as usual.
- Gerrit always says his coals take **45 minutes** to be ready. He's never wrong. It's annoying.
lock:
id: lock-time
prompt: What time did Gerrit collapse?
inputType: code
format: "HH:MM"
answers: ["1815", "615", "615PM"]
hints:
- Work out when the coals were ready first.
- 17:10 plus 45 minutes is 17:55. Then the meat cooked for 20 minutes.
- 17:55 + 20 = 18:15.
successText: |
**18:15.** The meat was on the fire from 17:55 to 18:15. Whatever killed Gerrit
was eaten or drunk right at the end of that.
keyName: Tongs Key
- id: ch2
title: Who Turned the Meat?
narrative: |
Gerrit never leaves his fire. Except, it turns out, once tonight.
clues:
A:
- id: a2-riaan
title: Riaan's statement
type: transcript
body: |
"Oom Gerrit ran inside for ice at about six. Someone in the **Bulls apron** took over the tongs for him.
I didn't see who, I was carrying chairs. They swapped the sosaties around a bit, that's all I saw."
- id: a2-annelie
title: Annelie's statement
type: transcript
body: |
"I was in the kitchen with Marlene making the potato salad from half five until he fell. Ask her."
B:
- id: b2-photos
title: Your phone photos (timestamped)
type: photo
body: |
- *17:48:* Gerrit in his **Springbok** shirt, poking the coals.
- *17:50:* Thandi borrowing the **Bulls apron** from the hook "so I don't ruin my white dress".
- *17:58:* Dawie glaring at the jacaranda in his **Stormers** jersey, beer in hand.
- *18:01:* Someone at the braai in the Bulls apron, back to the camera.
lock:
id: lock-apron
prompt: Who was at the braai while Gerrit fetched ice?
inputType: choice
options:
- { id: marlene, label: Marlene Botha }
- { id: dawie, label: Dawie van Wyk }
- { id: thandi, label: Thandi Mokoena }
- { id: riaan, label: Riaan Botha }
- { id: annelie, label: Annelie Smit }
answers: ["thandi"]
hints:
- Riaan saw the apron, not the face. Who was wearing that apron?
- Check who borrowed the Bulls apron at 17:50.
- Thandi.
successText: |
**Thandi** was minding the fire at 18:00, in the Bulls apron, swapping sosaties around.
keyName: Apron Key
- id: ch3
title: The Sosatie Sticks
narrative: |
The paramedic found green leaves stuck to the bottom of Gerrit's plate. The lab rushed his sosatie.
clues:
A:
- id: a3-lab
title: Lab report
type: report
body: |
Poison: **oleandrin**, a heart toxin. Found **in the meat**, soaked in from the **skewer stick**,
not in any sauce or drink. Gerrit's skewer was a fresh green stick, not bamboo.
*Only the plant itself contains oleandrin.*
B:
- id: b3-garden
title: Your garden notes
type: note
body: |
- Gerrit's hedge by the gate is **oleander**: pink flowers, long thin leaves. Everyone in Pretoria
knows you never use oleander sticks for skewers. It's an old warning.
- This afternoon, a few **oleander branches** were freshly cut, stripped of leaves.
- All the other sosaties were on **bamboo** skewers from the shop.
lock:
id: lock-plant
prompt: What were the deadly skewers cut from?
inputType: text
format: One word
answers: ["OLEANDER", "OLEANDERS", "NERIUM", "NERIUMOLEANDER"]
hints:
- The lab names the poison. The neighbour knows the garden.
- Oleandrin comes from one plant.
- Oleander, from the hedge by the gate.
successText: |
**Oleander.** Someone made one special sosatie on an oleander stick and swapped it onto
Gerrit's side of the grid while he was getting ice.
keyName: Skewer Key
- id: ch4
title: Gerrit's Note
narrative: |
On Gerrit's phone, a note to himself from this morning. Just numbers. Marlene says he did that
"so nosy people can't read my business."
clues:
A:
- id: a4-phone
title: Gerrit's phone note
type: note
body: |
## 1·21·4·9·20 · 13·15·14·4·1·25
## 20·8·5 · 20·9·12·12 · 9·19 · 19·8·15·18·20
B:
- id: b4-gossip
title: What you know about the butchery
type: note
body: |
- Gerrit told you last week: *"A=1, B=2... my old army trick. Still works."*
- Thandi does all the books for Botha & Mokoena Butchery. Gerrit "can't read a spreadsheet to save his life."
- Riaan owes Gerrit R20,000. Dawie wants the jacaranda gone. Annelie just doesn't like him.
lock:
id: lock-note
prompt: What did Gerrit's note say?
inputType: text
format: The message
answers: ["AUDITMONDAYTHETILLISSHORT"]
hints:
- Each number is a letter. A=1, B=2, and so on.
- "1-21-4-9-20 = AUDIT."
- AUDIT MONDAY. THE TILL IS SHORT.
successText: |
**"AUDIT MONDAY. THE TILL IS SHORT."** Gerrit had found money missing from the butchery,
and he'd booked an audit. Only one person kept those books.
keyName: Phone Key
finale:
type: accusation
prompt: Make your accusation before the pudding goes cold.
maxAttempts: 2
questions:
- id: who
label: Who killed Gerrit?
options:
- { id: marlene, label: Marlene Botha }
- { id: dawie, label: Dawie van Wyk }
- { id: thandi, label: Thandi Mokoena }
- { id: riaan, label: Riaan Botha }
- { id: annelie, label: Annelie Smit }
answer: thandi
- id: how
label: How?
options:
- { id: brandy, label: Poison in his brandy and Coke }
- { id: skewer, label: "A sosatie on an oleander stick, swapped onto his side while he fetched ice" }
- { id: salad, label: Poison in the potato salad }
- { id: gas, label: Fumes from a tampered gas bottle }
answer: skewer
- id: why
label: Why?
options:
- { id: wall, label: The boundary wall feud }
- { id: debt, label: "To get out of a R20,000 debt" }
- { id: till, label: To stop the audit that would expose her stealing from the butchery }
- { id: sister, label: Family resentment }
answer: till
successEnding: |
Thandi takes off the Bulls apron and folds it, very neatly.
"It was going to be paid back. All of it. He didn't have to call in auditors like I was a criminal."
She cut the oleander that afternoon, threaded one sosatie on it at home, and slipped it onto
Gerrit's side of the grid while he fetched ice.
**Case closed.** Somebody else will have to braai next Saturday. 🔥🔑
failEnding: |
The wrong person is arrested. Monday's audit never happens, and the butchery's money keeps disappearing.
reveal: |
**Thandi Mokoena** swapped a **sosatie on an oleander stick** onto Gerrit's side while he fetched ice,
to stop the **audit that would expose her stealing from the butchery**.
+408
View File
@@ -0,0 +1,408 @@
id: hawthorn-park-cipher
engine: story
category: murder-mystery
title: The Hawthorn Park Cipher
tagline: 1943. A secret codebreaking station. A codebreaker is dead, and there's a spy in the huts.
estimatedMinutes: 60
difficulty: 5
players: { min: 2, max: 2 }
theme: { primary: "#3a5a40", accent: "#dad7cd", background: "#0b0f0b", headingFont: "Playfair Display" }
rules:
hintPenaltySeconds: 240
timeLimitMinutes: 75
timeLimitLabel: "Until MI5 arrives"
roles:
A:
name: The Security Officer
blurb: Sent from London overnight. You have the intercepts, the statements, and the dead woman's desk.
B:
name: The Hut Supervisor
blurb: You run Hut 7. Key sheets, rotas, inventories, logs, and who sings in the Thursday choir.
intro: |
**Hawthorn Park, Buckinghamshire. 14 January 1943.**
Behind the fences, hundreds of people break enemy codes in freezing wooden huts. Nobody outside knows.
At 03:00, **Eleanor Finch**, the best codebreaker in Hut 7, is found dead at her desk.
The stove is out. The air smells wrong. On her desk, a typed note accuses a colleague of being a spy.
Eleanor was working on something alone. Someone made sure she never finished.
☠️ *Nightmare case. One accusation. A hard time limit. The locks give you pieces, not the answer.*
suspects:
- id: penrose
name: Prof. Arthur Penrose
role: Head of Hut 7
description: Brilliant, distracted, protective of "his" codebreakers. Argued with Eleanor last week.
- id: kemp
name: Dorothy "Dot" Kemp
role: Wren, teleprinter operator
description: Cheerful, kind, sings in the station choir. Sends Hut 7's decrypts to London.
- id: harrow
name: Lt. Miles Harrow
role: Army liaison officer
description: Handsome, secretive, and seen leaving Eleanor's billet twice this month.
- id: nowak
name: Stanisław Nowak
role: Polish mathematician
description: Escaped Warsaw in 1939. Some of the staff whisper about him because he's foreign. Unfairly.
- id: greaves
name: Rupert Greaves
role: Chess champion, new recruit
description: Recruited through a newspaper crossword. Arrogant, brilliant, always in debt.
- id: ashworth
name: Beatrice Ashworth
role: Canteen manager
description: Knows who eats what and when. Sees everyone come and go.
chapters:
- id: ch1
title: The Intercept
narrative: |
Locked in Eleanor's drawer: an enemy intercept she'd been working on alone. Hut 7 uses a simplified
"rotor" method for training, and Eleanor had pencilled the method in the margin.
clues:
A:
- id: a1-intercept
title: Intercept, dated 14 JAN 43
type: document
body: |
```
EAZBOEKEZLF THTTXAA OBZY UII IVNXH
```
*Eleanor's margin note:* "Rotor steps one place per letter. Spaces don't count.
Undo it: shift letter 1 back by the day's key, letter 2 back by key + 1, letter 3 by key + 2..."
- id: a1-alphabet
title: Alphabet strip (A = 0)
type: diagram
body: |
```
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
```
B:
- id: b1-keysheet
title: Training key sheet, January 1943
type: table
body: |
| Date | Key letter |
|------|------------|
| 12 JAN | M |
| 13 JAN | C |
| **14 JAN** | **R** |
| 15 JAN | W |
*Key letter → number using A = 0.*
- id: b1-nowak
title: Found in Stanisław Nowak's coat (by a "concerned colleague")
type: note
body: |
*A scrap of German newspaper.* Nowak: "I read every newspaper I can find. I am a mathematician, not a spy.
Someone put that there."
lock:
id: lock-intercept
prompt: What is the spy's codename?
inputType: text
format: One word
answers: ["NIGHTINGALE"]
hints:
- The key for 14 JAN is R = 17. Letter 1 shifts back 17, letter 2 back 18, and so on.
- "E − 17 = N. A − 18 = I. Z − 19 = G."
- "The message reads NIGHTINGALE REPORTS FROM HUT SEVEN."
successText: |
**"NIGHTINGALE REPORTS FROM HUT SEVEN."** An enemy agent, codename **Nightingale**, is inside Hut 7.
Eleanor had worked it out. She just hadn't worked out who.
keyName: Rotor Key
- id: ch2
title: The Crossword
narrative: |
Eleanor kept a half-finished crossword-style puzzle she'd been setting for the hut. Circled squares.
She'd scribbled: *"If anything happens, the answer's in the circles."*
clues:
A:
- id: a2-clues
title: Eleanor's clues
type: document
body: |
1. A lieutenant or a captain, for instance
2. How Hut 7 feels at three in the morning in January
3. The submarine the Navy fears most (hyphen ignored)
4. The machine every hut is fighting
- id: a2-coroner
title: Medical officer's first note
type: report
body: |
Cherry-pink colour to the skin. Headache tablets on the desk. **Carbon monoxide poisoning**.
The hut stove was alight until about 02:00, then went out. Something stopped it drawing air.
B:
- id: b2-grid
title: Eleanor's answer slots
type: diagram
body: |
```
1. _ (_) _ _ _ _ _ 7 letters, circle on letter 2
2. _ _ (_) _ 4 letters, circle on letter 3
3. (_) _ _ _ _ 5 letters, circle on letter 1
4. (_) _ _ _ _ _ 6 letters, circle on letter 1
```
lock:
id: lock-crossword
prompt: What do the circled letters spell?
inputType: text
format: 4 letters
answers: ["FLUE"]
hints:
- The answers are OFFICER, COLD, UBOAT and ENIGMA.
- Take letter 2 of OFFICER, letter 3 of COLD, letter 1 of UBOAT, letter 1 of ENIGMA.
- F-L-U-E.
successText: |
**FLUE.** Eleanor suspected the stove. The police climb onto Hut 7's roof: **a rag stuffed into the stove flue**.
Smoke and carbon monoxide poured back into the hut. A ladder was leant against the back wall.
This wasn't an accident.
keyName: Crossword Key
- id: ch3
title: The Typed Note
narrative: |
The note on Eleanor's desk reads: *"Nowak is the spy. I have proof. — E.F."* It's typed.
Every typewriter at Hawthorn Park has its own flaws.
clues:
A:
- id: a3-note
title: The note, under a magnifying glass
type: report
body: |
- Every lower-case **e** sits slightly **above** the line.
- The **t** is missing the left half of its crossbar.
- Capital letters are **smudged**, as if the type is clogged.
- The "E.F." is typed, not signed. Eleanor signed everything by hand.
B:
- id: b3-machines
title: Typewriter inventory and known faults
type: table
body: |
| Machine | Location | e above line | broken t | smudged capitals |
|---------|----------|--------------|----------|------------------|
| 1 | Hut 7, Eleanor's desk | yes | no | no |
| 2 | Hut 7, Penrose's office | yes | yes | no |
| 3 | Hut 3 | no | yes | yes |
| 4 | **Teleprinter Room** | yes | yes | yes |
| 5 | Canteen office | no | no | yes |
| 6 | Liaison office (Harrow) | yes | no | yes |
lock:
id: lock-typewriter
prompt: Which machine typed the note?
inputType: code
format: Machine number
answers: ["4"]
hints:
- The note has three faults. Find the machine with all three.
- Machines 2, 3 and 6 each have two of the three faults.
- Machine 4, in the Teleprinter Room.
successText: |
**Machine 4, the Teleprinter Room.** Not Eleanor's typewriter. Someone framed Nowak with a forged note.
The Teleprinter Room is staffed overnight by two people: **Dot Kemp** and, until midnight, **Rupert Greaves** (doing a filing shift for extra pay).
keyName: Typewriter Key
- id: ch4
title: The Silent Teleprinter
narrative: |
To stuff a rag in Hut 7's flue, someone had to leave their post, fetch a ladder, climb the roof, and come back.
The flue was blocked around 01:30, give or take.
clues:
A:
- id: a4-kemp
title: Dot Kemp's statement
type: transcript
body: |
"I was on the teleprinter all night, sending Hut 7's decrypts to London. I never left my machine once.
Ask anyone. It clatters like a train, you'd have heard it."
- id: a4-greaves
title: Greaves's statement
type: transcript
body: |
"I left the Teleprinter Room at midnight and went to the canteen. Mrs Ashworth gave me cocoa. I lost at
chess to the night guard until four. Badly."
B:
- id: b4-tape
title: Teleprinter outgoing log, night of 13–14 Jan
type: log
body: |
| Time sent | Message |
|-----------|---------|
| 00:35 | HUT7/0412 |
| 00:50 | HUT7/0413 |
| 01:05 | HUT7/0414 |
| **01:20** | HUT7/0415 |
| **02:05** | HUT7/0416 |
| 02:20 | HUT7/0417 |
| 02:35 | HUT7/0418 |
*Normal rhythm: one message every 15 minutes while there's traffic. There was traffic all night.*
- id: b4-canteen
title: Mrs Ashworth's canteen book
type: log
body: |
00:05 Greaves, cocoa. Still here at 04:00 playing chess with Sgt Bell.
01:15 Lt Harrow, tea, left 01:25 for the liaison office (phone logs show him calling London 01:30–02:10).
lock:
id: lock-silent
prompt: For how many minutes was the teleprinter silent?
inputType: code
format: Minutes
answers: ["45"]
hints:
- Look for the gap that breaks the 15-minute rhythm.
- Nothing was sent between 01:20 and 02:05.
- 45 minutes.
successText: |
**45 minutes**, 01:20 to 02:05, right across the time the flue was blocked. The machine that
"clatters like a train" went quiet. Dot Kemp said she never left it.
keyName: Teleprinter Key
- id: ch5
title: Eleanor's Tape
narrative: |
Under Eleanor's blotter: a short strip of punched paper tape, the kind the teleprinters use.
Eleanor had punched it by hand. Five holes per row.
clues:
A:
- id: a5-tape
title: The punched tape (● hole, ○ no hole)
type: diagram
body: |
```
● ○ ○ ● ●
○ ● ○ ○ ○
○ ○ ● ○ ●
● ○ ○ ● ●
○ ● ○ ○ ●
○ ● ● ● ○
○ ○ ● ● ●
● ○ ○ ● ●
```
B:
- id: b5-code
title: Hut 7 training code card
type: document
body: |
Each row of a training tape is one letter, written in **binary**: ● = 1, ○ = 0,
read **left to right**, biggest place first (16, 8, 4, 2, 1).
The number is the letter's place in the alphabet: 1 = A, 2 = B... 26 = Z.
lock:
id: lock-tape
prompt: What did Eleanor punch?
inputType: text
format: The message (2 words)
answers: ["SHESINGS"]
hints:
- "Row 1: ●○○●● = 16 + 2 + 1 = 19."
- 19 is S. Row 2 is 8 = H.
- "19, 8, 5, 19, 9, 14, 7, 19 = SHE SINGS."
successText: |
**"SHE SINGS."** A nightingale sings. Eleanor knew the spy was a woman, and a singer.
keyName: Tape Key
- id: ch6
title: The Thursday Choir
narrative: |
Hawthorn Park's choir rehearses every Thursday in the chapel. Several women sing in it.
clues:
A:
- id: a6-programme
title: Christmas concert programme, December 1942
type: document
body: |
1. Carol: "In the Bleak Midwinter" · Full choir
2. Song: **"A Nightingale Sang in Berkeley Square"** · *Soloist: D. K.*
3. Carol: "Silent Night" · Sopranos
4. Readings · Prof. Penrose
B:
- id: b6-roster
title: Choir roster
type: table
body: |
| Name | Voice |
|------|-------|
| Beatrice Ashworth | Alto |
| **Dorothy Kemp** | **Soprano** (soloist) |
| Rupert Greaves | Tenor |
| Arthur Penrose | Bass |
| Eleanor Finch | Alto |
*Nicknames are common: the choir calls Dorothy "our little nightingale".*
lock:
id: lock-choir
prompt: Who is D. K.?
inputType: choice
options:
- { id: penrose, label: Prof. Arthur Penrose }
- { id: kemp, label: Dorothy "Dot" Kemp }
- { id: harrow, label: Lt. Miles Harrow }
- { id: nowak, label: Stanisław Nowak }
- { id: greaves, label: Rupert Greaves }
- { id: ashworth, label: Beatrice Ashworth }
answers: ["kemp"]
hints:
- Match the initials to the roster.
- Only one choir member has the initials D. K.
- Dorothy Kemp.
successText: |
**Dorothy Kemp.** The choir's "little nightingale" sang the Nightingale solo at Christmas.
keyName: Choir Key
finale:
type: accusation
prompt: MI5's car is at the gate. One accusation. Every part must be right.
maxAttempts: 1
questions:
- id: who
label: Who killed Eleanor Finch?
options:
- { id: penrose, label: Prof. Arthur Penrose }
- { id: kemp, label: Dorothy "Dot" Kemp }
- { id: harrow, label: Lt. Miles Harrow }
- { id: nowak, label: Stanisław Nowak }
- { id: greaves, label: Rupert Greaves }
- { id: ashworth, label: Beatrice Ashworth }
answer: kemp
- id: how
label: How?
options:
- { id: flue, label: Blocked the Hut 7 stove flue with a rag from the roof during the teleprinter's silence }
- { id: poison, label: Poisoned her tea }
- { id: gas, label: Turned on a gas tap in the hut }
- { id: tablets, label: Swapped her headache tablets }
answer: flue
- id: note
label: What about the note on Eleanor's desk?
options:
- { id: real, label: Eleanor wrote it; Nowak really is the spy }
- { id: forged, label: Forged on Teleprinter Room machine 4 to frame Nowak }
- { id: harrow, label: Typed by Harrow in the liaison office }
- { id: penrose, label: Typed by Penrose to protect the hut }
answer: forged
- id: why
label: Why?
options:
- { id: affair, label: Jealousy over Lt. Harrow }
- { id: spy, label: Eleanor had uncovered that she was the enemy agent Nightingale }
- { id: promotion, label: To get Eleanor's job }
- { id: debt, label: Eleanor knew about her gambling debts }
answer: spy
successEnding: |
Dot Kemp stops smiling for the first time anyone can remember.
"She was so close. Another day and she'd have had me."
At 01:20 Dot left her teleprinter, took the gardener's ladder, climbed onto Hut 7's roof and stuffed a rag
into the stove flue. Back at her machine by 02:05. Before dawn she typed a note on machine 4 to frame
Stanisław Nowak, the foreigner everyone already whispered about, and slipped it onto Eleanor's desk
when the hut was cleared.
**Nightingale is caught.** Nowak gets an apology from the Prime Minister's office. Quietly. 🕵️🔑
failEnding: |
MI5 takes away the wrong person. Nightingale keeps singing, and the decrypts keep leaking.
reveal: |
**Dorothy "Dot" Kemp**, codename **Nightingale**, blocked the **Hut 7 stove flue** with a rag during the
45-minute silence of her teleprinter, then **forged a note on machine 4 to frame Nowak**, because
Eleanor had **uncovered that she was the spy**.
+82
View File
@@ -0,0 +1,82 @@
id: hedge-maze-midnight
engine: labyrinth
category: co-op
title: The Hedge Maze at Midnight
tagline: An old estate maze, a locked iron gate, and somewhere in the hedges, the key.
estimatedMinutes: 15
difficulty: 3
players: { min: 2, max: 2 }
theme: { primary: "#2d6a4f", accent: "#ffd166", background: "#06110b", headingFont: "Playfair Display" }
roles:
A:
name: Navigator
blurb: You have the gardener's plan of the maze from the balcony. You can't see your partner in the dark.
B:
name: Explorer
blurb: You're in the hedges with a candle. You see the ground beneath you and nothing else.
intro: |
Midsummer at Ravenscourt House. Tradition says couples who light all three **garden lanterns** in the
hedge maze before the clock strikes one will be lucky for a year.
The heart of the maze is behind an **iron gate**, and the gate is locked. Its key was dropped somewhere
in the hedges a century ago. **Four** stretches of the path hide old gardener's traps.
New: **items**. The Explorer picks up an item by stepping on it. The gate only opens for the Explorer carrying its key.
# Grid legend: # hedge · . path · S start · E exit (needs all lanterns) · K lantern · T trap
grid:
- "###############"
- "#S....#K.T.T..#"
- "#.#.#.#.#####.#"
- "#...#.#..T#...#"
- "#.#.#.##.##.#.#"
- "#.#T..#.....#.#"
- "#.#####.#####.#"
- "#.#.....#...#.#"
- "#K###K###.#.#.#"
- "#.....#......E#"
- "###############"
lanternsRequired: 3
# NEW: items and doors. Items are picked up automatically on entry (kept after traps).
# A door cell is impassable until the Explorer carries the item named in `needs`.
items:
- { x: 5, y: 9, id: gate-key, name: "Iron Gate Key", explorerText: "Something glints in the grass: a heavy iron key!" }
doors:
- { x: 5, y: 7, needs: gate-key, name: "Iron Gate", lockedText: "A locked iron gate. You need its key.", openText: "The key turns. The gate creaks open." }
glyphs:
- { x: 1, y: 1, glyph: fountain, label: "⛲ A stone fountain (the entrance)" }
- { x: 3, y: 3, glyph: rabbit, label: "🐇 A stone rabbit" }
- { x: 5, y: 4, glyph: sundial, label: "🕰 A mossy sundial" }
- { x: 1, y: 6, glyph: bench, label: "🪑 A wooden bench" }
- { x: 3, y: 9, glyph: owl, label: "🦉 An owl watching from the hedge" }
- { x: 7, y: 5, glyph: urn, label: "🏺 A cracked urn" }
- { x: 10, y: 7, glyph: rose, label: "🌹 A climbing rose" }
- { x: 13, y: 4, glyph: statue, label: "🗿 A statue with no face" }
- { x: 9, y: 9, glyph: frog, label: "🐸 A frog on a stepping stone" }
- { x: 12, y: 3, glyph: bell, label: "🔔 A tiny bell on a ribbon" }
lanterns:
- { x: 1, y: 8, name: Lantern of the Bench }
- { x: 5, y: 8, name: Lantern of the Gate }
- { x: 7, y: 1, name: Lantern of the High Hedge }
explorerView:
flavour:
blank: "Clipped grass and the smell of roses."
trap: "The ground drops into a ha-ha ditch! You climb out, muddy, back at the fountain."
lantern: "You light a paper lantern. It glows gold. ({name})"
exitLocked: "The maze's heart: a pavilion with three empty lantern hooks."
exitOpen: "Three lanterns glow. Somewhere, a clock strikes one, and fireworks go up over the house."
navigatorView:
showMoveCount: true
scoring:
trapPenaltySeconds: 60
parMoves: 70
successEnding: |
Three lanterns, one gate, zero arguments (mostly). A year of luck, guaranteed. 🏮🔑
+2 -2
View File
@@ -90,10 +90,10 @@ predict:
- { id: d09, q: "{subject}'s happiest childhood memory involves...", options: [A holiday, A family tradition, A friend, A pet] }
- { id: d10, q: "What would {subject} want written about them one day?", options: [They were generous, They were brave, They made people laugh, They loved well] }
- { id: d11, q: "What drains {subject} the fastest?", options: [Conflict, Crowds, Boredom, Being rushed] }
- { id: d12, q: "{subject}'s dream for us in 10 years?", options: [Travelling the world, A full, noisy home, Building something together, Peace and quiet somewhere beautiful] }
- { id: d12, q: "{subject}'s dream for us in 10 years?", options: [Travelling the world, "A full, noisy home", Building something together, Peace and quiet somewhere beautiful] }
- { id: d13, q: "What does {subject} think is our biggest strength as a couple?", options: [We laugh a lot, We fight fair, We have the same values, We're a good team] }
- { id: d14, q: "When {subject} is upset with me, they usually...", options: [Tell me straight away, Go quiet first, Joke about it, Wait until later to bring it up] }
- { id: d15, q: "Which of these would {subject} most want to learn?", options: [An instrument, A language, A sport, A craft (woodwork, pottery, etc.)] }
- { id: d15, q: "Which of these would {subject} most want to learn?", options: [An instrument, A language, A sport, "A craft (woodwork, pottery, etc.)"] }
- { id: d16, q: "What does {subject} spend the most time thinking about?", options: [The future, The past, Other people, Ideas and projects] }
- { id: d17, q: "If {subject} wrote a book, it'd be...", options: [A thriller, A memoir, A how-to, A fantasy epic] }
- { id: d18, q: "{subject}'s ideal way to celebrate a big win?", options: [Fancy dinner, Big party, Quiet night in, Buy something special] }
+110
View File
@@ -0,0 +1,110 @@
id: in-sync-road-trip
engine: sync
category: co-op
title: "In Sync: Road Trip"
tagline: Ten hours in a car together. Do you agree on snacks, music, and who's driving?
estimatedMinutes: 15
difficulty: 2
players: { min: 2, max: 2 }
theme: { primary: "#f77f00", accent: "#fcbf49", background: "#140b02", headingFont: "Playfair Display" }
roles:
A:
name: Driver
blurb: In the spotlight for Round 3.
B:
name: Navigator
blurb: In the spotlight for Round 4.
intro: |
You're planning the ultimate road trip. First, let's see if you'd survive it.
- **Round 1, Same Page:** both pick from the same options. Match = point.
- **Round 2, Telepathy:** both type one word. Match = point.
- **Rounds 3 & 4, Spotlight:** one answers about themselves, the other guesses.
3 out of 5 earns each key. One replay per round.
rules:
questionsPerRound: 5
pointsForKey: 3
replaysPerRound: 1
rounds:
- id: r1
title: Same Page
type: match
blurb: Both pick the answer you think your partner will pick.
keyName: Fuel Key
- id: r2
title: Telepathy
type: telepathy
blurb: Both type ONE word. Same word = point.
keyName: Radio Key
- id: r3
title: Spotlight on the Driver
type: predict
subject: A
blurb: The Driver answers about themselves. The Navigator guesses.
keyName: Ignition Key
- id: r4
title: Spotlight on the Navigator
type: predict
subject: B
blurb: The Navigator answers about themselves. The Driver guesses.
keyName: Map Key
match:
- { id: m01, q: "Our road trip destination?", options: [Garden Route, Drakensberg, Kruger, Cape Winelands] }
- { id: m02, q: "First snack out of the bag?", options: [Biltong, Chips, Sweets, Fruit (we're lying)] }
- { id: m03, q: "Car music situation?", options: [One shared playlist, Take turns, Podcasts, Silence and vibes] }
- { id: m04, q: "Who drives most of the way?", options: [Me, My partner, 50/50, Whoever loses rock-paper-scissors] }
- { id: m05, q: "Best road trip stop?", options: [Farm stall, Petrol station pie, Scenic lookout, A random small town] }
- { id: m06, q: "Where do we sleep?", options: [Fancy hotel, Cosy B&B, Camping, In the car (budget)] }
- { id: m07, q: "How do we handle getting lost?", options: [Laugh about it, Blame the GPS, Blame each other, Ask a stranger] }
- { id: m08, q: "Departure time?", options: [Before sunrise, Civilised morning, After lunch, Whenever we're packed] }
- { id: m09, q: "Car game of choice?", options: [I spy, 20 questions, Spot the number plate, Karaoke] }
- { id: m10, q: "Packing style?", options: [One small bag, Everything just in case, Last-minute chaos, Spreadsheet] }
- { id: m11, q: "The first argument will be about...", options: [Directions, Music, Aircon temperature, Bathroom stops] }
- { id: m12, q: "Perfect road trip length?", options: [A weekend, A week, Two weeks, Until the money runs out] }
- { id: m13, q: "Souvenir we'd bring home?", options: [Fridge magnet, Local food, Photos only, Something weird] }
- { id: m14, q: "Road trip breakfast?", options: [Wimpy, Coffee and rusks, Big hotel breakfast, Skip it and drive] }
- { id: m15, q: "Best time of day on the road?", options: [Sunrise, Midday, Golden hour, Night drive] }
- { id: m16, q: "Who falls asleep in the passenger seat first?", options: [Me, My partner, Neither, Both (uh oh)] }
telepathy:
- { id: t01, q: "A road trip snack." }
- { id: t02, q: "Something you always forget to pack." }
- { id: t03, q: "A South African town." }
- { id: t04, q: "A car colour." }
- { id: t05, q: "Something you see out the window on a long drive." }
- { id: t06, q: "A song you'd both sing along to (one word from the title)." }
- { id: t07, q: "An animal you'd stop the car for." }
- { id: t08, q: "A drink for the road." }
- { id: t09, q: "Something in the cubbyhole." }
- { id: t10, q: "A word for how you feel after 8 hours in the car." }
- { id: t11, q: "A fast-food place." }
- { id: t12, q: "A thing that makes a road trip perfect." }
- { id: t13, q: "Something you'd find at a farm stall." }
- { id: t14, q: "A car brand." }
- { id: t15, q: "A word you'd yell at a pothole." }
- { id: t16, q: "Somewhere you'd stop to stretch your legs." }
predict:
- { id: p01, q: "{subject}'s road trip superpower?", options: [Navigating, Snacks, Music, Keeping the peace] }
- { id: p02, q: "{subject}'s biggest road trip annoyance?", options: [Slow drivers, Bathroom stops, Bad music, Being hungry] }
- { id: p03, q: "If {subject} picked the music, it'd be...", options: [Throwbacks, Worship, Hip-hop, Whatever's trending] }
- { id: p04, q: "{subject} in the passenger seat is...", options: [The DJ, The backseat driver, Asleep, The snack dispenser] }
- { id: p05, q: "{subject}'s ideal pit stop meal?", options: [Pie, Burger, Toasted sarmie, A proper sit-down meal] }
- { id: p06, q: "Where would {subject} drive if they had a week and no plans?", options: [The coast, The mountains, The bush, Another country] }
- { id: p07, q: "{subject}'s packing mistake every time?", options: [Too many clothes, No charger, No jacket, Forgets toiletries] }
- { id: p08, q: "How does {subject} handle a flat tyre?", options: [Fixes it themselves, Calls for help, Panics a little, Makes me do it] }
- { id: p09, q: "{subject}'s favourite road trip memory type?", options: [A view, A meal, A song moment, A disaster we laugh about now] }
- { id: p10, q: "Would {subject} rather...", options: [Drive 10 hours straight, Stop every hour] }
- { id: p11, q: "{subject}'s petrol station buy?", options: [Coffee, Energy drink, Chocolate, "Nothing, just fuel"] }
- { id: p12, q: "When {subject} is tired on the road, they...", options: [Get quiet, Get grumpy, Get silly, Pull over and nap] }
- { id: p13, q: "{subject}'s road trip outfit?", options: [Comfy tracksuit, Shorts and slops, Actually stylish, "Pyjamas, honestly"] }
- { id: p14, q: "What would {subject} photograph most?", options: [Views, Food, Us, Random signs] }
- { id: p15, q: "{subject}'s dream car for the trip?", options: [Old-school Kombi, Big 4x4, Convertible, Whatever's paid off] }
- { id: p16, q: "{subject}'s first words at the destination?", options: ["Finally!", "Wow.", "Where's the bathroom?", "Let's eat."] }
successEnding: |
Snacks agreed. Playlist approved. Nobody got left at a petrol station. **Road trip ready.** 🚗🔑
+300
View File
@@ -0,0 +1,300 @@
id: inventors-paradox
engine: story
category: escape-room
title: The Inventor's Paradox
tagline: A mad inventor's lab, sealed since 1930. His time machine still hums. So do his traps.
estimatedMinutes: 50
difficulty: 5
players: { min: 2, max: 2 }
theme: { primary: "#7f5539", accent: "#80ffdb", background: "#0a0806", headingFont: "JetBrains Mono" }
rules:
hintPenaltySeconds: 240
timeLimitMinutes: 55
timeLimitLabel: "Reactor stability"
roles:
A:
name: Inside the Lab
blurb: You're in the workshop with the machines. Panels, dials, clocks and vials.
B:
name: The Archivist
blurb: You're in the locked study next door with the inventor's notebooks. You can read his mind, nearly.
intro: |
**The Halloway Laboratory, sealed since 1930.**
Professor Edmund Halloway claimed he'd built a working time machine, then vanished. His lab was bricked up.
Tonight you two are the first people inside in almost a hundred years. The moment you cross the threshold,
the doors seal and a reactor in the corner starts to hum, louder and louder.
A gramophone crackles into life with his voice:
> *"If you can hear this, you've triggered my safeguards. Only a mind that thinks in two places at once
> can shut them down. Good luck. You'll need each other."*
☠️ *Nightmare tier. Long hint penalty. A hard time limit. Some of it is maths. Breathe.*
chapters:
- id: ch1
title: The Switchboard
narrative: |
A brass panel with four switches (S1–S4) and one lamp. The lamp must light to open the inner door.
Halloway drew the circuit in his own private symbols.
clues:
A:
- id: a1-circuit
title: Circuit plate above the switches
type: diagram
body: |
```
LAMP = ( S1 ⊗ ¬S2 )
⊗ ( S3 ⊕ S4 )
⊗ ( S2 ⊘ S4 )
⊗ ¬( S1 ⊗ S3 )
```
Lock entry: the four switch positions as 1 (on) / 0 (off), S1 first. Only one setting lights the lamp.
B:
- id: b1-legend
title: Notebook, "my symbols"
type: table
body: |
| Symbol | Meaning |
|--------|---------|
| ⊗ | AND: true only if both sides are true |
| ⊘ | OR: true if either side is true |
| ⊕ | XOR: true if exactly one side is true |
| ¬ | NOT: flips true and false |
lock:
id: lock-switches
prompt: Set the switches.
inputType: code
format: 4 digits of 1/0 (S1 S2 S3 S4)
answers: ["1001"]
hints:
- Every bracket is joined by AND, so every bracket must be true.
- "(S1 AND NOT S2) means S1 = 1 and S2 = 0. Then (S2 OR S4) forces S4 = 1."
- "S3 XOR S4 with S4 = 1 means S3 = 0. Check NOT(S1 AND S3): true. Answer 1001."
successText: |
**1001.** The lamp blazes. The inner door unlocks onto the workshop proper: a chalkboard grid, a wall of clocks, and a rack of glowing vials.
keyName: Circuit Key
- id: ch2
title: The Chalkboard Grid
narrative: |
A 4×4 grid is chalked on the board, half rubbed out. A lock below wants four digits.
clues:
A:
- id: a2-board
title: The chalkboard
type: diagram
body: |
```
┌───┬───┬───┬───┐
│ 1 │ │ │ │
├───┼───┼───┼───┤
│ │ │ │ 2 │
├───┼───┼───┼───┤
│ │ 1 │ │ │
├───┼───┼───┼───┤
│ │ │ 2 │ │
└───┴───┴───┴───┘
```
*Fill with 1–4. Every row, column and 2×2 box has each digit once.*
*The lock wants the diagonal, top-left to bottom-right.*
B:
- id: b2-copy
title: Halloway's copy of the same grid (different squares survived)
type: diagram
body: |
```
┌───┬───┬───┬───┐
│ │ │ 3 │ │
├───┼───┼───┼───┤
│ │ 4 │ │ │
├───┼───┼───┼───┤
│ │ │ │ 3 │
├───┼───┼───┼───┤
│ 4 │ │ │ │
└───┴───┴───┴───┘
```
lock:
id: lock-grid
prompt: Enter the diagonal.
inputType: code
format: 4 digits
answers: ["1441"]
hints:
- Combine both grids into one first. Neither has enough on its own.
- "Row 1 is 1, ?, 3, ?. Its top-left box already has 1 and 4 (from row 2), so row 1, column 2 is 2."
- "Solution rows: 1234 / 3412 / 2143 / 4321. Diagonal 1, 4, 4, 1."
successText: |
**1441.** A hatch pops open with a letter in it, from the Professor to his wife.
keyName: Chalk Key
- id: ch3
title: The Return Date
narrative: |
The time machine's destination panel wants a date: day, month, year.
clues:
A:
- id: a3-letter
title: Letter in the hatch
type: letter
body: |
*My dearest Margaret,*
*I go forward tonight. I will return on **the first Friday the 13th after your 60th birthday**,
so that we may be unlucky together, as we always were.*
*Your E.*
*(On the panel: enter DDMMYYYY.)*
B:
- id: b3-records
title: Family records, and a 1931 desk calendar
type: table
body: |
**Margaret Halloway**, born **14 April 1871**.
| 1931 month | 1st of the month falls on a |
|------------|-----------------------------|
| Jan | Thursday |
| Feb | Sunday |
| Mar | Sunday |
| Apr | Wednesday |
| May | Friday |
| Jun | Monday |
| Jul | Wednesday |
| Aug | Saturday |
| Sep | Tuesday |
| Oct | Thursday |
| Nov | Sunday |
| Dec | Tuesday |
lock:
id: lock-date
prompt: Set the return date.
inputType: code
format: DDMMYYYY
answers: ["13111931"]
hints:
- Her 60th birthday is 14 April 1931. Look for 13ths after that.
- The 13th is a Friday when the 1st of the month is a Sunday.
- "After April, the first month starting on a Sunday is November. 13 November 1931."
successText: |
**13 11 1931.** The machine shudders, and a drawer marked "FUEL" slides open: empty, with a chemistry note pinned inside.
keyName: Calendar Key
- id: ch4
title: The Fuel Mix
narrative: |
The machine needs fuel mixed from the vials, and a colour meter that reads in hexadecimal.
clues:
A:
- id: a4-vials
title: The vial rack
type: table
body: |
| Vial | Glow colour |
|------|-------------|
| 1 | #FFFFFF |
| 2 | #FF3300 |
| 3 | #0066CC |
| 4 | #00FF00 |
| 5 | #33CC00 |
| 6 | #9900FF |
*Meter: "Enter the colour of the mix as a hex code."*
B:
- id: b4-recipe
title: Notebook, fuel recipe
type: note
body: |
*Equal parts of every vial whose number is **prime**. Nothing else.
The mix glows the **average** of their colours, **each channel separately** (red, green, blue),
**rounded down**. Hex: two digits per channel, #RRGGBB.*
lock:
id: lock-fuel
prompt: Enter the mix colour.
inputType: text
format: "#RRGGBB"
answers: ["667744", "#667744"]
hints:
- Prime vials are 2, 3 and 5.
- "Red: FF + 00 + 33 = 255 + 0 + 51 = 306. ÷ 3 = 102 = 0x66."
- "Green: (51 + 102 + 204) ÷ 3 = 119 = 0x77. Blue: (0 + 204 + 0) ÷ 3 = 68 = 0x44. #667744."
successText: |
**#667744.** The fuel glows a murky olive green. The Professor's notes call it "perfect". The reactor hum steadies, a little.
keyName: Vial Key
- id: ch5
title: The Brass Clock
narrative: |
The last lock wants the true time of the Professor's "departure". There's a clock on the wall behind you,
but you can only see it reflected in a big brass plate.
clues:
A:
- id: a5-clock
title: What you see in the brass plate
type: photo
body: |
*The clock face in the plate: the hour hand sits **between the 4 and the 5**, the minute hand points at the **7**.
It looks like about 4:35.*
*Lock: enter the time as HH:MM.*
B:
- id: b5-note
title: Notebook, last page
type: note
body: |
*Everything in my workshop is seen in brass. Trust no clock you see in a reflection.
In a mirror, the 1 sits where the 11 should be, the 2 where the 10 should be...*
lock:
id: lock-clock
prompt: What's the true time?
inputType: code
format: "HH:MM"
answers: ["0725", "725", "1925"]
hints:
- In a mirror, each hand points to the reflected position. 1 ↔ 11, 2 ↔ 10, 3 ↔ 9, 4 ↔ 8, 5 ↔ 7.
- The minute hand at the 7 is really at the 5 (25 minutes). The hour hand between 4 and 5 is really between 8 and 7.
- "7:25. (Or: 11:60 − 4:35 = 7:25.)"
successText: |
**7:25.** The time machine's door hisses open. Inside, one dial left, and a riddle in the Professor's handwriting.
keyName: Brass Key
finale:
type: escape
prompt: The time machine's year dial. Set it to the year of the Professor's birth and the safeguards shut down.
clues:
A:
- id: af-plate
title: Maker's plate on the machine
type: photo
body: |
*A riveted brass plate:* **"HALLOWAY CHRONO-CARRIAGE · BUILT 1912"**
*Year dial: 4 digits.*
B:
- id: bf-riddle
title: The Professor's riddle (inside the notebook cover)
type: note
body: |
*The year I built my machine, I was **three times** as old as my daughter Ada.
**Eighteen years later**, I was only **twice** as old as she was.
Set my dial to the year I was born.*
lock:
id: lock-year
prompt: Set the year dial.
inputType: code
format: 4 digits
answers: ["1858"]
hints:
- "Let Ada's age in the build year be y. The Professor was 3y. Eighteen years later: 3y + 18 = 2(y + 18)."
- y = 18, so the Professor was 54 when he built it.
- The plate says 1912. 1912 − 54 = 1858.
successEnding: |
**1858.** The reactor sighs into silence. Every door in the lab unlocks at once.
As you leave, the gramophone crackles one last time:
> *"Two minds, one problem. That was always the real machine. — E.H."*
On the doorstep is a letter postmarked **13 November 1931**, addressed to Margaret. It's never been opened.
**You escaped!** ⚙️🔑
failEnding: |
The reactor hum reaches a screech. White light. You wake up outside the bricked-up door, with no memory of
the last hour. (It's a game. Try again?)
+3 -3
View File
@@ -384,15 +384,15 @@ finale:
options:
- { id: snake, label: An accidental puff adder bite }
- { id: same, label: Drugged and killed by the same killer to silence him }
- { id: other, label: Killed by a second, different killer }
- { id: other, label: "Killed by a second, different killer" }
- { id: illness, label: Heart failure from the heat }
answer: same
- id: how
label: How did Marlowe die?
options:
- { id: fall, label: He fell into the pit in the dark }
- { id: struck, label: Lured to the dig at 02:00, struck on the head, then put in the pit }
- { id: poison, label: Poisoned at dinner, found dead at the pit }
- { id: struck, label: "Lured to the dig at 02:00, struck on the head, then put in the pit" }
- { id: poison, label: "Poisoned at dinner, found dead at the pit" }
- { id: shot, label: Shot with the Captain's rifle }
answer: struck
- id: why
+197
View File
@@ -0,0 +1,197 @@
id: magicians-dressing-room
engine: story
category: escape-room
title: The Magician's Dressing Room
tagline: The Great Marvello vanished mid-show. Now you're locked in his dressing room. Coincidence? Never.
estimatedMinutes: 25
difficulty: 3
players: { min: 2, max: 2 }
theme: { primary: "#5b2a86", accent: "#f9c74f", background: "#0e0716", headingFont: "Playfair Display" }
rules:
hintPenaltySeconds: 180
roles:
A:
name: The Assistant
blurb: You're in the dressing room itself. Mirrors, cabinets, cards, and the stage door.
B:
name: The Stagehand
blurb: You're behind the trick wall in the prop store. Scripts, charts, and the magician's secrets.
intro: |
Front-row seats at **The Great Marvello**'s farewell show. Mid-act, he steps into his Vanishing Cabinet...
and genuinely vanishes. The theatre panics. An usher asks you two to "check his dressing room".
The door clicks shut behind you. A playing card slides under it:
> *"Every great trick needs two. Find your way out, and you'll find me. — M."*
One of you is in the dressing room. The other fell through a trick wall into the prop store behind it.
You can hear each other through the mirror.
chapters:
- id: ch1
title: The Magic Square
narrative: |
The makeup table drawer has a 4-digit lock. Its lid is painted with a 3×3 grid of numbers, some rubbed away.
clues:
A:
- id: a1-grid
title: The painted grid on the drawer
type: diagram
body: |
```
┌───┬───┬───┐
│ 2 │ ? │ 6 │
├───┼───┼───┤
│ ? │ 5 │ ? │
├───┼───┼───┤
│ 4 │ ? │ 8 │
└───┴───┴───┘
```
*Tiny print under it: "Read the missing ones top to bottom, left to right."*
B:
- id: b1-notebook
title: Marvello's notebook, page 1
type: note
body: |
*My drawer square is MAGIC: every row, every column and both diagonals add to the **same number**.
Use each number 1 to 9 exactly once.*
lock:
id: lock-square
prompt: Open the drawer.
inputType: code
format: 4 digits
answers: ["7913"]
hints:
- Find the magic total first. The diagonal 2, 5, 8 is complete.
- Every line adds to 15. The top row needs 15 − 2 − 6.
- "Missing: top 7, middle-left 9, middle-right 1, bottom 3. Reading order: 7, 9, 1, 3."
successText: |
**7913.** The drawer opens: a top hat, and a deck of cards with six cards turned face up.
keyName: Top Hat Key
- id: ch2
title: What's in the Hat?
narrative: |
A brass plate on the hat stand reads **"WHAT'S IN THE HAT?"** with a six-letter tile lock.
Six cards are laid face up on the table in a row.
clues:
A:
- id: a2-cards
title: The six face-up cards (left to right)
type: diagram
body: |
```
5♥ A♠ 2♠ 2♠ 9♠ 7♥
```
B:
- id: b2-cipher
title: Marvello's card cipher (prop store wall)
type: table
body: |
| Suit | Ace | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | J | Q | K |
|------|-----|---|---|---|---|---|---|---|---|----|---|---|---|
| ♠ Spades | A | B | C | D | E | F | G | H | I | J | K | L | M |
| ♥ Hearts | N | O | P | Q | R | S | T | U | V | W | X | Y | Z |
*Clubs and diamonds mean nothing. Decoys.*
lock:
id: lock-hat
prompt: What's in the hat?
inputType: text
format: 6 letters
answers: ["RABBIT"]
hints:
- Spades are A to M, hearts are N to Z, by card value.
- "5♥ = R. A♠ = A."
- R-A-B-B-I-T.
successText: |
**RABBIT.** The hat's false bottom drops open. Out falls a velvet card with a grid of little squares,
and a note: *"Picture my favourite thing."*
keyName: Rabbit Key
- id: ch3
title: The Picture Puzzle
narrative: |
A wall panel has a 5×5 grid of light switches. Turn on the right ones and the panel shows a picture.
Then say what it is.
clues:
A:
- id: a3-rows
title: Velvet card, side 1 (rows, top to bottom)
type: table
body: |
| Row | Clue |
|-----|------|
| 1 | 1 1 |
| 2 | 5 |
| 3 | 5 |
| 4 | 3 |
| 5 | 1 |
*Each number is a run of switches ON in a row, in order, with at least one OFF between runs.*
B:
- id: b3-cols
title: Velvet card, side 2 (columns, left to right)
type: table
body: |
| Column | Clue |
|--------|------|
| 1 | 2 |
| 2 | 4 |
| 3 | 4 |
| 4 | 4 |
| 5 | 2 |
*Same rules, but reading each column top to bottom.*
lock:
id: lock-picture
prompt: What does the picture show?
inputType: text
format: One word
answers: ["HEART", "AHEART", "LOVE", "LOVEHEART"]
hints:
- Rows 2 and 3 are fully ON. Start there.
- Column 1 has a run of 2 and rows 2–3 already give it that, so the rest of column 1 is OFF. Same for column 5.
- "Row 1: switches 2 and 4. Row 4: switches 2–4. Row 5: switch 3. It's a heart."
successText: |
**A heart.** The panel glows red. Behind it, a card table with a spread of cards and a stage door with four suit symbols.
keyName: Heart Key
finale:
type: escape
prompt: The stage door has four dials, one under each suit.
clues:
A:
- id: af-door
title: The stage door
type: diagram
body: |
```
♠ ♥ ♦ ♣
[ ] [ ] [ ] [ ]
```
*Engraved: "Count my table. Every card counts."*
B:
- id: bf-table
title: The card table in the prop store
type: table
body: |
| Card | Card | Card | Card |
|------|------|------|------|
| K♥ | 3♠ | 7♥ | Q♦ |
| 2♥ | J♠ | 9♦ | A♥ |
| 5♣ | 8♠ | 4♥ | |
lock:
id: lock-stage
prompt: Set the four dials.
inputType: code
format: 4 digits (♠ ♥ ♦ ♣)
answers: ["3521"]
hints:
- Count how many cards of each suit are on the table.
- "♠: 3♠, J♠, 8♠."
- "♠ 3, ♥ 5, ♦ 2, ♣ 1 = 3521."
successEnding: |
**3521.** The stage door swings open onto the empty stage, under a single spotlight.
The Vanishing Cabinet's door creaks. The Great Marvello steps out, bows deeply, and hands you two
a pair of tickets: *"Front row, for life. You make a marvellous double act."*
**You escaped!** 🎩🔑
+1 -1
View File
@@ -281,7 +281,7 @@ finale:
- id: how
label: How?
options:
- { id: dagger, label: Stabbed with the armoury dagger, then buried it in a dry cactus pot }
- { id: dagger, label: "Stabbed with the armoury dagger, then buried it in a dry cactus pot" }
- { id: poison, label: Poison in his champagne }
- { id: lake, label: Drowned in the conservatory pond }
- { id: pistol, label: Shot with a silenced pistol }
+205
View File
@@ -0,0 +1,205 @@
id: pharaohs-tomb
engine: story
category: escape-room
title: The Pharaoh's Tomb
tagline: Valley of the Kings, 1922. The torch is dying, the door has sealed, and the walls don't write vowels.
estimatedMinutes: 35
difficulty: 4
players: { min: 2, max: 2 }
theme: { primary: "#b08d57", accent: "#40c9a2", background: "#120d06", headingFont: "Playfair Display" }
rules:
hintPenaltySeconds: 180
timeLimitMinutes: 40
timeLimitLabel: "Torch"
roles:
A:
name: The Archaeologist
blurb: You're inside the tomb with a failing torch. You see the glyphs, the floor, the doors.
B:
name: The Translator
blurb: You're at the camp tent with the books and the radio. You can read what they can't.
intro: |
**The Valley of the Kings, November 1922.**
The expedition finally breaks into an unknown tomb. You go in first, alone, while your partner
works the radio from the camp tent. Ten steps inside, a stone slab grinds down behind you.
Your torch has about forty minutes of oil. The walls are covered in glyphs.
Your partner has the dictionaries. **Nothing in here opens without both of you.**
⚠️ *Ancient Egyptian writing usually leaves out the vowels. You'll have to fill them in.*
chapters:
- id: ch1
title: The God of the Door
narrative: |
The first inner door has a carved panel: *"Speak the name of the one who guards the dead."*
Below it, a row of glyphs, and a slot of letter tiles.
clues:
A:
- id: a1-door
title: Glyphs on the door
type: diagram
body: |
## 🔒 👄 🔒
*Each glyph is one sound. There are tiles for vowels too. The glyphs don't show them.*
B:
- id: b1-chart
title: Uniliteral sign chart (one glyph = one consonant)
type: table
body: |
| Glyph | Sound | Glyph | Sound |
|-------|-------|-------|-------|
| 🦉 | M | 🍞 | T |
| 🌊 | N | 🐍 | F |
| 👄 | R | 🦶 | B |
| 🔒 | S | 🌀 | H |
| 🧺 | K | 🪑 | P |
| 🖐 | D | | |
*Vowels are not written. Readers fill them in from context.*
- id: b1-gods
title: The gods (dictionary appendix)
type: document
body: |
Amun · Anubis · Horus · Isis · **Osiris** (lord of the dead) · Ptah · Seth
lock:
id: lock-god
prompt: Speak the name.
inputType: text
format: The god's name
answers: ["OSIRIS"]
hints:
- The glyphs give three consonants. Which god's name has exactly those consonants, in that order?
- 🔒 👄 🔒 is S-R-S.
- O-S-I-R-I-S. (Isis is S-S, Horus is H-R-S.)
successText: |
**OSIRIS.** The door rumbles aside. The next chamber has two painted cartouches and a number dial.
keyName: Ankh Key
- id: ch2
title: Two Kings
narrative: |
The dial in the second chamber is carved: *"How many years stand between the two kings?"*
clues:
A:
- id: a2-cartouche
title: The cartouche in this chamber
type: diagram
body: |
**King Khaf-Ra, crowned in year:**
## 🪷🪷 🪢🪢🪢 ∩∩∩∩ |||||||
B:
- id: b2-numerals
title: Egyptian number signs
type: table
body: |
| Sign | Value |
|------|-------|
| 🪷 lotus | 1000 |
| 🪢 coil of rope | 100 |
| ∩ heel bone | 10 |
| \| stroke | 1 |
*Add up every sign.*
- id: b2-notebook
title: The expedition notebook
type: note
body: |
*Copied from the entrance corridor yesterday:*
**King Djed-Ka-Ra, crowned in year:** 🪷 🪢🪢🪢🪢🪢🪢🪢🪢🪢 ∩∩ |
lock:
id: lock-kings
prompt: How many years between the two kings?
inputType: code
format: A number
answers: ["426"]
hints:
- Count each sign and multiply by its value.
- Khaf-Ra = 2000 + 300 + 40 + 7 = 2347. Djed-Ka-Ra = 1000 + 900 + 20 + 1 = 1921.
- 2347 − 1921 = 426.
successText: |
**426.** A passage opens onto a long chamber with a floor of carved tiles. Several of the tiles look... loose.
keyName: Cartouche Key
- id: ch3
title: The Floor of Judgement
narrative: |
To cross, you must step tile to tile (forwards, backwards, or sideways, never diagonally) from the
entrance row to the far row. A wrong tile means a long drop.
Each tile has a symbol, and a letter cut into its edge.
clues:
A:
- id: a3-floor
title: The floor (far wall at the top, you're at the bottom)
type: diagram
body: |
```
far row → ○K □D △H ◇M △T
◇E ○S △O ○L □N
□P △R △A ◇F ○B
△H △A ○I □C △X
entrance → △P ◇W □V ○G ◇Y
```
*A plate at the entrance: "Walk only where the living walk. Speak the letters of your path."*
B:
- id: b3-book
title: Book of the Dead, translator's notes
type: table
body: |
| Symbol | Meaning |
|--------|---------|
| △ | **Life** |
| ○ | Death |
| □ | Water |
| ◇ | Sky |
lock:
id: lock-floor
prompt: Speak the letters of the safe path.
inputType: text
format: One word
answers: ["PHARAOH"]
hints:
- Only the △ (life) tiles are safe. Find a connected path of △ from the entrance row to the far row.
- Start at the bottom-left △P. Go up to △H, right to △A, up to △R...
- P-H-A-R-A-O-H.
successText: |
**PHARAOH.** You cross. The far wall slides open onto the burial chamber: a golden sarcophagus
with a sealed lid and a name lock.
keyName: Scarab Key
finale:
type: escape
prompt: The sarcophagus lid will lift only for the name of the one who sleeps inside.
clues:
A:
- id: af-cartouche
title: The royal cartouche on the lid
type: diagram
body: |
## 🌊 🐍 👄 🍞 👄
*Above it, a symbol for "queen".*
B:
- id: bf-royals
title: Royal names (dictionary appendix)
type: document
body: |
Hatshepsut · Khufu · Nefertari · Nefertiti · Ramesses · Tutankhamun
*Use the uniliteral chart from the first door.*
lock:
id: lock-sarcophagus
prompt: Speak her name.
inputType: text
format: The queen's name
answers: ["NEFERTARI"]
hints:
- Read the consonants with the chart.
- "N-F-R-T-R. Careful: Nefertiti would be N-F-R-T-T."
- Nefertari.
successEnding: |
**NEFERTARI.** The lid lifts with a sigh of 3,000-year-old air. Inside, no mummy, but a hidden stair
spiralling up into daylight, and two gold rings, one for each of you.
Your partner is waiting at the top of the stairs with the radio and a flask of tea.
**You escaped!** 🏺🔑
failEnding: |
The torch gutters out. Darkness. (It's a game. Light another torch?)
+362
View File
@@ -0,0 +1,362 @@
id: snowbound-edelweiss
engine: story
category: murder-mystery
title: Snowbound at the Edelweiss
tagline: Switzerland, 1965. A blizzard, a ski champion, and tracks the snow is slowly erasing.
estimatedMinutes: 40
difficulty: 4
players: { min: 2, max: 2 }
theme: { primary: "#274c77", accent: "#e7ecef", background: "#070c12", headingFont: "Playfair Display" }
rules:
hintPenaltySeconds: 180
roles:
A:
name: The Inspector
blurb: You came up on the last train before the pass closed. Statements, the doctor's notes, the evidence bag.
B:
name: The Concierge
blurb: You run the Edelweiss. Logs, charts, the switchboard, and every guest's habits.
intro: |
**Hotel Edelweiss, above Zermatt. January 1965.**
The blizzard closed the pass at nine. By dawn, a metre of snow, and **Bruno Keller**, Olympic champion and
owner of the Edelweiss, lies dead in the snow beside the old ski hut, 200 metres from the hotel.
Two sets of tracks lead to the hut: Bruno's boot prints going out, and a pair of ski tracks that go out
**and come back**. The snow has been filling them in all night.
⚠️ *Expert case: locks give you facts, not the killer. One accusation, four parts. Make it count.*
suspects:
- id: lotte
name: Lotte Keller
role: Bruno's wife
description: Ran the hotel while Bruno chased medals. Inherits everything.
- id: hans
name: Hans Brunner
role: Former rival
description: Bruno's great rival until a crash at last year's championship ended his career.
- id: ingrid
name: Dr Ingrid Falk
role: Hotel physician
description: Hands out sleeping pills like sweets. Knows everyone's secrets.
- id: pierre
name: Pierre Moreau
role: Investor
description: Wants to buy the Edelweiss and turn it into a casino. Bruno said no. Loudly.
- id: sophie
name: Sophie Weiss
role: Ski technician and instructor
description: Bruno's protégée. Prepares the racers' skis. Adored him.
chapters:
- id: ch1
title: Snow in the Tracks
narrative: |
The ski tracks are partly filled with fresh snow. Measure how much, and you know when they were made.
clues:
A:
- id: a1-measure
title: Police measurements at 06:00
type: report
body: |
| Tracks | Fresh snow lying inside them |
|--------|------------------------------|
| Bruno's boot prints (going out) | **9 cm** |
| The ski tracks (out and back) | **7 cm** |
- id: a1-body
title: Dr Falk's examination
type: report
body: |
One heavy blow to the back of the head, something long and hard. A ski pole basket was
found pressed into the snow beside him.
B:
- id: b1-barometer
title: Hotel weather log
type: log
body: |
| Time | Snowfall rate |
|------|---------------|
| 20:00 – 22:00 | 1 cm per hour |
| 22:00 – 02:00 | **3 cm per hour** (blizzard peak) |
| 02:00 – 06:00 | 0.5 cm per hour |
*Snow settles evenly in tracks and on open ground.*
lock:
id: lock-snow
prompt: At what time were the ski tracks made?
inputType: code
format: "HH:MM"
answers: ["0020", "020", "1220", "1220AM"]
hints:
- Work backwards from 06:00. How much snow fell between 02:00 and 06:00?
- 2 cm fell from 02:00 to 06:00. The ski tracks need 5 cm more, at 3 cm per hour.
- 5 cm at 3 cm/hour is 1 hour 40 minutes before 02:00, so 00:20. (Bruno walked out at 23:40.)
successText: |
**00:20.** Bruno walked out to the hut at 23:40. Someone followed him **on skis at 00:20**,
and skied back to the hotel.
keyName: Snowflake Key
- id: ch2
title: The Wax
narrative: |
Racing skis are waxed for the temperature. The lab scraped wax from the ski tracks.
clues:
A:
- id: a2-lab
title: Lab note on the wax residue
type: report
body: |
Hard racing wax. Manufacturer's rating: **−10 °C to −18 °C**.
Not a rental-ski wax. Applied fresh, within the last day.
B:
- id: b2-chart
title: Ski room wax chart
type: table
body: |
| Wax colour | Temperature range |
|------------|-------------------|
| Yellow | 0 to −2 °C |
| Red | −2 to −6 °C |
| Violet | −4 to −10 °C |
| Blue | −8 to −14 °C |
| **Green** | **−10 to −18 °C** |
| Polar | −16 to −30 °C |
- id: b2-log
title: Ski room wax log, yesterday
type: log
body: |
| Skis | Waxed by | Wax |
|------|----------|-----|
| Lotte Keller (touring) | Sophie | Blue |
| Pierre Moreau (rental) | Sophie | Red |
| Dr Falk (touring) | herself | Violet |
| Hans Brunner (racing) | Sophie | Green |
| Sophie Weiss (racing) | herself | Green |
| Bruno Keller (racing) | Sophie | Green, *but Bruno walked, not skied* |
lock:
id: lock-wax
prompt: What colour wax was in the tracks?
inputType: text
format: One colour
answers: ["GREEN"]
hints:
- Match the lab's temperature rating to the chart exactly.
- −10 °C to −18 °C.
- Green.
successText: |
**Green.** Only two pairs of skis that could have made those tracks were waxed green: **Hans's** and **Sophie's**.
keyName: Wax Key
- id: ch3
title: Signals from the Hut
narrative: |
The night porter saw a lantern at the ski hut around 00:10, and someone signalling with ski poles.
He drew what he saw.
clues:
A:
- id: a3-porter
title: The night porter's sketch, from the lobby window
type: diagram
body: |
```
Signal Pole on MY left Pole on MY right
1 Low Down
2 High Low
3 High Out
4 High Down
5 High Low
6 High Out
7 Low Low
8 High High
```
*"He was facing the hotel, waving them about like a madman. Then the lantern went out."*
B:
- id: b3-chart
title: Edelweiss Ski School pole signal chart
type: table
body: |
**Written from the SIGNALLER'S point of view** (their own left and right pole).
| Left ↓ \ Right → | Down | Low | Out | High | Up |
|------------------|------|-----|-----|------|----|
| **Down** | A | B | C | D | E |
| **Low** | F | G | H | I | J |
| **Out** | K | L | M | N | O |
| **High** | P | Q | R | S | T |
| **Up** | U | V | W | X | Y |
- id: b3-hans
title: Hans's window
type: note
body: |
Room 4, Hans Brunner's, is the only guest room that faces the ski hut.
Bruno asked you yesterday to "make sure Hans looks out of his window at midnight".
lock:
id: lock-signal
prompt: What word did Bruno signal?
inputType: text
format: One word
answers: ["BINDINGS"]
hints:
- The porter was facing Bruno. His left is Bruno's right.
- Swap each pair. Signal 1 becomes Bruno's left = Down, right = Low.
- B-I-N-D-I-N-G-S.
successText: |
**"BINDINGS."** Bruno was signalling to Hans's window. He wanted to tell Hans something
about the **bindings** that tore loose in last year's championship crash.
keyName: Pole Key
- id: ch4
title: Who Was Where
narrative: |
Two people had green-waxed skis. Where were they at 00:20?
clues:
A:
- id: a4-rooms
title: Guest register
type: table
body: |
| Room | Guest |
|------|-------|
| 1 | Bruno & Lotte Keller |
| 2 | Pierre Moreau |
| 3 | Dr Ingrid Falk |
| 4 | Hans Brunner |
| 5 | Sophie Weiss |
- id: a4-sophie
title: Sophie's statement
type: transcript
body: |
"Dr Falk gave me a sleeping pill at eleven. I took it and slept like a stone until the screaming at dawn."
- id: a4-falk
title: Dr Falk's private note
type: note
body: |
*Gave Sophie a pill at 23:00. She thanked me... then tipped it into the rubber plant when she thought I wasn't looking. Didn't say anything. Not my business.*
B:
- id: b4-switchboard
title: Switchboard log
type: log
body: |
| Time | Room | Call |
|------|------|------|
| 23:15 | 2 | Outside line, Paris. 6 min. |
| 00:05 – 00:40 | **4** | Outside line, Zürich. "A manager. Long call, lots of shouting." |
| 00:50 | 1 | Room service: hot chocolate, two cups |
- id: b4-bar
title: Barman's note
type: note
body: |
Card game in the lounge until 01:00: Madame Keller, Monsieur Moreau, Dr Falk. None of them left the table.
lock:
id: lock-where
prompt: Which green-wax skier has no alibi at 00:20?
inputType: choice
options:
- { id: hans, label: Hans Brunner }
- { id: sophie, label: Sophie Weiss }
answers: ["sophie"]
hints:
- Match the switchboard's room number to the guest register.
- Room 4 was on the phone from 00:05 to 00:40. That's Hans.
- Sophie says she was asleep, but she never took the pill.
successText: |
**Sophie.** Hans was shouting down the phone to Zürich the whole time (and missed Bruno's signal).
Sophie claims she slept, but she poured her sleeping pill into a plant.
keyName: Switchboard Key
- id: ch5
title: The Paper Strip
narrative: |
In Bruno's glove: a long, narrow strip of paper covered in letters. Bruno used to say that a message
wrapped around a ski pole is "the oldest trick in the Alps."
clues:
A:
- id: a5-strip
title: The unwound strip
type: note
body: |
## THCHWCEAICSDRNEAONSAT
*Pencil marks show it was wound tightly around something round.*
B:
- id: b5-pole
title: Bruno's ski pole grip
type: photo
body: |
*A leather grip, thin and worn. A strip of paper wound round it shows **exactly 3 letters per turn**, one under the other.*
*(Wind the letters down in columns of 3, then read across.)*
- id: b5-clipping
title: Newspaper clipping, one year ago
type: document
body: |
**BRUNNER CRASHES OUT. CAREER OVER.**
*"...his binding tore loose at 90 km/h. The skis had been prepared that morning by
his young technician, **Mlle S. Weiss**, who was inconsolable."*
lock:
id: lock-strip
prompt: What does the strip say?
inputType: text
format: The message
answers: ["THECRASHWASNOACCIDENT"]
hints:
- Write the letters into columns, 3 letters per column, top to bottom.
- Column 1 is T-H-C, column 2 is H-W-C, column 3 is E-A-I...
- Read the top row, then the middle, then the bottom. THE CRASH WAS NO ACCIDENT.
successText: |
**"THE CRASH WAS NO ACCIDENT."** Bruno knew the binding had been loosened deliberately.
The person who prepared Hans's skis that morning was Sophie Weiss.
keyName: Scytale Key
finale:
type: accusation
prompt: The snowplough is coming up the pass. One accusation, four parts.
maxAttempts: 1
questions:
- id: who
label: Who killed Bruno Keller?
options:
- { id: lotte, label: Lotte Keller }
- { id: hans, label: Hans Brunner }
- { id: ingrid, label: Dr Ingrid Falk }
- { id: pierre, label: Pierre Moreau }
- { id: sophie, label: Sophie Weiss }
answer: sophie
- id: how
label: How?
options:
- { id: skis, label: Followed him to the hut on green-waxed racing skis at 00:20 and struck him with a ski pole }
- { id: walked, label: Walked out with him at 23:40 and pushed him }
- { id: pills, label: Drugged his drink so he collapsed in the snow }
- { id: window, label: Threw something from Room 4's window }
answer: skis
- id: why
label: Why?
options:
- { id: inherit, label: To inherit the hotel }
- { id: casino, label: He refused to sell the hotel }
- { id: crash, label: He was about to reveal she had loosened Hans's binding before last year's crash }
- { id: rival, label: Revenge for ending a career }
answer: crash
- id: signal
label: Who was Bruno signalling to?
options:
- { id: hans, label: "Hans, to tell him the truth about the crash" }
- { id: lotte, label: "Lotte, to call for help" }
- { id: porter, label: "The porter, to send a doctor" }
- { id: sophie, label: "Sophie, to warn her" }
answer: hans
successEnding: |
Sophie doesn't cry. She just looks out at the hut, half buried now.
"I loosened one screw. One. I wanted Hans to lose, not to... and Bruno covered for me. For a year.
Then he said he couldn't live with it. He was going to tell Hans at midnight, and the federation in the morning."
She saw Bruno walk out at 23:40, waxed her racing skis green for the cold, and followed him at 00:20.
He was still waving his poles at Hans's dark window when she reached him.
**Case closed.** 🏔️🔑
failEnding: |
The plough arrives, the pass opens, and the wrong person goes down the mountain in handcuffs.
reveal: |
**Sophie Weiss** skied out on **green-waxed racing skis at 00:20** and struck Bruno with a ski pole, because he
was about to reveal she had **loosened Hans's binding** before last year's crash. Bruno was signalling
**"BINDINGS"** to **Hans's window**.
+201
View File
@@ -0,0 +1,201 @@
id: the-lighthouse
engine: story
category: escape-room
title: The Last Lighthouse
tagline: A storm, a dead lamp, and a ship heading for the rocks. Relight it before it's too late.
estimatedMinutes: 20
difficulty: 2
players: { min: 2, max: 2 }
theme: { primary: "#d62828", accent: "#fcbf49", background: "#05090f", headingFont: "Playfair Display" }
rules:
timeLimitMinutes: 25
timeLimitLabel: "Ship to the rocks"
roles:
A:
name: The Lookout
blurb: You're on the gallery outside the lamp room with a telescope, a radio and the keeper's logbook.
B:
name: The Keeper's Helper
blurb: You're down in the keeper's cottage with the signal books, charts and the lighthouse plans.
intro: |
**Cape Storm Lighthouse, 1950s. A winter gale.**
You took shelter in the old lighthouse when the storm hit. Then the lamp went out.
Through the rain, a cargo ship's lights are heading straight for the reef.
One of you climbed up to the gallery. The other is in the cottage with the keeper's books.
The radio between you crackles. **Relight the lamp. Save the ship.**
chapters:
- id: ch1
title: Flags in the Rain
narrative: |
Through the telescope, the ship is flying a string of signal flags, top to bottom.
The lamp room's emergency box has a four-letter word lock.
clues:
A:
- id: a1-flags
title: What you see through the telescope (top to bottom)
type: photo
body: |
1. *Split down the middle: **white** on the side nearest the mast, **red** on the outer side.*
2. *Split **diagonally** into a **red** triangle and a **yellow** triangle.*
3. *A **blue** flag with a **white square** in the middle.*
4. *Split across the middle: **blue** on top, **red** below.*
B:
- id: b1-book
title: International signal flag book (a few pages)
type: table
body: |
| Flag | Looks like |
|------|------------|
| A | White and blue, with a swallowtail notch |
| B | All red, with a swallowtail notch |
| C | Horizontal stripes: blue, white, red, white, blue |
| D | Horizontal stripes: yellow, blue, yellow |
| **E** | **Blue top half, red bottom half** |
| **H** | **White half (by the mast) and red half (outside)** |
| J | Horizontal stripes: blue, white, blue |
| N | Blue and white chequerboard |
| **O** | **Diagonal halves, red and yellow** |
| **P** | **Blue with a white square in the centre** |
| S | White with a blue square in the centre |
| U | Red and white quarters |
lock:
id: lock-flags
prompt: Enter the word the ship is flying.
inputType: text
format: 4 letters
answers: ["HOPE"]
hints:
- Match each flag description to the book.
- Careful with flag 3. P is blue with a white square; S is the other way round.
- H-O-P-E.
successText: |
**HOPE.** The emergency box opens: matches, a spare mantle for the lamp, and the clockwork crank.
keyName: Flag Key
- id: ch2
title: The Lens
narrative: |
The lamp is lit, but the lens has to turn at the right speed or the ship won't recognise the light.
The clockwork wants you to set the flash interval in seconds.
clues:
A:
- id: a2-lens
title: The great glass lens
type: photo
body: |
*A huge glass lens around the lamp. Around it are **4 bright "bullseye" panels**, evenly spaced.
Each time a bullseye sweeps past the ship, the ship sees one flash.*
B:
- id: b2-plans
title: Lighthouse plans, clockwork page
type: document
body: |
*Weighted clockwork. When properly wound, the lens makes **one full turn every 40 seconds**.*
*The Notice to Mariners lists Cape Storm as: "one white flash every ___ seconds."*
lock:
id: lock-lens
prompt: Set the flash interval (seconds).
inputType: code
format: Seconds
answers: ["10"]
hints:
- Each full turn of the lens sends out one flash per bullseye panel.
- 4 flashes per 40-second turn.
- 40 ÷ 4 = 10 seconds.
successText: |
**10 seconds.** The beam sweeps out across the water. The ship's horn blares, but it's still heading for the reef.
They don't know where they are.
keyName: Lens Key
- id: ch3
title: Which Light Is That?
narrative: |
Across the bay, another lighthouse is blinking. If you can name it, you can radio the ship its exact position.
clues:
A:
- id: a3-light
title: The light across the bay
type: signal
signal: "..."
body: |
A distant **red** light, flashing in a group, then dark, then the group again.
Count the flashes in each group and tell the cottage.
B:
- id: b3-list
title: Notice to Mariners, local lights
type: table
body: |
| Light | Pattern | Colour |
|-------|---------|--------|
| Seal Point | Groups of 2 flashes | White |
| Gull Island | Groups of 2 flashes | Red |
| Bird Rock | Groups of 3 flashes | Red |
| Whale Head | Groups of 3 flashes | White |
| Kelp Bay | Groups of 4 flashes | Red |
lock:
id: lock-light
prompt: Which light is across the bay?
inputType: text
format: The light's name
answers: ["BIRDROCK", "BIRDROCKLIGHT", "BIRDROCKLIGHTHOUSE"]
hints:
- "You need two things: how many flashes per group, and the colour."
- Groups of 3, red.
- Bird Rock.
successText: |
**Bird Rock.** You radio the ship: *"You're between Cape Storm and Bird Rock. Turn to port, now!"*
A long pause. Then the ship's lights swing slowly away from the reef.
One last job: lock the lamp room so the storm can't blow the lamp out again.
keyName: Radio Key
finale:
type: escape
prompt: The lamp room storm door has a two-digit lock.
clues:
A:
- id: af-log
title: The keeper's logbook, inside cover
type: note
body: |
*Lamp room door code = the number of **steps from where you're standing now** up to the lamp.*
*(You're on the **2nd floor** landing, just below the gallery stairs.)*
B:
- id: bf-section
title: Cross-section of the tower
type: diagram
body: |
```
LAMP ROOM
▲ 16 steps
4th floor
▲ 20 steps
3rd floor
▲ 24 steps
2nd floor
▲ 28 steps
1st floor
▲ 32 steps
GROUND (door)
```
lock:
id: lock-door
prompt: Lock the lamp room.
inputType: code
format: 2 digits
answers: ["60"]
hints:
- Only count the stairs above the 2nd floor.
- 24 + 20 + 16.
- "60."
successEnding: |
**60.** The storm door locks with a clang. The lamp burns steady, sweeping the sea every ten seconds.
At dawn, a battered cargo ship limps into harbour. The captain sends a crate of oranges up to the lighthouse
with a note: *"To whoever was on watch last night. Thank you."*
**You saved the ship!** 🌊🔑
failEnding: |
A horrible grinding sound from the reef. (It's a game. Everyone made it to the lifeboats. Try again?)
+175
View File
@@ -0,0 +1,175 @@
id: the-treehouse
engine: story
category: escape-room
title: The Treehouse
tagline: Your old childhood treehouse. The trapdoor just locked. The secret club still has rules.
estimatedMinutes: 15
difficulty: 1
players: { min: 2, max: 2 }
theme: { primary: "#4f772d", accent: "#f6bd60", background: "#0f140b", headingFont: "Playfair Display" }
roles:
A:
name: Up the Tree
blurb: You climbed in. Comics, club signs, the trapdoor and a great view of the garden.
B:
name: Down Below
blurb: You stayed on the grass. The shed, the tree trunk, and the old club's photo album.
intro: |
You're visiting the house you grew up in, and you finally show your partner **the treehouse**.
One of you climbs up. The trapdoor slams shut and an old padlock clicks.
Painted on the inside of the door, in very wobbly letters:
> **SECRET CLUB. NO GROWN-UPS. Only REAL members can get out.**
Time to prove you're a real member.
chapters:
- id: ch1
title: The Comic Shelf
narrative: |
A tin lockbox on the shelf has a 3-digit lock. Next to it, a row of old comics.
clues:
A:
- id: a1-comics
title: The comic shelf
type: table
body: |
| Comic issue | Spine colour |
|-------------|--------------|
| #4 | Red |
| #9 | Blue |
| #2 | Green |
B:
- id: b1-chalk
title: Chalk on the shed wall
type: note
body: |
*LOCKBOX = comic numbers in RAINBOW order!*
*(Red, orange, yellow, green, blue, purple. We only have some of them.)*
lock:
id: lock-comics
prompt: Open the lockbox.
inputType: code
format: 3 digits
answers: ["429"]
hints:
- Line the comics up by spine colour, using the rainbow order on the shed wall.
- Red comes first, then green, then blue.
- "Red #4, green #2, blue #9: 429."
successText: |
**429.** The lockbox pops open: a club badge, a magnifying glass, and a card that says *"Password required."*
keyName: Badge Key
- id: ch2
title: The Club Password
narrative: |
A wooden sign hangs over the trapdoor: **"SAY THE PASSWORD."** Below it, four letter tiles.
clues:
A:
- id: a2-charter
title: The club charter (pinned to the wall)
type: document
body: |
**THE SECRET CLUB CHARTER**
Rule 1: The password is the first letters of our pets' names, **in the order we signed this charter.**
*Signed:* Sam · Ava · Jo · Ben
B:
- id: b2-album
title: The club photo album
type: photo
body: |
*Four kids, four pets, captions in felt-tip:*
- "Ben + Socks the cat"
- "Jo + Luna the dog"
- "Sam + Peanut the hamster"
- "Ava + Ace the parrot"
lock:
id: lock-password
prompt: Say the password.
inputType: text
format: 4 letters
answers: ["PALS"]
hints:
- The Up-the-Tree player has the order. The Down-Below player has the pets.
- Sam, Ava, Jo, Ben → Peanut, Ace, Luna, Socks.
- P-A-L-S.
successText: |
**PALS.** A tiny drawer slides out of the sign. Inside: a treasure map of the garden, torn in half.
keyName: Password Key
- id: ch3
title: The Treasure Map
narrative: |
Half the map is in the treehouse. The other half was always kept in the shed.
clues:
A:
- id: a3-view
title: View of the garden from the window
type: map
body: |
```
1 2 3 4
A [shed] [roses] [swing] [veg patch]
B [rock] [bench] [pond] [birdbath]
C [compost] [lemon tree][tap] [gnome]
```
North is at the top (row A). East is to the right.
B:
- id: b3-map
title: Treasure map (shed half)
type: map
body: |
```
START at the BIG ROCK.
Walk 3 squares EAST.
Dig for the KEY! ✗
```
lock:
id: lock-map
prompt: Where is the treasure buried?
inputType: text
format: The garden spot
answers: ["BIRDBATH", "THEBIRDBATH", "B4"]
hints:
- Find the big rock on the garden view first.
- The rock is at B1. Three squares east of B1.
- B1 → B2 → B3 → B4, the birdbath.
successText: |
**The birdbath!** Under it, in a jam jar: the trapdoor key's little brass tag... and a note:
*"The key is on the trunk. Ask the tree."*
keyName: Map Key
finale:
type: escape
prompt: The trapdoor padlock needs a 4-digit code.
clues:
A:
- id: af-lock
title: Scratched next to the padlock
type: note
body: |
*Code = the year we built this, but add 1 to every digit. (So grown-ups can't guess it.)*
B:
- id: bf-trunk
title: Carved into the tree trunk
type: photo
body: |
*A heart with initials, a smiley face, and in big chunky letters: **BUILT 2009**.*
lock:
id: lock-trapdoor
prompt: Open the trapdoor.
inputType: code
format: 4 digits
answers: ["3110"]
hints:
- The year is carved on the trunk.
- Add 1 to each digit of 2009. Be careful with the 9.
- 2→3, 0→1, 0→1, 9→10... a 9 plus 1 wraps round to 0 on a lock dial. 3110.
successEnding: |
**3110.** The trapdoor swings open. You climb down, and on the way you notice something carved
under the window, small, in fresh-looking letters: your two initials, inside a heart.
*(Your partner may have been here earlier than they said.)*
**You escaped!** 🌳🔑
+2 -2
View File
@@ -252,8 +252,8 @@ manual: |
| 2 | Yellow | Green | Blue | Red |
# ---------- Solver fixtures (MUST pass as unit tests) ----------
# Generated from the reference solver in tools/reference/overtime_solver.py, which implements
# the manual above exactly. Port that solver to TypeScript in server/src/engines/defuse.ts.
# Generated from the reference solver (GAMES-NEW.md, Appendix A), which implements the manual
# above exactly. Its TypeScript port is the `overtime` ruleset in server/src/engines/defuse.ts.
fixtures:
wires:
- {"serial": "TK4L82", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "yellow"], "expect": {"type": "cut", "index": 2}}
+60
View File
@@ -0,0 +1,60 @@
id: tick-tock-switch
engine: defuse
category: co-op
title: "Tick Tock: Switch"
tagline: Same bomb manual as Overtime. Except every level, you swap seats.
estimatedMinutes: 20
difficulty: 4
players: { min: 2, max: 2 }
theme: { primary: "#f72585", accent: "#4cc9f0", background: "#09030a", headingFont: "JetBrains Mono" }
roles:
A:
name: Partner A
blurb: Technician on levels 1, 3 and 5. Expert on levels 2 and 4.
B:
name: Partner B
blurb: Expert on levels 1, 3 and 5. Technician on levels 2 and 4.
intro: |
You've beaten Overtime. Now find out if you can do each other's job.
**Every level, the roles swap.** Whoever just read the manual now has to describe the device,
and whoever just described it now has to find the rules, fast.
Five levels, shorter timers, and only **2 strikes** per level. Same Overtime manual, same modules.
# NEW: reuse another game's device, modules, manual and solver instead of duplicating them.
manualFrom: tick-tock-overtime # take `device`, `modules` and `manual` from that game; its fixtures already cover the rules
swapRolesEachLevel: true # level 1: A = Technician; level 2: B = Technician; and so on
strikes: { max: 2, speedUpPerStrike: 0.25 }
levels:
- id: lvl1
title: "Switch: Warm-up"
timeSeconds: 180
modules: [wires, button, keypad]
keyName: First Switch Key
- id: lvl2
title: "Switch: Swapped"
timeSeconds: 210
modules: [wires, passwords, morse]
keyName: Swap Key
- id: lvl3
title: "Switch: Echoes"
timeSeconds: 240
modules: [button, keypad, simon]
keyName: Echo Switch Key
- id: lvl4
title: "Switch: Crossed Wires"
timeSeconds: 270
modules: [wires, wires, passwords, morse] # two separate wire modules on one device
keyName: Crossed Key
- id: lvl5
title: "Switch: Full Circle"
timeSeconds: 330
strikesOverride: 1
modules: [wires, button, keypad, passwords, morse, simon]
keyName: Full Circle Key
successEnding: |
You can do each other's job. Under pressure. With a ticking bomb. That's basically marriage. 💣🔑
+279
View File
@@ -0,0 +1,279 @@
id: til-death-do-us-part
engine: story
category: murder-mystery
title: "'Til Death Do Us Part"
tagline: A Stellenbosch wedding. A perfect first dance. A best man at the bottom of the garden stairs.
estimatedMinutes: 30
difficulty: 3
players: { min: 2, max: 2 }
theme: { primary: "#b5838d", accent: "#ffcdb2", background: "#140c0e", headingFont: "Playfair Display" }
rules:
hintPenaltySeconds: 180
roles:
A:
name: The Detective
blurb: You're a wedding guest who happens to be a detective. Statements, the seating rules, the police notes.
B:
name: The Photographer's Assistant
blurb: You worked the wedding. Camera files, the planner's folders, and the best man's phone.
intro: |
**A wine estate outside Stellenbosch. Saturday, 21:30.**
Thabo and Lize are married. The speeches went well, the lights dim, and the couple step out for their first dance.
At 21:50, a waiter finds **Jason Pike**, the best man, at the bottom of the stone garden stairs. Everyone assumes
champagne and a missed step. The doctor at table 6 doesn't.
Don't let a murder ruin a perfectly good wedding. **Solve it before the cake is cut.**
⚠️ *Hard case: the locks give you facts. You'll connect them in the accusation.*
suspects:
- id: kayla
name: Kayla Daniels
role: Maid of honour
description: Jason's ex. They broke up badly six months ago and had to share a head table tonight.
- id: sipho
name: Sipho Ndlovu
role: Groom's brother
description: Owes Jason money. Was seen arguing with him by the bar.
- id: johan
name: Johan Marais
role: Father of the bride
description: Paid for everything. Jason's speech joked about his "dodgy property deals". He didn't laugh.
- id: celeste
name: Celeste Moreau
role: Wedding planner
description: Elegant, efficient, everywhere at once. Wears the planner team's navy dress.
- id: marco
name: Marco Russo
role: The bride's ex
description: Not invited. Turned up anyway, "just to say congratulations". Security walked him out at 21:00.
chapters:
- id: ch1
title: The Empty Chair
narrative: |
The videographer filmed the first dance, 21:32 to 21:42, with the head table behind the couple.
Two chairs at the head table are empty in every frame. One is Jason's. Whose is the other?
clues:
A:
- id: a1-rules
title: The planner's seating notes (police copy)
type: note
body: |
Head table, seats **1–5 from the guests' left** (the couple's own seats in the middle don't count).
1. Johan sits directly next to his wife, Anita.
2. The maid of honour is not on either end.
3. The best man sits at the groom's end: **seat 5**.
4. Johan is **not** in the middle seat (3). "He hates being in the middle of photos."
B:
- id: b1-planner
title: More seating notes (from the planner's folder)
type: note
body: |
5. The groom's brother sits directly next to the maid of honour.
6. Anita sits somewhere to Johan's **left** (guests' view).
7. Jason and Johan must **not** sit next to each other. Long story.
8. The groom's brother sits to the maid of honour's **left**.
- id: b1-video
title: Videographer's note
type: log
body: |
First dance 21:32–21:42. Head table visible throughout.
**Seats 3 and 5 empty the entire time.** Seats 1, 2 and 4 occupied.
lock:
id: lock-chair
prompt: Who should have been in seat 3?
inputType: choice
options:
- { id: anita, label: Anita Marais (mother of the bride) }
- { id: johan, label: Johan Marais }
- { id: kayla, label: Kayla Daniels }
- { id: sipho, label: Sipho Ndlovu }
answers: ["sipho"]
hints:
- Jason is in seat 5. Johan can't be next to him, so Johan isn't in seat 4.
- Johan isn't in 3 or 4, and Anita is to his left and next to him. So Anita 1, Johan 2.
- "Seats 3 and 4 are Sipho and Kayla, with Sipho on Kayla's left: Sipho is seat 3."
successText: |
**Sipho**, the groom's brother. He wasn't at the table during the first dance.
(Sipho, cornered: *"I was in the car park on the phone to my bank. Ask them."* His call log
shows a call from 21:30 to 21:47. Inconvenient for a killer.)
keyName: Place Card Key
- id: ch2
title: The Last Photo
narrative: |
The photographer took hundreds of shots. One shows Jason at the top of the garden stairs with someone.
But the camera's clock was never set properly.
clues:
A:
- id: a2-photos
title: Photo files near the garden stairs (camera timestamps)
type: log
body: |
| File | Camera time | What it shows |
|------|-------------|---------------|
| IMG_2207 | 23:41 | Jason laughing on the terrace, champagne in hand |
| IMG_2219 | **23:45** | Jason at the **top of the garden stairs**, talking to a woman in a **navy dress**, her back to the camera |
| IMG_2231 | 23:47 | The first dance, wide shot |
| IMG_2246 | 23:52 | Garden stairs, empty |
B:
- id: b2-camera
title: Photographer's voice note
type: transcript
body: |
"Ugh, I never changed the camera clock after the Dubai job. It's **two hours ahead**.
And it was already running **seven minutes fast** before that. Sorry!"
- id: b2-dresscode
title: Planner's colour plan
type: table
body: |
| Group | Colour |
|-------|--------|
| Bridesmaids (incl. maid of honour) | Sage green |
| Mothers of the couple | Blush pink |
| **Planner team** | **Navy** |
| Guests | "Anything but white, navy or sage" |
lock:
id: lock-photo
prompt: What was the real time of IMG_2219?
inputType: code
format: "HH:MM"
answers: ["2138", "938", "938PM"]
hints:
- The camera is ahead by 2 hours plus 7 minutes.
- Take 2:07 off the camera time.
- 23:45 − 2:07 = 21:38.
successText: |
**21:38**, during the first dance. Jason was alive at the top of the garden stairs, talking to a woman in **navy**.
By 21:45 the stairs were empty. Navy is the **planner team's** colour, and only one planner was working tonight.
keyName: Camera Key
- id: ch3
title: The Toast Card
narrative: |
In Jason's jacket: his speech card. One line on the back looks oddly printed, with some letters bold and some not.
Jason's friends say he loved "old spy stuff".
clues:
A:
- id: a3-card
title: Back of Jason's speech card
type: note
body: |
**H**e**re**'s t**o** the bri**de** a**nd** **g**r**o**o**m**, **m**ay every day feel like this one
*(Pencilled: "Final toast. 5 by 5. Thin = a, fat = b.")*
B:
- id: b3-bacon
title: Jason's phone, a saved note "Bacon's cipher"
type: table
body: |
Read letters in groups of five: thin letter = **a**, bold letter = **b**. Ignore spaces and punctuation.
| Group | Letter | Group | Letter | Group | Letter |
|-------|--------|-------|--------|-------|--------|
| aaaaa | A | ababa | K | baaab | R |
| aaaab | B | ababb | L | baaba | S |
| aaaba | C | abbaa | M | baabb | T |
| aaabb | D | abbab | N | babaa | U |
| aabaa | E | abbba | O | babab | V |
| aabab | F | abbbb | P | babba | W |
| aabba | G | baaaa | Q | babbb | X |
| aabbb | H | | | bbaaa | Y |
| abaaa | I | | | bbaab | Z |
| abaab | J | | | | |
*This version has 26 separate letters (I and J, U and V are different).*
lock:
id: lock-bacon
prompt: What did Jason hide on his toast card?
inputType: text
format: One word
answers: ["WIDOW", "BLACKWIDOW"]
hints:
- "Write each letter as a (normal) or b (bold), then split into groups of five: H-e-r-e-s = b a b b a."
- babba = W. The next group, t-o-t-h-e, is a b a a a.
- W-I-D-O-W.
successText: |
**"WIDOW."** Jason's final toast was going to name someone as a **widow**. And not a sad one.
keyName: Toast Key
- id: ch4
title: Uncle Leon's Wedding
narrative: |
Jason's phone has an album called "UNCLE LEON". His uncle married in 2019, died eight months later,
and his new wife vanished with his savings.
clues:
A:
- id: a4-police
title: Police description of Celeste Moreau (taken tonight)
type: report
body: |
Female, about 40. Dark hair in a chignon. Navy planner's dress.
Small tattoo on the **inside of her left wrist: a swallow**. Says she's "been planning weddings for six years".
B:
- id: b4-album
title: Jason's album "UNCLE LEON", wedding photo, 2019
type: photo
body: |
*Leon Pike, beaming, beside his bride "Claire". She's raising a glass with her left hand.
On the inside of her wrist: a small **swallow** tattoo.*
*Jason's caption, written last week: "Found her. She's planning Thabo's wedding."*
lock:
id: lock-tattoo
prompt: What links the planner to Uncle Leon's bride?
inputType: text
format: One word
answers: ["SWALLOW", "TATTOO", "SWALLOWTATTOO", "ASWALLOW", "THESWALLOW"]
hints:
- Compare the police description with the old wedding photo.
- Look at the left wrist.
- The swallow tattoo.
successText: |
**The swallow tattoo.** "Claire Pike" and Celeste Moreau are the same woman: she married Jason's uncle,
and when he died she disappeared with everything. Jason recognised her and was going to say so in his final toast.
keyName: Swallow Key
finale:
type: accusation
prompt: The cake is coming out. Make your accusation before anyone cuts it.
maxAttempts: 2
questions:
- id: who
label: Who killed Jason Pike?
options:
- { id: kayla, label: Kayla Daniels }
- { id: sipho, label: Sipho Ndlovu }
- { id: johan, label: Johan Marais }
- { id: celeste, label: Celeste Moreau }
- { id: marco, label: Marco Russo }
answer: celeste
- id: how
label: How?
options:
- { id: fell, label: "He fell, drunk, nobody did anything" }
- { id: pushed, label: Lured to the top of the garden stairs during the first dance at 21:38 and pushed }
- { id: poison, label: Poison in his champagne }
- { id: carpark, label: Attacked in the car park }
answer: pushed
- id: why
label: Why?
options:
- { id: ex, label: A bitter break-up }
- { id: money, label: An unpaid debt }
- { id: speech, label: Humiliation in the speeches }
- { id: widow, label: "He'd recognised her as the woman who married and robbed his uncle, and was about to expose her" }
answer: widow
successEnding: |
Celeste straightens a centrepiece that doesn't need straightening.
"Six weddings a month. Nobody looks at the planner. And then the best man looks at my wrist and goes pale."
At 21:38, while everyone watched the first dance, she asked Jason to "help with the sparklers" at the top
of the garden stairs. He told her he knew. She pushed.
**Case closed.** The cake gets cut. Lize says it's the most memorable wedding in family history. 💍🔑
failEnding: |
The wrong person is led out past the dessert table. "Claire" collects her fee and moves on to the next wedding.
reveal: |
**Celeste Moreau** pushed Jason down the garden stairs at **21:38 during the first dance**, because he'd recognised her
(the **swallow tattoo**) as the **"widow" who married and robbed his uncle** and was about to expose her in his toast.
+91
View File
@@ -0,0 +1,91 @@
id: twin-cafes
engine: twin
category: co-op
title: "Twin Rooms: The Twin Cafés"
tagline: Two cafés on opposite corners, run by identical twins who copy everything. Almost everything.
estimatedMinutes: 15
difficulty: 2
players: { min: 2, max: 2 }
theme: { primary: "#6f4e37", accent: "#e9c46a", background: "#120c08", headingFont: "Playfair Display" }
roles:
A:
name: Partner A
blurb: You're in Café Uno.
B:
name: Partner B
blurb: You're in Café Due, across the street.
intro: |
The Bianchi twins opened identical cafés on opposite corners of the same square. They copy each other's
menus, chairs, even the cat. But each twin secretly changed a few things to see if anyone would notice.
You'll notice. Describe your café square by square, then **both tap the same square** within 15 seconds to claim a difference.
Wrong claims cost 20 seconds. No coordinates on the grid this time; you'll have to describe positions yourselves.
twin:
claimWindowSeconds: 15
wrongClaimPenaltySeconds: 20
mirrorB: false
showCoordinates: false
scenes:
- id: counter
title: "The Counter"
timeSeconds: 240
keyName: Espresso Key
differences: 6
grid: # shown to A (and to B with changes applied)
- "🫖 🍰 🫖 🍪 🍩 🍰 🫖 🥪"
- "🍩 🫖 🧇 🥯 🥪 🍪 🍪 🥖"
- "🍰 🍫 🍰 ☕ ☕ 🥐 🍩 🥤"
- "🥯 🥪 🫖 🥯 🧁 🥖 🍰 🧇"
- "🥯 🥯 🫖 🥪 🍫 🥯 🥪 🍪"
- "🫖 🍩 ☕ 🍫 🍰 🧁 🥯 🥪"
changes: # SERVER ONLY: B's version differs at these cells
- { x: 6, y: 0, to: "🍶" }
- { x: 0, y: 2, to: "🧁" }
- { x: 4, y: 3, to: "🍰" }
- { x: 0, y: 4, to: "🍩" }
- { x: 4, y: 5, to: "🧁" }
- { x: 6, y: 5, to: "🍩" }
- id: terrace
title: "The Terrace"
timeSeconds: 240
keyName: Terrace Key
differences: 6
grid: # shown to A (and to B with changes applied)
- "☂ 🌞 🌼 🎨 🛵 🪑 🧑‍🎨 🚲"
- "🛵 🌸 🪑 🕊 🎨 🌼 🌞 ☂"
- "🛵 🌿 🪑 🌿 🛵 🌳 🐈 🌼"
- "🪑 🌳 📰 🕊 🕊 🪑 🌳 🌿"
- "📰 🐈 🚲 🧑‍🎨 📰 🧑‍🎨 🌸 ☂"
- "🌿 🌸 🧑‍🎨 🌳 🌳 📰 🌼 🛵"
changes: # SERVER ONLY: B's version differs at these cells
- { x: 0, y: 1, to: "🏍" }
- { x: 6, y: 1, to: "🌝" }
- { x: 6, y: 3, to: "🌲" }
- { x: 2, y: 4, to: "🛴" }
- { x: 4, y: 5, to: "🌲" }
- { x: 6, y: 5, to: "🌻" }
- id: window
title: "The Window Seat"
timeSeconds: 240
keyName: Window Key
differences: 6
grid: # shown to A (and to B with changes applied)
- "💌 🕯 💌 🍵 🧸 🧣 📚 📖"
- "🖋 🕯 🕯 🌙 🌙 💌 🧣 🧸"
- "📖 📖 🌙 🕯 🧸 🌙 🌧 📖"
- "🪟 📚 📖 📚 🪟 ⭐ 🎧 🖋"
- "🎧 🌙 🪟 🪟 🧸 🍂 🎧 🖋"
- "🧣 🍂 🎧 📚 🍂 🍂 💌 💌"
changes: # SERVER ONLY: B's version differs at these cells
- { x: 1, y: 1, to: "🪔" }
- { x: 2, y: 1, to: "🪔" }
- { x: 5, y: 1, to: "✉" }
- { x: 3, y: 3, to: "🗂" }
- { x: 1, y: 4, to: "🌛" }
- { x: 5, y: 4, to: "🍁" }
successEnding: |
Every difference found. You blink, and you're both back on the same side of the screen, looking at each other.
**Same wavelength. Same room.** 🪞🔑
+108
View File
@@ -0,0 +1,108 @@
id: twin-looking-glass
engine: twin
category: co-op
title: "Twin Rooms: Through the Looking Glass"
tagline: One of you is on the other side of the mirror. Everything is flipped. And the differences are tiny.
estimatedMinutes: 25
difficulty: 4
players: { min: 2, max: 2 }
theme: { primary: "#3c096c", accent: "#c77dff", background: "#0a0412", headingFont: "Playfair Display" }
rules:
timeLimitMinutes: 25
timeLimitLabel: "Before the mirror clouds over"
roles:
A:
name: This Side
blurb: You see the real room.
B:
name: Through the Glass
blurb: You see the room in the mirror. Left and right are swapped. And some things have changed.
intro: |
An antique mirror in a junk shop. One of you leans in too close... and is suddenly standing inside it.
**Through the Glass sees every room flipped left to right.** The differences are subtle: a green apple
for a red one, a new moon for a smiling one, a clock that's half an hour off.
Describe, find, and **both tap the same square** (in your own view) within 15 seconds to claim it.
Wrong claims cost 30 seconds. No coordinates. Eight differences per room. Good luck.
twin:
claimWindowSeconds: 15
wrongClaimPenaltySeconds: 30
mirrorB: true # B's grid is shown left-right mirrored; server unmirrors B's taps (x → width − 1 − x)
showCoordinates: false
scenes:
- id: orchard
title: "The Mirror Orchard"
timeSeconds: 300
keyName: Apple Key
differences: 8
grid: # shown to A (and to B with changes applied)
- "🐛 🌸 🪵 🐛 🍐 🌿 🪺 🍏 🍂 🐝"
- "🍎 🍐 🌿 🌳 🍎 🌿 🍂 🍃 🍎 🪵"
- "🍃 🐦 🌿 🍐 🪵 🪺 🌿 🍎 🌳 🌿"
- "🍂 🍐 🐦 🍂 🌳 🌳 🌿 🍃 🌳 🐝"
- "🍒 🐝 🍂 🐝 🍃 🍏 🐝 🍒 🌳 🌸"
- "🌳 🍒 🪵 🪵 🐦 🍃 🐝 🍒 🪵 🌳"
- "🌸 🍂 🪵 🪵 🍏 🍂 🐦 🍂 🐦 🍒"
- "🍃 🍏 🍃 🍐 🍐 🍎 🍏 🪵 🌿 🌸"
changes: # SERVER ONLY: B's version differs at these cells
- { x: 3, y: 0, to: "🐌" }
- { x: 0, y: 2, to: "🌿" }
- { x: 3, y: 2, to: "🍋" }
- { x: 5, y: 4, to: "🍎" }
- { x: 2, y: 5, to: "🪨" }
- { x: 9, y: 6, to: "🍓" }
- { x: 1, y: 7, to: "🍎" }
- { x: 5, y: 7, to: "🍏" }
- id: night
title: "Midnight Garden"
timeSeconds: 300
keyName: Moon Key
differences: 8
grid: # shown to A (and to B with changes applied)
- "🌹 🐈 🔮 🐈‍⬛ 🌟 🌚 🐈 🥀 🦉 🐈‍⬛"
- "🔮 🐈 🥀 🌟 🐈 🦉 🐈 🦉 🌹 🐈‍⬛"
- "⭐ 🌚 🔮 🦉 🌚 🕯 ⭐ 🌚 🌹 🌑"
- "🔮 🦇 🥀 🔮 ⭐ 🌹 🌹 🌚 🐈‍⬛ 🦇"
- "🦉 🐈 🗝 🌚 🦉 🦇 ⭐ 🦉 🦉 🌚"
- "🌑 🕯 🗝 🔮 🦉 🕯 🌑 🌑 🌚 🌟"
- "🌑 🦉 🌚 🌚 🌹 🐈‍⬛ 🐈 🗝 🦇 🦇"
- "🔮 🌙 🐈 🌹 🌹 🥀 🐈 🌑 🕯 🦇"
changes: # SERVER ONLY: B's version differs at these cells
- { x: 5, y: 1, to: "🦇" }
- { x: 7, y: 2, to: "🌑" }
- { x: 7, y: 4, to: "🦇" }
- { x: 4, y: 5, to: "🦇" }
- { x: 9, y: 5, to: "⭐" }
- { x: 4, y: 6, to: "🥀" }
- { x: 6, y: 7, to: "🐈‍⬛" }
- { x: 7, y: 7, to: "🌚" }
- id: clocks
title: "The Hall of Clocks"
timeSeconds: 300
keyName: Clock Key
differences: 8
grid: # shown to A (and to B with changes applied)
- "🕚 🕔 🕖 🕔 ⌛ 🕕 🕐 🕛 🕛 🕖"
- "🕐 🕔 🕛 🕕 🕔 🕛 ⏳ ⏳ 🕑 🕔"
- "⏳ 🕖 🕕 🕙 🕐 🕘 🕕 🕓 🕘 🕐"
- "🕛 🕑 🕒 🕙 🕑 🕑 🕐 🕒 🕘 🕕"
- "🕙 🕚 🕚 🕔 🕐 🕖 🕙 🕒 🕕 🕖"
- "🕖 🕖 🕓 ⏳ 🕖 🕚 🕓 ⏳ 🕒 🕒"
- "🕘 🕐 🕚 🕛 🕘 🕚 🕘 🕖 🕔 ⏳"
- "🕒 🕓 🕐 🕐 🕔 🕘 🕗 🕖 🕔 🕕"
changes: # SERVER ONLY: B's version differs at these cells
- { x: 4, y: 0, to: "⏳" }
- { x: 1, y: 1, to: "🕠" }
- { x: 7, y: 1, to: "⌛" }
- { x: 2, y: 2, to: "🕡" }
- { x: 3, y: 3, to: "🕥" }
- { x: 5, y: 6, to: "🕦" }
- { x: 0, y: 7, to: "🕞" }
- { x: 5, y: 7, to: "🕤" }
successEnding: |
Every difference found. You blink, and you're both back on the same side of the screen, looking at each other.
**Same wavelength. Same room.** 🪞🔑
+84
View File
@@ -0,0 +1,84 @@
id: twin-sunday-morning
engine: twin
category: co-op
title: "Twin Rooms: Sunday Morning"
tagline: Two cosy rooms. Almost identical. Find what's different without peeking at each other's screen.
estimatedMinutes: 10
difficulty: 1
players: { min: 2, max: 2 }
theme: { primary: "#e76f51", accent: "#f4a261", background: "#14100d", headingFont: "Playfair Display" }
roles:
A:
name: Partner A
blurb: You see one version of each room.
B:
name: Partner B
blurb: You see the other version. Same room... mostly.
intro: |
You each wake up in the same house, on the same lazy Sunday. Except it isn't quite the same.
**Describe what you see, square by square.** When you think you've found a difference,
you **both tap that same square** within 15 seconds to claim it.
Find every difference in a room to earn its key.
Tapping the same square when it **isn't** a difference costs you 15 seconds. No screen-peeking!
twin:
claimWindowSeconds: 15
wrongClaimPenaltySeconds: 15
mirrorB: false
showCoordinates: true # row letters + column numbers around the grid, to help you describe squares
scenes:
- id: kitchen
title: "Breakfast Kitchen"
timeSeconds: 180
keyName: Pancake Key
differences: 4
grid: # shown to A (and to B with changes applied)
- "☕ 🍞 🍊 🍊 🍌 ☕"
- "🍓 🥄 🥄 🍊 🧈 🥄"
- "🍊 🍽 🍯 🥄 🍯 🍓"
- "🍳 🥄 ☕ ☕ 🥛 ☕"
- "🍯 🍳 🍌 🍯 🍌 🍞"
changes: # SERVER ONLY: B's version differs at these cells
- { x: 0, y: 1, to: "🍒" }
- { x: 2, y: 1, to: "🍴" }
- { x: 5, y: 1, to: "🍴" }
- { x: 0, y: 2, to: "🍋" }
- id: garden
title: "The Back Garden"
timeSeconds: 180
keyName: Garden Key
differences: 4
grid: # shown to A (and to B with changes applied)
- "🪴 🐌 🐞 🌼 🪨 🌳"
- "🐞 🪴 🌳 🌼 🌼 🌷"
- "🌷 🍄 🐝 🪨 🐞 🐞"
- "🐦 🌷 🌻 🐞 🪴 🪴"
- "🍄 🐌 🌳 🐌 🐦 🌼"
changes: # SERVER ONLY: B's version differs at these cells
- { x: 3, y: 0, to: "🌸" }
- { x: 1, y: 2, to: "🌰" }
- { x: 3, y: 2, to: "🧱" }
- { x: 3, y: 4, to: "🐢" }
- id: bedroom
title: "Lazy Bedroom"
timeSeconds: 180
keyName: Pillow Key
differences: 4
grid: # shown to A (and to B with changes applied)
- "🧦 🖼 📱 🎧 🧦 🪟"
- "🎧 🖼 📱 🧦 🧦 🛏"
- "🖼 🕯 📚 🛏 ⏰ 🧦"
- "🪴 🧸 ⏰ 🖼 🎧 📱"
- "📚 📱 🕯 🧸 🪴 ⏰"
changes: # SERVER ONLY: B's version differs at these cells
- { x: 2, y: 2, to: "📖" }
- { x: 3, y: 2, to: "🛋" }
- { x: 3, y: 3, to: "🪞" }
- { x: 4, y: 3, to: "📻" }
successEnding: |
Every difference found. You blink, and you're both back on the same side of the screen, looking at each other.
**Same wavelength. Same room.** 🪞🔑
+74
View File
@@ -0,0 +1,74 @@
id: wavelength-date-night
engine: dial
category: co-op
title: "Wavelength: Date Night"
tagline: Every spectrum is about you two. Turns out you might not agree on what counts as romantic.
estimatedMinutes: 15
difficulty: 2
players: { min: 2, max: 2 }
theme: { primary: "#ef476f", accent: "#ffd6e0", background: "#14060a", headingFont: "Playfair Display" }
roles:
A:
name: Partner A
blurb: You give the clue in odd rounds and guess in even rounds.
B:
name: Partner B
blurb: You guess in odd rounds and give the clue in even rounds.
intro: |
Same rules as Tune In, but every dial is about relationships, dates, and the two of you.
**Terrible first date ↔ Perfect first date.** **Ick ↔ Green flag.** You'll learn things.
Clues are up to **3 words**. Normal scoring bands. 8 rounds, three keys. Expect debate after every reveal.
dial:
rounds: 8
range: [0, 100]
targetMargin: 5 # hidden target is a random integer from 5 to 95
scoring: # distance from target → points (first band that fits)
- { within: 4, points: 4 }
- { within: 9, points: 3 }
- { within: 14, points: 2 }
clue:
maxWords: 3
maxChars: 30
banLabelWords: true # clue can't contain any word from either label (normalized, case-insensitive)
noRepeatWords: false # if true, no clue word may be reused within the game
guesserSeesLabels: both # both | left | none
guesserSeesNumbers: true # whether the dial shows 0–100 tick numbers to the guesser
keys:
- { at: 8, name: Spark Key }
- { at: 14, name: Flame Key }
- { at: 20, name: Forever Key }
spectra:
- { id: d01, left: "Terrible first date", right: "Perfect first date" }
- { id: d02, left: "Unromantic", right: "Very romantic" }
- { id: d03, left: "Cheap date", right: "Fancy date" }
- { id: d04, left: "Bad gift", right: "Amazing gift" }
- { id: d05, left: "Ick", right: "Green flag" }
- { id: d06, left: "Boring couple activity", right: "Thrilling couple activity" }
- { id: d07, left: "Awkward thing to say on a date", right: "Smooth thing to say on a date" }
- { id: d08, left: "Lazy Sunday", right: "Busy Sunday" }
- { id: d09, left: "Chore I'd do", right: "Chore I'd never do" }
- { id: d10, left: "Snack to share", right: "Snack to keep to myself" }
- { id: d11, left: "Normal pet name", right: "Cringe pet name" }
- { id: d12, left: "Bad movie for date night", right: "Perfect movie for date night" }
- { id: d13, left: "Easy to forgive", right: "Hard to forgive" }
- { id: d14, left: "Friends-zone energy", right: "Soulmate energy" }
- { id: d15, left: "Low-effort surprise", right: "High-effort surprise" }
- { id: d16, left: "Cosy", right: "Adventurous" }
- { id: d17, left: "Something I'd never do for you", right: "Something I'd always do for you" }
- { id: d18, left: "Our quiet night", right: "Our wild night" }
- { id: d19, left: "Bad holiday destination", right: "Dream holiday destination" }
- { id: d20, left: "Small argument", right: "Big argument" }
- { id: d21, left: "Polite", right: "Brutally honest" }
- { id: d22, left: "Morning person thing", right: "Night owl thing" }
- { id: d23, left: "Stay in", right: "Go out" }
- { id: d24, left: "Unimportant anniversary", right: "Huge anniversary" }
- { id: d25, left: "Normal habit", right: "Weird habit" }
- { id: d26, left: "Hated chore", right: "Loved chore" }
- { id: d27, left: "Bad wedding song", right: "Perfect wedding song" }
- { id: d28, left: "Cheap way to say I love you", right: "Expensive way to say I love you" }
- { id: d29, left: "Overshare", right: "Keep it private" }
- { id: d30, left: "Calm", right: "Chaotic" }
successEnding: |
Date night: certified. 🍷🔑
+77
View File
@@ -0,0 +1,77 @@
id: wavelength-static
engine: dial
category: co-op
title: "Wavelength: Static"
tagline: One-word clues. Narrow targets. And the guesser can only see half the dial.
estimatedMinutes: 20
difficulty: 5
players: { min: 2, max: 2 }
theme: { primary: "#8338ec", accent: "#3a86ff", background: "#06040d", headingFont: "JetBrains Mono" }
roles:
A:
name: Partner A
blurb: You give the clue in odd rounds and guess in even rounds.
B:
name: Partner B
blurb: You guess in odd rounds and give the clue in even rounds.
intro: |
☠️ **Nightmare rules.**
- Clues are **exactly one word**, and you can **never reuse a word** used earlier in the game.
- The guesser only sees the **left** label. The right label is lost in static. Work it out from the clue... and from each other.
- The guesser's dial has **no numbers**.
- Scoring bands are razor thin. 10 rounds. Three keys.
dial:
rounds: 10
range: [0, 100]
targetMargin: 5 # hidden target is a random integer from 5 to 95
scoring: # distance from target → points (first band that fits)
- { within: 2, points: 4 }
- { within: 5, points: 3 }
- { within: 8, points: 2 }
clue:
maxWords: 1
maxChars: 30
banLabelWords: true # clue can't contain any word from either label (normalized, case-insensitive)
noRepeatWords: true # if true, no clue word may be reused within the game
guesserSeesLabels: left # both | left | none
guesserSeesNumbers: false # whether the dial shows 0–100 tick numbers to the guesser
keys:
- { at: 12, name: Static Key }
- { at: 20, name: Signal Key }
- { at: 26, name: Clear Channel Key }
spectra:
- { id: s01, left: "Cold", right: "Hot" }
- { id: s02, left: "Ordinary", right: "Magical" }
- { id: s03, left: "Past", right: "Future" }
- { id: s04, left: "Bitter", right: "Sweet" }
- { id: s05, left: "Simple", right: "Complicated" }
- { id: s06, left: "Silent", right: "Deafening" }
- { id: s07, left: "Fake", right: "Genuine" }
- { id: s08, left: "Temporary", right: "Permanent" }
- { id: s09, left: "Safe", right: "Dangerous" }
- { id: s10, left: "Nobody knows it", right: "Everybody knows it" }
- { id: s11, left: "Square", right: "Round" }
- { id: s12, left: "Fiction", right: "Fact" }
- { id: s13, left: "Smooth", right: "Rough" }
- { id: s14, left: "Light", right: "Heavy" }
- { id: s15, left: "Weird", right: "Normal" }
- { id: s16, left: "Sacred", right: "Silly" }
- { id: s17, left: "Harmless", right: "Deadly" }
- { id: s18, left: "Bad advice", right: "Good advice" }
- { id: s19, left: "Introvert", right: "Extrovert" }
- { id: s20, left: "Ancient", right: "Brand new" }
- { id: s21, left: "Dull", right: "Shiny" }
- { id: s22, left: "Soft skill", right: "Hard skill" }
- { id: s23, left: "Mainstream", right: "Niche" }
- { id: s24, left: "Peaceful", right: "Violent" }
- { id: s25, left: "Tasteless", right: "Delicious" }
- { id: s26, left: "Low quality", right: "High quality" }
- { id: s27, left: "Calm", right: "Panicked" }
- { id: s28, left: "Childish", right: "Mature" }
- { id: s29, left: "Guilty pleasure", right: "Proud pleasure" }
- { id: s30, left: "Overpriced", right: "Bargain" }
- { id: s31, left: "Messy", right: "Tidy" }
- { id: s32, left: "Rural", right: "Urban" }
successEnding: |
Through all that static, you still found each other. 📡🔑
+79
View File
@@ -0,0 +1,79 @@
id: wavelength-tune-in
engine: dial
category: co-op
title: "Wavelength: Tune In"
tagline: One of you knows where the needle should land. One clue. Can you read each other's minds?
estimatedMinutes: 10
difficulty: 1
players: { min: 2, max: 2 }
theme: { primary: "#06d6a0", accent: "#ffd166", background: "#04120d", headingFont: "Playfair Display" }
roles:
A:
name: Partner A
blurb: You give the clue in odd rounds and guess in even rounds.
B:
name: Partner B
blurb: You guess in odd rounds and give the clue in even rounds.
intro: |
Each round, a dial appears with two opposites, like **Cold ↔ Hot**. The **clue-giver** secretly sees where
the target sits on the dial and types a short clue (up to 5 words), something that sits at that spot.
The **guesser** turns the dial to where they think the target is, then locks in.
Example: target near "Hot" → clue "a fresh cup of coffee". Target in the middle → "a bath you forgot about".
Wide scoring bands. 6 rounds. Two keys to win. No talking during a round, just the clue!
dial:
rounds: 6
range: [0, 100]
targetMargin: 5 # hidden target is a random integer from 5 to 95
scoring: # distance from target → points (first band that fits)
- { within: 5, points: 4 }
- { within: 10, points: 3 }
- { within: 15, points: 2 }
- { within: 20, points: 1 }
clue:
maxWords: 5
maxChars: 30
banLabelWords: true # clue can't contain any word from either label (normalized, case-insensitive)
noRepeatWords: false # if true, no clue word may be reused within the game
guesserSeesLabels: both # both | left | none
guesserSeesNumbers: true # whether the dial shows 0–100 tick numbers to the guesser
keys:
- { at: 8, name: Tuning Key }
- { at: 14, name: Frequency Key }
spectra:
- { id: g01, left: "Cold", right: "Hot" }
- { id: g02, left: "Cheap", right: "Expensive" }
- { id: g03, left: "Useless", right: "Useful" }
- { id: g04, left: "Boring", right: "Exciting" }
- { id: g05, left: "Quiet", right: "Loud" }
- { id: g06, left: "Tiny", right: "Huge" }
- { id: g07, left: "Healthy snack", right: "Unhealthy snack" }
- { id: g08, left: "Easy to spell", right: "Hard to spell" }
- { id: g09, left: "Scary", right: "Cute" }
- { id: g10, left: "Old-fashioned", right: "Modern" }
- { id: g11, left: "Overrated", right: "Underrated" }
- { id: g12, left: "Smells bad", right: "Smells good" }
- { id: g13, left: "Rare", right: "Common" }
- { id: g14, left: "Bad movie", right: "Great movie" }
- { id: g15, left: "Soft", right: "Hard" }
- { id: g16, left: "Normal pet", right: "Weird pet" }
- { id: g17, left: "Sad song", right: "Happy song" }
- { id: g18, left: "Easy job", right: "Hard job" }
- { id: g19, left: "Fragile", right: "Unbreakable" }
- { id: g20, left: "Breakfast food", right: "Dinner food" }
- { id: g21, left: "Forgettable", right: "Unforgettable" }
- { id: g22, left: "Slow", right: "Fast" }
- { id: g23, left: "Ugly", right: "Beautiful" }
- { id: g24, left: "Villain", right: "Hero" }
- { id: g25, left: "Dry", right: "Wet" }
- { id: g26, left: "Mild", right: "Spicy" }
- { id: g27, left: "Lazy", right: "Hardworking" }
- { id: g28, left: "Indoor activity", right: "Outdoor activity" }
- { id: g29, left: "Kid's toy", right: "Adult's toy" }
- { id: g30, left: "Waste of money", right: "Worth every cent" }
- { id: g31, left: "Weak superpower", right: "Strong superpower" }
- { id: g32, left: "Short-lived", right: "Lasts forever" }
successEnding: |
You two are on the same frequency. 📻🔑
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "two-keys",
"version": "1.2.0",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "two-keys",
"version": "1.2.0",
"version": "1.3.0",
"workspaces": [
"shared",
"server",
@@ -23,7 +23,7 @@
},
"client": {
"name": "@two-keys/client",
"version": "1.2.0",
"version": "1.3.0",
"dependencies": {
"@two-keys/shared": "*",
"framer-motion": "^11.11.0",
@@ -6847,7 +6847,7 @@
},
"server": {
"name": "@two-keys/server",
"version": "1.2.0",
"version": "1.3.0",
"dependencies": {
"@two-keys/shared": "*",
"express": "^4.21.0",
@@ -7332,7 +7332,7 @@
},
"shared": {
"name": "@two-keys/shared",
"version": "1.2.0",
"version": "1.3.0",
"devDependencies": {
"vitest": "^2.1.0"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "two-keys",
"private": true,
"version": "1.2.0",
"version": "1.3.0",
"description": "Co-op mystery games for couples",
"workspaces": [
"shared",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@two-keys/server",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"type": "module",
"scripts": {
+25 -1
View File
@@ -33,7 +33,9 @@ export function loadGameFile(file: string): LoadedFile {
} catch (err) {
return { file: name, game: null, errors: [`YAML parse error: ${(err as Error).message}`] };
}
const parsed = gameSchema.safeParse(raw);
const borrowed = borrowManual(file, raw);
if (borrowed.error) return { file: name, game: null, errors: [borrowed.error] };
const parsed = gameSchema.safeParse(borrowed.raw);
if (!parsed.success) {
const errors = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`);
return { file: name, game: null, errors };
@@ -45,6 +47,28 @@ export function loadGameFile(file: string): LoadedFile {
return { file: name, game, errors };
}
/** What a defuse game with `manualFrom` takes from that game's file. */
const MANUAL_FIELDS = ['ruleset', 'device', 'modules', 'manual', 'fixtures'] as const;
/** `manualFrom: <game-id>` reuses that game's device, modules, manual, ruleset and fixtures (and so its solver). */
function borrowManual(file: string, raw: unknown): { raw: unknown; error?: string } {
const own = raw as Record<string, unknown> | null;
if (!own || typeof own !== 'object' || own.engine !== 'defuse' || typeof own.manualFrom !== 'string') return { raw };
const source = path.join(path.dirname(file), `${own.manualFrom}.yaml`);
let base: Record<string, unknown>;
try {
base = yaml.load(fs.readFileSync(source, 'utf8')) as Record<string, unknown>;
} catch {
return { raw, error: `manualFrom: game "${own.manualFrom}" is missing or unreadable` };
}
if (base?.engine !== 'defuse' || base.manualFrom) {
return { raw, error: `manualFrom: "${own.manualFrom}" must be a defuse game with its own manual` };
}
const merged = { ...own };
for (const field of MANUAL_FIELDS) if (field in base) merged[field] = base[field];
return { raw: merged };
}
export function loadAllFiles(dir = gamesDir()): LoadedFile[] {
return fs
.readdirSync(dir)
+153 -6
View File
@@ -1,10 +1,12 @@
// Builds everything that leaves the server. Views are assembled field by field (never by
// copying a content object and deleting from it), so answers can't leak by accident.
import type {
BlueprintView,
ChapterView,
ClueView,
DefuseModuleView,
DefuseView,
DialView,
Dir,
EngineView,
GameMeta,
@@ -15,10 +17,13 @@ import type {
Role,
StoryView,
SyncView,
TwinView,
} from '@two-keys/shared';
import { otherRole } from '@two-keys/shared';
import { maxStrikes, remainingAt, toSignal, type DefuseState } from '../engines/defuse';
import { totalKeys } from '../engines/index';
import { architectOf, patternRemaining, peeking, type BlueprintState } from '../engines/blueprint';
import { maxStrikes, remainingAt, technicianOf, toSignal, wireModules, type DefuseState } from '../engines/defuse';
import { giverOf, maxTotal, spectrumOf, type DialState } from '../engines/dial';
import { timeLimitSeconds, totalKeys } from '../engines/index';
import {
DELTAS,
OPPOSITE,
@@ -32,8 +37,29 @@ import {
} from '../engines/labyrinth';
import { NUDGE_AFTER_WRONG, hintPenalty, type StoryState } from '../engines/story';
import { canReplay, isMatch, pointsForKey, subjectOf, type SyncState } from '../engines/sync';
import {
canRetry,
flipX,
gridA,
gridB,
liveTap,
mirrored,
sceneRemaining,
type TwinState,
} from '../engines/twin';
import type { Room } from '../rooms/store';
import type { Clue, DefuseGame, Game, LabyrinthGame, Lock, StoryGame, SyncGame } from './schemas';
import type {
BlueprintGame,
Clue,
DefuseGame,
DialGame,
Game,
LabyrinthGame,
Lock,
StoryGame,
SyncGame,
TwinGame,
} from './schemas';
export function gameSummary(game: Game): GameSummary {
return {
@@ -47,7 +73,7 @@ export function gameSummary(game: Game): GameSummary {
theme: { ...game.theme },
roles: { A: { ...game.roles.A }, B: { ...game.roles.B } },
totalKeys: totalKeys(game),
timeLimitMinutes: game.engine === 'story' ? (game.rules.timeLimitMinutes ?? null) : null,
timeLimitMinutes: timeLimitSeconds(game) === null ? null : timeLimitSeconds(game)! / 60,
};
}
@@ -76,6 +102,7 @@ function lockView(game: StoryGame, lock: Lock, s: StoryState): LockView {
hints: lock.hints.slice(0, s.hints[lock.id] ?? 0),
hintsTotal: lock.hints.length,
hintPenaltySeconds: hintPenalty(game),
timeLimited: timeLimitSeconds(game) !== null,
wrongAttempts,
nudge: !solved && wrongAttempts >= NUDGE_AFTER_WRONG,
successText: solved ? (lock.successText ?? null) : null,
@@ -146,6 +173,8 @@ const MORSE_BAND = { minKhz: 3500, maxKhz: 3600 };
export function defuseView(game: DefuseGame, s: DefuseState, role: Role, now: number): DefuseView {
const level = game.levels[s.level];
const technician = technicianOf(game, s.level);
const ready = s.swapReady ?? [];
const common = {
engine: 'defuse' as const,
level: { index: s.level, total: game.levels.length, title: level.title, timeSeconds: level.timeSeconds },
@@ -156,16 +185,21 @@ export function defuseView(game: DefuseGame, s: DefuseState, role: Role, now: nu
clock: s.clock
? { remaining: Math.max(0, remainingAt(s, now)), speed: s.clock.speed, running: s.status === 'active' }
: null,
technician,
swapRoles: game.swapRolesEachLevel,
swapReady: { you: ready.includes(role), partner: ready.includes(otherRole(role)) },
};
// The Expert gets the manual and nothing about the device.
if (role === 'B') return { ...common, seat: 'expert', manual: game.manual };
if (role !== technician) return { ...common, seat: 'expert', manual: game.manual };
// The Technician gets the device and never the manual.
const d = s.device;
if (!d) return { ...common, seat: 'technician', device: null };
const modules: DefuseModuleView[] = [];
if (d.wires) modules.push({ id: 'wires', solved: d.wires.solved, wires: [...d.wires.colors] });
for (const { instance, title, module } of wireModules(d)) {
modules.push({ id: 'wires', instance, title, solved: module.solved, wires: [...module.colors] });
}
if (d.button) {
modules.push({
id: 'button',
@@ -285,6 +319,9 @@ export function labyrinthView(game: LabyrinthGame, s: LabyrinthState, role: Role
y: g.y,
dir: mirror && (g.dir === 'e' || g.dir === 'w') ? OPPOSITE[g.dir] : g.dir,
})),
// Dimmed once picked up; the Navigator still only hears about it from the Explorer.
items: game.items.map((it) => ({ x: fx(it.x), y: it.y, name: it.name, collected: (s.inventory ?? []).includes(it.id) })),
doors: game.doors.map((d) => ({ x: fx(d.x), y: d.y, name: d.name })),
banner: game.navigatorView.banner ?? null,
keysCarried,
totalKeys: total,
@@ -320,6 +357,110 @@ export function labyrinthView(game: LabyrinthGame, s: LabyrinthState, role: Role
message: s.message || (glyph ? '' : game.explorerView.flavour.blank),
moves: s.moves,
falls: s.falls,
// Names only: where items and doors are stays on the Navigator's map.
inventory: (s.inventory ?? []).map((id) => game.items.find((it) => it.id === id)?.name ?? id),
};
}
// ---------- dial ----------
export function dialView(game: DialGame, s: DialState, role: Role): DialView {
const giver = giverOf(s.round);
const iAmGiver = role === giver;
const revealed = s.status === 'reveal';
const { left, right } = spectrumOf(game, s);
// The guesser may only see some labels, and never the target before locking in.
const sees = iAmGiver || revealed ? 'both' : game.dial.guesserSeesLabels;
return {
engine: 'dial',
status: s.status,
round: s.round,
rounds: game.dial.rounds,
giver,
iAmGiver,
left: sees === 'none' ? null : left,
right: sees === 'both' ? right : null,
showNumbers: iAmGiver || revealed || game.dial.guesserSeesNumbers,
range: [...game.dial.range],
bands: game.dial.scoring.map((b) => ({ within: b.within, points: b.points })),
rules: { ...game.dial.clue },
usedWords: [...s.usedWords],
clue: iAmGiver || s.status !== 'clue' ? s.clue : null,
target: iAmGiver || revealed ? s.target : null,
// The live needle stays with the guesser: no "warmer/colder" for the clue-giver.
needle: iAmGiver ? null : s.needle,
guess: revealed ? s.guess : null,
points: revealed ? s.points : null,
total: s.total,
maxTotal: maxTotal(game),
keys: game.dial.keys.map((k) => ({ at: k.at, name: k.name, earned: s.total >= k.at })),
history: s.history.map((h) => ({ ...h })),
};
}
// ---------- twin ----------
export function twinView(game: TwinGame, s: TwinState, role: Role, now: number): TwinView {
const scene = game.scenes[s.scene];
// Each player gets only their own room; `changes` never leave the server.
const own = role === 'A' ? gridA(scene) : gridB(scene);
const grid = mirrored(game, role) ? own.map((row) => [...row].reverse()) : own;
const tap = s.status === 'playing' ? liveTap(game, s.taps[role], now) : null;
const theirs = s.status === 'playing' ? liveTap(game, s.taps[otherRole(role)], now) : null;
const left = (at: number) => Math.max(0, game.twin.claimWindowSeconds - (now - at) / 1000);
return {
engine: 'twin',
status: s.status,
scene: {
index: s.scene,
total: game.scenes.length,
title: scene.title,
keyName: scene.keyName,
differences: scene.changes.length,
},
grid,
showCoordinates: game.twin.showCoordinates,
found: s.found.map((i) => ({ x: flipX(game, scene, role, scene.changes[i].x), y: scene.changes[i].y })),
myTap: tap ? { x: flipX(game, scene, role, tap.x), y: tap.y, secondsLeft: left(tap.at) } : null,
partnerTap: theirs ? { secondsLeft: left(theirs.at) } : null,
remaining: Math.max(0, sceneRemaining(game, s, now)),
timeSeconds: scene.timeSeconds,
claimWindowSeconds: game.twin.claimWindowSeconds,
wrongClaimPenaltySeconds: game.twin.wrongClaimPenaltySeconds,
canRetry: canRetry(s),
isRetry: s.attempt > 0,
};
}
// ---------- blueprint ----------
export function blueprintView(game: BlueprintGame, s: BlueprintState, role: Role, now: number): BlueprintView {
const p = game.patterns[s.pattern];
const architect = architectOf(game, s.pattern);
const isArchitect = role === architect;
const scored = s.status === 'scored';
return {
engine: 'blueprint',
status: s.status,
seat: isArchitect ? 'architect' : 'builder',
architect,
pattern: { index: s.pattern, total: game.patterns.length, size: p.size, keyName: p.keyName },
palette: Object.entries(p.palette).map(([key, c]) => ({ key, name: c.name, hex: c.hex })),
// The Builder never gets the picture or its name until it's scored.
target: isArchitect || scored ? [...p.rows] : null,
title: isArchitect || scored ? p.title : null,
// The Architect only sees the canvas during a peek, or once it's scored.
canvas: !isArchitect || scored || peeking(s, now) ? [...s.canvas] : null,
rotateBuilder: game.blueprint.rotateBuilder,
peeksLeft: Math.max(0, p.peeks - s.peeksUsed),
peekUntil: isArchitect && peeking(s, now) ? s.peekUntil : null,
submitsLeft: Math.max(0, game.blueprint.submitsPerPattern - s.submits),
threshold: p.threshold,
bestScore: s.bestScore,
lastScore: s.lastScore,
passed: s.passed,
remaining: Math.max(0, patternRemaining(game, s, now)),
timeSeconds: p.timeSeconds,
};
}
@@ -333,5 +474,11 @@ export function engineView(game: Game, room: Room, role: Role, now: number): Eng
return syncView(game, room.engine as SyncState, role);
case 'labyrinth':
return labyrinthView(game, room.engine as LabyrinthState, role);
case 'dial':
return dialView(game, room.engine as DialState, role);
case 'twin':
return twinView(game, room.engine as TwinState, role, now);
case 'blueprint':
return blueprintView(game, room.engine as BlueprintState, role, now);
}
}
+135 -4
View File
@@ -30,7 +30,14 @@ const clueSchema = z.object({
.optional(),
});
const optionSchema = z.object({ id: z.string().min(1), label: z.string().min(1) });
/** A hard limit on the whole game's clock (elapsed + penalties). Running out ends the game. */
const timeLimitRules = {
timeLimitMinutes: z.number().positive().optional(),
timeLimitLabel: z.string().min(1).optional(),
};
// Strict: an unquoted comma in `{ id, label: a, b }` adds a stray `b` key instead of failing.
const optionSchema = z.object({ id: z.string().min(1), label: z.string().min(1) }).strict();
const lockSchema = z.object({
id: z.string().min(1),
@@ -92,9 +99,7 @@ const storySchema = z.object({
rules: z
.object({
hintPenaltySeconds: z.number().int().nonnegative().optional(),
/** Hard limit on the clock (elapsed + penalties). Running out ends the game. */
timeLimitMinutes: z.number().positive().optional(),
timeLimitLabel: z.string().min(1).optional(),
...timeLimitRules,
})
.default({}),
suspects: z
@@ -115,6 +120,10 @@ const defuseSchema = z.object({
engine: z.literal('defuse'),
/** Which manual the solver implements. Each manual's rules live in engines/defuse.ts. */
ruleset: z.enum(['classic', 'overtime']).default('classic'),
/** The game whose device, modules, manual, ruleset and fixtures this one reuses (filled in by the loader). */
manualFrom: z.string().optional(),
/** Level 1: A is the Technician; level 2: B; and so on. */
swapRolesEachLevel: z.boolean().default(false),
device: z.object({
serial: z.object({ length: z.number().int().min(2), alphabet: z.string().min(2) }),
batteries: range,
@@ -155,6 +164,8 @@ const defuseSchema = z.object({
simon: z.object({ colors: z.array(z.string()).min(2), stages: range }).optional(),
}),
manual: z.string().min(1),
/** Shown when the last level is cleared. */
successEnding: z.string().min(1).optional(),
fixtures: z.object({
wires: z.array(
z.object({
@@ -238,6 +249,8 @@ const syncSchema = z.object({
predict: z.array(questionSchema).default([]),
telepathy: z.array(telepathySchema).default([]),
rank: z.array(rankSchema).default([]),
/** Shown when every key is earned. */
successEnding: z.string().min(1).optional(),
});
// ---------- labyrinth ----------
@@ -255,6 +268,23 @@ const labyrinthSchema = z.object({
lanternsRequired: z.number().int().positive().default(3),
/** One-way gates: you may step from (x, y) in `dir`, never back the other way. */
oneWay: z.array(z.object({ x: z.number().int(), y: z.number().int(), dir })).default([]),
/** Picked up on entry and kept after traps. */
items: z
.array(z.object({ x: z.number().int(), y: z.number().int(), id: z.string().min(1), name: z.string().min(1), explorerText: z.string().min(1) }))
.default([]),
/** Impassable until the Explorer carries the item `needs`. */
doors: z
.array(
z.object({
x: z.number().int(),
y: z.number().int(),
needs: z.string().min(1),
name: z.string().min(1),
lockedText: z.string().min(1),
openText: z.string().min(1),
}),
)
.default([]),
explorer: z
.object({
/** `relative` swaps N/E/S/W for forward/left/right/back from the way the Explorer faces. */
@@ -282,17 +312,118 @@ const labyrinthSchema = z.object({
successEnding: z.string().min(1),
});
// ---------- dial (Wavelength) ----------
const dialSchema = z.object({
...header,
engine: z.literal('dial'),
rules: z.object(timeLimitRules).default({}),
dial: z.object({
rounds: z.number().int().positive(),
range: z.tuple([z.number().int(), z.number().int()]),
/** The hidden target is a random integer at least this far inside the range. */
targetMargin: z.number().int().nonnegative(),
/** Distance from the target → points; the first band that fits wins, outside all = 0. */
scoring: z.array(z.object({ within: z.number().nonnegative(), points: z.number().int().positive() })).min(1),
clue: z.object({
maxWords: z.number().int().positive(),
maxChars: z.number().int().positive(),
banLabelWords: z.boolean(),
noRepeatWords: z.boolean(),
}),
guesserSeesLabels: z.enum(['both', 'left', 'none']),
guesserSeesNumbers: z.boolean(),
/** Cumulative points thresholds. */
keys: z.array(z.object({ at: z.number().int().positive(), name: z.string().min(1) })).min(1),
}),
spectra: z.array(z.object({ id: z.string().min(1), left: z.string().min(1), right: z.string().min(1) })).min(1),
successEnding: z.string().min(1),
});
// ---------- twin (Twin Rooms) ----------
const twinSchema = z.object({
...header,
engine: z.literal('twin'),
rules: z.object(timeLimitRules).default({}),
twin: z.object({
claimWindowSeconds: z.number().positive(),
wrongClaimPenaltySeconds: z.number().nonnegative(),
/** B's room is shown flipped left to right. */
mirrorB: z.boolean().default(false),
showCoordinates: z.boolean().default(false),
}),
scenes: z
.array(
z.object({
id: z.string().min(1),
title: z.string().min(1),
timeSeconds: z.number().int().positive(),
keyName: z.string().min(1),
differences: z.number().int().positive().optional(),
/** A's room: one row per string, cells separated by single spaces (never split by character). */
grid: z.array(z.string().min(1)).min(1),
/** SERVER ONLY: B's room is the grid with these cells replaced. */
changes: z.array(z.object({ x: z.number().int(), y: z.number().int(), to: z.string().min(1) })).min(1),
}),
)
.min(1),
successEnding: z.string().min(1),
});
// ---------- blueprint (Describe & Draw) ----------
const blueprintSchema = z.object({
...header,
engine: z.literal('blueprint'),
rules: z.object(timeLimitRules).default({}),
blueprint: z.object({
scoring: z.literal('overlap'),
/** The Builder's canvas is shown rotated clockwise; the server stores it in target orientation. */
rotateBuilder: z.union([z.literal(0), z.literal(90), z.literal(180), z.literal(270)]).default(0),
swapRolesEachPattern: z.boolean().default(false),
submitsPerPattern: z.number().int().positive().default(1),
}),
patterns: z
.array(
z.object({
id: z.string().min(1),
/** SERVER ONLY for the Builder until the pattern is scored. */
title: z.string().min(1),
size: z.number().int().min(2).max(16),
timeSeconds: z.number().int().positive(),
threshold: z.number(),
peeks: z.number().int().nonnegative(),
keyName: z.string().min(1),
palette: z.record(
z.string().regex(/^[A-Z]$/, 'palette keys are single capital letters'),
z.object({ name: z.string().min(1), hex }),
),
/** '.' = empty. SERVER ONLY for the Builder. */
rows: z.array(z.string()).min(2),
}),
)
.min(1),
successEnding: z.string().min(1),
});
export const gameSchema = z.discriminatedUnion('engine', [
storySchema,
defuseSchema,
syncSchema,
labyrinthSchema,
dialSchema,
twinSchema,
blueprintSchema,
]);
export type StoryGame = z.infer<typeof storySchema>;
export type DefuseGame = z.infer<typeof defuseSchema>;
export type SyncGame = z.infer<typeof syncSchema>;
export type LabyrinthGame = z.infer<typeof labyrinthSchema>;
export type DialGame = z.infer<typeof dialSchema>;
export type TwinGame = z.infer<typeof twinSchema>;
export type BlueprintGame = z.infer<typeof blueprintSchema>;
export type Game = z.infer<typeof gameSchema>;
export type Clue = z.infer<typeof clueSchema>;
export type Lock = z.infer<typeof lockSchema>;
+146 -4
View File
@@ -10,8 +10,21 @@ import {
wireSolution,
} from '../engines/defuse';
import { DELTAS, analyseMaze, cellAt, findCells, isReachable, safeReachable } from '../engines/labyrinth';
import { maxTotal } from '../engines/dial';
import { groupOf, pointsForKey } from '../engines/sync';
import type { Clue, DefuseGame, Game, LabyrinthGame, Lock, StoryGame, SyncGame } from './schemas';
import { gridA } from '../engines/twin';
import type {
BlueprintGame,
Clue,
DefuseGame,
DialGame,
Game,
LabyrinthGame,
Lock,
StoryGame,
SyncGame,
TwinGame,
} from './schemas';
export const MIN_SYNC_QUESTIONS = 15;
@@ -26,9 +39,99 @@ export function validateGame(game: Game): string[] {
return validateSync(game);
case 'labyrinth':
return validateLabyrinth(game);
case 'dial':
return validateDial(game);
case 'twin':
return validateTwin(game);
case 'blueprint':
return validateBlueprint(game);
}
}
/** A one-line summary for the validate-content report. */
export function describeGame(game: Game): string {
switch (game.engine) {
case 'story':
return `${game.chapters.length} chapters + ${game.finale.type}`;
case 'defuse':
return `${game.levels.length} levels${game.manualFrom ? `, manual from ${game.manualFrom}` : ''}${game.swapRolesEachLevel ? ', roles swap' : ''}`;
case 'sync':
return `${game.rounds.length} rounds: ${game.rounds.map((r) => r.type).join(', ')}`;
case 'labyrinth': {
const traps = findCells(game.grid, 'T').length;
return `${game.grid[0].length}×${game.grid.length}, ${traps} traps, ${game.lanterns.length} lanterns, ${game.oneWay.length} one-way, ${game.doors.length} doors`;
}
case 'dial':
return `${game.dial.rounds} rounds, ${game.spectra.length} spectra, keys at ${game.dial.keys.map((k) => k.at).join('/')} of ${maxTotal(game)}`;
case 'twin':
return `${game.scenes.length} scenes, ${game.scenes.reduce((n, s) => n + s.changes.length, 0)} differences${game.twin.mirrorB ? ', B mirrored' : ''}`;
case 'blueprint':
return `${game.patterns.length} pictures, ${[...new Set(game.patterns.map((p) => p.size))].join('/')} grid${game.blueprint.rotateBuilder ? `, Builder rotated ${game.blueprint.rotateBuilder}°` : ''}`;
}
}
export const MIN_SPECTRA = 30;
function validateDial(game: DialGame): string[] {
const errors: string[] = [];
const { dial, spectra } = game;
// Enough for a session that never repeats, with room to spare.
const needed = Math.max(MIN_SPECTRA, dial.rounds * 2);
if (spectra.length < needed) errors.push(`spectra bank has ${spectra.length}, needs at least ${needed}`);
if (new Set(spectra.map((x) => x.id)).size !== spectra.length) errors.push('duplicate spectrum id');
const [lo, hi] = dial.range;
if (hi - lo <= 2 * dial.targetMargin) errors.push('targetMargin leaves no room for a target');
const bands = dial.scoring.map((b) => b.within);
if (bands.some((w, i) => i > 0 && w <= bands[i - 1])) errors.push('scoring bands must widen');
if (dial.scoring.some((b, i) => i > 0 && b.points >= dial.scoring[i - 1].points)) errors.push('wider scoring bands must score less');
const max = maxTotal(game);
for (const k of dial.keys) if (k.at > max) errors.push(`key "${k.name}" needs ${k.at} points, but ${max} is the most possible`);
if (dial.keys.some((k, i) => i > 0 && k.at <= dial.keys[i - 1].at)) errors.push('key thresholds must go up');
return errors;
}
function validateTwin(game: TwinGame): string[] {
const errors: string[] = [];
const ids = new Set<string>();
for (const scene of game.scenes) {
const where = `scene ${scene.id}`;
if (ids.has(scene.id)) errors.push(`duplicate scene id "${scene.id}"`);
ids.add(scene.id);
const grid = gridA(scene);
const width = grid[0].length;
if (grid.some((row) => row.length !== width)) errors.push(`${where}: rows don't all have ${width} cells`);
if (grid.some((row) => row.some((cell) => cell === ''))) errors.push(`${where}: a row has a double space`);
const seen = new Set<string>();
for (const c of scene.changes) {
if (c.x < 0 || c.y < 0 || c.y >= grid.length || c.x >= width) {
errors.push(`${where}: change at (${c.x},${c.y}) is off the grid`);
continue;
}
if (grid[c.y][c.x] === c.to) errors.push(`${where}: change at (${c.x},${c.y}) doesn't change anything`);
if (seen.has(`${c.x},${c.y}`)) errors.push(`${where}: two changes at (${c.x},${c.y})`);
seen.add(`${c.x},${c.y}`);
}
if ((scene.differences ?? scene.changes.length) !== scene.changes.length) {
errors.push(`${where}: says ${scene.differences} differences but has ${scene.changes.length} changes`);
}
}
return errors;
}
function validateBlueprint(game: BlueprintGame): string[] {
const errors: string[] = [];
for (const p of game.patterns) {
const where = `pattern ${p.id}`;
if (p.rows.length !== p.size || p.rows.some((r) => r.length !== p.size)) errors.push(`${where}: rows must be ${p.size}×${p.size}`);
const used = new Set(p.rows.join('').replaceAll('.', ''));
for (const c of used) if (!(c in p.palette)) errors.push(`${where}: colour "${c}" is not in the palette`);
if (used.size === 0) errors.push(`${where}: the picture is empty`);
if (!(p.threshold > 0 && p.threshold <= 1)) errors.push(`${where}: threshold must be above 0 and at most 1`);
}
if (new Set(game.patterns.map((p) => p.id)).size !== game.patterns.length) errors.push('duplicate pattern id');
return errors;
}
function validateStory(game: StoryGame): string[] {
const errors: string[] = [];
const ids = new Set<string>();
@@ -118,8 +221,15 @@ function validateDefuse(game: DefuseGame): string[] {
});
for (const level of game.levels) {
const from = game.manualFrom ? ` (in ${game.manualFrom})` : '';
for (const id of level.modules) {
if (!(id in modules)) errors.push(`level ${level.id}: module "${id}" is not defined under modules`);
if (!(id in modules)) errors.push(`level ${level.id}: module "${id}" is not defined under modules${from}`);
}
// Only wires can appear twice on one device (as Wires A and Wires B).
for (const id of new Set(level.modules)) {
if (id !== 'wires' && level.modules.filter((m) => m === id).length > 1) {
errors.push(`level ${level.id}: only wires can appear more than once, "${id}" does`);
}
}
}
if (modules.passwords) {
@@ -180,6 +290,15 @@ function validateSync(game: SyncGame): string[] {
for (const q of game.rank) {
if (q.items.length !== 4) errors.push(`rank question ${q.id}: needs exactly 4 items, has ${q.items.length}`);
}
// An unquoted comma in a YAML [flow, list] splits one option into several, e.g. "A craft (woodwork" + "pottery".
const lists = [...game.match, ...game.predict].map((q) => [q.id, q.options] as const);
for (const [id, options] of [...lists, ...game.rank.map((q) => [q.id, q.items] as const)]) {
for (const o of options) {
if (o.split('(').length !== o.split(')').length || /^[a-z]/.test(o)) {
errors.push(`question ${id}: option "${o}" looks split by an unquoted comma; quote it`);
}
}
}
for (const r of game.rounds) {
if (r.type === 'predict' && !r.subject) errors.push(`round ${r.id}: predict rounds need a subject`);
if (r.type === 'rank' && r.subjectPattern && r.subjectPattern.length !== game.rules.questionsPerRound) {
@@ -219,16 +338,39 @@ function validateLabyrinth(game: LabyrinthGame): string[] {
if (ends.includes('T')) errors.push(`one-way gate at (${g.x},${g.y}) ${g.dir} sits on a trap`);
}
// Items and doors live on plain floor; a door needs an item that exists.
const itemIds = new Set(game.items.map((it) => it.id));
if (itemIds.size !== game.items.length) errors.push('duplicate item id');
for (const it of [...game.items, ...game.doors]) {
const c = cellAt(grid, it.x, it.y);
if (c !== '.') errors.push(`"${it.name}" at (${it.x},${it.y}) must be on plain floor (.), not "${c}"`);
}
for (const d of game.doors) {
if (!itemIds.has(d.needs)) errors.push(`door "${d.name}" needs unknown item "${d.needs}"`);
}
const taken = new Set<string>();
for (const p of [...game.items, ...game.doors]) {
if (taken.has(`${p.x},${p.y}`)) errors.push(`two items or doors share (${p.x},${p.y})`);
taken.add(`${p.x},${p.y}`);
}
if (starts.length === 1) {
const reach = safeReachable(grid, game.oneWay);
// Doors block until their item is carried, so reachability plays the maze out with items.
const reach =
game.doors.length > 0
? analyseMaze(grid, game.oneWay, game.lanterns, game.items, game.doors).safeCells
: safeReachable(grid, game.oneWay);
for (const k of keys) {
if (!isReachable(reach, k)) errors.push(`lantern at (${k.x},${k.y}) is not reachable from S without a trap`);
}
for (const e of exits) {
if (!isReachable(reach, e)) errors.push(`exit at (${e.x},${e.y}) is not reachable from S without a trap`);
}
for (const it of game.items) {
if (!isReachable(reach, it)) errors.push(`item "${it.name}" at (${it.x},${it.y}) is not reachable from S without a trap`);
}
if (errors.length === 0) {
const { winnable, stuck } = analyseMaze(grid, game.oneWay, game.lanterns);
const { winnable, stuck } = analyseMaze(grid, game.oneWay, game.lanterns, game.items, game.doors);
if (!winnable) errors.push('no trap-free route collects every lantern and then reaches the exit');
for (const p of stuck) errors.push(`the Explorer can get stuck for good at (${p.x},${p.y})`);
}
+173
View File
@@ -0,0 +1,173 @@
import type { Role } from '@two-keys/shared';
import type { BlueprintGame } from '../content/schemas';
import { OK, fail, type ActionResult, type Ctx } from './types';
type Pattern = BlueprintGame['patterns'][number];
export const PEEK_MS = 3000;
export const EMPTY = '.';
export interface BlueprintState {
pattern: number;
status: 'ready' | 'building' | 'scored';
startedAt: number | null;
/** The Builder's canvas, row by row, in target orientation ('.' = empty). */
canvas: string[];
submits: number;
bestScore: number | null;
lastScore: number | null;
passed: boolean | null;
peeksUsed: number;
peekUntil: number | null;
}
const state = (ctx: Ctx) => ctx.room.engine as BlueprintState;
const blank = (size: number) => Array.from({ length: size }, () => EMPTY.repeat(size));
export function initBlueprint(game: BlueprintGame): BlueprintState {
return {
pattern: 0,
status: 'ready',
startedAt: null,
canvas: blank(game.patterns[0].size),
submits: 0,
bestScore: null,
lastScore: null,
passed: null,
peeksUsed: 0,
peekUntil: null,
};
}
/** Pattern 1: A describes. With `swapRolesEachPattern`, the Architect alternates A, B, A... */
export const architectOf = (game: BlueprintGame, pattern: number): Role =>
game.blueprint.swapRolesEachPattern && pattern % 2 === 1 ? 'B' : 'A';
/**
* Matched cells (both painted, same colour) over cells painted in either grid. Cells empty in
* both don't count, so a blank canvas never scores.
*/
export function overlapScore(target: readonly string[], canvas: readonly string[]): number {
let matched = 0;
let union = 0;
target.forEach((row, y) => {
[...row].forEach((t, x) => {
const p = canvas[y]?.[x] ?? EMPTY;
if (t === EMPTY && p === EMPTY) return;
union++;
if (t === p) matched++;
});
});
return union === 0 ? 0 : matched / union;
}
/**
* The Builder's canvas shown rotated 90° clockwise: displayed (column c, row r) on an n×n grid is
* stored cell (x = r, y = n − 1 − c). The other angles follow by repeating it.
*/
export function displayedToStored(n: number, rotate: number, c: number, r: number): { x: number; y: number } {
let p = { x: c, y: r };
for (let turn = 0; turn < ((rotate / 90) % 4); turn++) p = { x: p.y, y: n - 1 - p.x };
return p;
}
export function patternRemaining(game: BlueprintGame, s: BlueprintState, now: number): number {
const p = game.patterns[s.pattern];
return s.startedAt === null ? p.timeSeconds : p.timeSeconds - (now - s.startedAt) / 1000;
}
export const peeking = (s: BlueprintState, now: number) => s.peekUntil !== null && now < s.peekUntil;
export function paint(
game: BlueprintGame,
ctx: Ctx,
role: Role,
cells: { x: number; y: number; color: string | null }[],
): ActionResult {
const s = state(ctx);
if (s.status !== 'building') return fail('not_building', 'The canvas is closed.');
if (role === architectOf(game, s.pattern)) return fail('wrong_role', 'Only the Builder paints.');
const p = game.patterns[s.pattern];
const rows = s.canvas.map((row) => [...row]);
for (const { x, y, color } of cells) {
if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= p.size || y >= p.size) {
return fail('bad_cell', 'That square is off the canvas.');
}
if (color !== null && !(color in p.palette)) return fail('bad_color', "That colour isn't on the palette.");
rows[y][x] = color ?? EMPTY;
}
s.canvas = rows.map((row) => row.join(''));
return OK;
}
export function peek(game: BlueprintGame, ctx: Ctx, role: Role): ActionResult {
const s = state(ctx);
if (s.status !== 'building') return fail('not_building', 'Nothing to peek at right now.');
if (role !== architectOf(game, s.pattern)) return fail('wrong_role', 'Only the Architect can peek.');
if (peeking(s, ctx.now)) return fail('peeking', 'You are already peeking.');
if (s.peeksUsed >= game.patterns[s.pattern].peeks) return fail('no_peeks', 'No peeks left for this picture.');
s.peeksUsed++;
s.peekUntil = ctx.now + PEEK_MS;
return OK;
}
function score(game: BlueprintGame, ctx: Ctx, s: BlueprintState, p: Pattern) {
const value = overlapScore(p.rows, s.canvas);
s.submits++;
s.lastScore = value;
s.bestScore = Math.max(s.bestScore ?? 0, value);
const passed = s.bestScore >= p.threshold;
if (passed || s.submits >= game.blueprint.submitsPerPattern) {
s.status = 'scored';
s.passed = passed;
s.peekUntil = null;
if (passed) ctx.addKey(p.keyName);
} else {
ctx.room.wrongGuesses++;
ctx.toast('all', `${Math.round(value * 100)}%. You need ${Math.round(p.threshold * 100)}%. One more try!`);
}
}
export function submitCanvas(game: BlueprintGame, ctx: Ctx, role: Role): ActionResult {
const s = state(ctx);
if (s.status !== 'building') return fail('not_building', 'Nothing to submit right now.');
if (role === architectOf(game, s.pattern)) return fail('wrong_role', 'Only the Builder submits.');
if (tickBlueprint(game, ctx)) return OK;
score(game, ctx, s, game.patterns[s.pattern]);
return OK;
}
/** Starts a picture, or moves on to the next one. */
export function continueBlueprint(game: BlueprintGame, ctx: Ctx): ActionResult {
const s = state(ctx);
if (s.status === 'ready') {
s.status = 'building';
s.startedAt = ctx.now;
return OK;
}
if (s.status === 'building') return fail('busy', 'The clock is running!');
if (s.pattern < game.patterns.length - 1) {
const next = s.pattern + 1;
Object.assign(s, initBlueprint(game), { pattern: next, canvas: blank(game.patterns[next].size) });
return OK;
}
const keys = ctx.room.keys.length;
const total = game.patterns.length;
ctx.finish(
keys === total,
keys === total ? game.successEnding : `**${keys} of ${total} pictures.** A few brushstrokes off. Play again?`,
);
return OK;
}
/** Called on a timer; returns true if the room changed. When the clock runs out, the canvas is scored as it is. */
export function tickBlueprint(game: BlueprintGame, ctx: Ctx): boolean {
const s = state(ctx);
if (!s || s.status !== 'building' || patternRemaining(game, s, ctx.now) > 0) return false;
ctx.toast('all', "⏰ Time's up! Scoring the canvas as it is.");
// Out of time ends the picture even with a submit left.
s.submits = Math.max(s.submits, game.blueprint.submitsPerPattern - 1);
score(game, ctx, s, game.patterns[s.pattern]);
return true;
}
+65 -20
View File
@@ -209,10 +209,17 @@ export function mulberry32(seed: number): () => number {
};
}
export interface WiresModule {
colors: string[];
solved: boolean;
}
export interface DefuseDevice {
serial: string;
batteries: number;
wires?: { colors: string[]; solved: boolean };
wires?: WiresModule;
/** A level that lists `wires` more than once gets one module per extra entry, generated and solved separately. */
moreWires?: WiresModule[];
button?: { label: string; color: string; strip: string; holding: boolean; solved: boolean };
keypad?: { symbols: string[]; pressed: string[]; solved: boolean };
indicators?: { label: string; lit: boolean }[];
@@ -266,7 +273,9 @@ export function generateDevice(game: DefuseGame, levelIndex: number, seed: numbe
for (const id of game.levels[levelIndex].modules) {
if (id === 'wires') {
const count = int(mods.wires.count.min, mods.wires.count.max);
device.wires = { colors: Array.from({ length: count }, () => pick(mods.wires.colors)), solved: false };
const wires = { colors: Array.from({ length: count }, () => pick(mods.wires.colors)), solved: false };
if (device.wires) (device.moreWires ??= []).push(wires);
else device.wires = wires;
} else if (id === 'button') {
device.button = {
label: pick(mods.button.labels),
@@ -307,12 +316,22 @@ export function generateDevice(game: DefuseGame, levelIndex: number, seed: numbe
return device;
}
/**
* Every wire module on the device with the id `defuse:action` uses for it: plain `wires` when
* there is one, `wires-1`, `wires-2`... (shown as "Wires A", "Wires B"...) when there are several.
*/
export function wireModules(d: DefuseDevice): { instance: string; title: string; module: WiresModule }[] {
const all = d.wires ? [d.wires, ...(d.moreWires ?? [])] : [];
if (all.length === 1) return [{ instance: 'wires', title: 'Wires', module: all[0] }];
return all.map((module, i) => ({ instance: `wires-${i + 1}`, title: `Wires ${String.fromCharCode(65 + i)}`, module }));
}
/** A generated device must always be solvable by the manual. */
export function isSolvable(game: DefuseGame, device: DefuseDevice): boolean {
if (!game.levels.some((level) => level.modules.every((id) => device[id] !== undefined))) return false;
if (device.wires) {
const { index } = wireSolution(game.ruleset, edgework(device), device.wires.colors);
if (index < 1 || index > device.wires.colors.length) return false;
for (const { module } of wireModules(device)) {
const { index } = wireSolution(game.ruleset, edgework(device), module.colors);
if (index < 1 || index > module.colors.length) return false;
}
if (device.keypad && !solveKeypad(device.keypad.symbols, game.modules.keypad.columns)) return false;
if (device.passwords && !solvePassword(device.passwords.columns, game.modules.passwords?.words ?? [])) return false;
@@ -332,6 +351,8 @@ export interface DefuseState {
strikes: number;
/** `remaining` game-seconds as of `anchor`; ticks down at `speed` per real second. */
clock: { remaining: number; anchor: number; speed: number } | null;
/** Roles that tapped Ready on the swap card (games that swap roles every level). */
swapReady?: Role[];
}
const newSeed = () => Math.floor(Math.random() * 0xffffffff);
@@ -347,7 +368,11 @@ export const maxStrikes = (game: DefuseGame, level: number): number =>
game.levels[level]?.strikesOverride ?? game.strikes.max;
const deviceModules = (d: DefuseDevice) =>
[d.wires, d.button, d.keypad, d.passwords, d.morse, d.simon].filter((m) => m !== undefined);
[d.wires, ...(d.moreWires ?? []), d.button, d.keypad, d.passwords, d.morse, d.simon].filter((m) => m !== undefined);
/** Who holds the device on a level: A, unless roles swap every level (then B on levels 2, 4...). */
export const technicianOf = (game: DefuseGame, level: number): Role =>
game.swapRolesEachLevel && level % 2 === 1 ? 'B' : 'A';
export function remainingAt(s: DefuseState, now: number): number {
if (!s.clock) return 0;
@@ -391,6 +416,7 @@ function checkCleared(game: DefuseGame, ctx: Ctx, s: DefuseState) {
if (s.level === game.levels.length - 1) {
ctx.finish(
true,
game.successEnding ??
'**Device disarmed.** The ticking stops. Somewhere inside the box, a tiny speaker plays a wedding march.\n\nOne of you had the hands, one of you had the manual. Neither of you could have done it alone. 💣🔑',
);
}
@@ -400,16 +426,8 @@ function checkCleared(game: DefuseGame, ctx: Ctx, s: DefuseState) {
export function continueDefuse(game: DefuseGame, ctx: Ctx): ActionResult {
const s = state(ctx);
if (s.status === 'ready') {
s.device = generateDevice(game, s.level, s.seed);
// Never arm a device the solver can't solve.
for (let i = 0; i < 20 && !isSolvable(game, s.device); i++) {
s.seed = newSeed();
s.device = generateDevice(game, s.level, s.seed);
}
s.strikes = 0;
s.clock = { remaining: game.levels[s.level].timeSeconds, anchor: ctx.now, speed: 1 };
s.status = 'active';
return OK;
if (game.swapRolesEachLevel) return fail('swap_ready', 'You both need to tap Ready on the swap card.');
return startLevel(game, ctx, s);
}
if (s.status === 'active') return fail('busy', 'The clock is running!');
if (s.status === 'cleared') {
@@ -417,7 +435,33 @@ export function continueDefuse(game: DefuseGame, ctx: Ctx): ActionResult {
s.level++;
}
// Retry (same level, no key lost) or next level: fresh seed, back to the briefing.
Object.assign(s, { status: 'ready', failReason: null, seed: newSeed(), device: null, strikes: 0, clock: null });
Object.assign(s, { status: 'ready', failReason: null, seed: newSeed(), device: null, strikes: 0, clock: null, swapReady: [] });
return OK;
}
/** The swap card: once both have tapped Ready, the clock starts. Tapping again takes it back. */
export function levelReady(game: DefuseGame, ctx: Ctx, role: Role): ActionResult {
const s = state(ctx);
if (!game.swapRolesEachLevel) return fail('no_swap', 'Roles stay the same in this game.');
if (s.status !== 'ready') return fail('not_ready', 'The level has already started.');
const ready = new Set(s.swapReady ?? []);
if (ready.has(role)) ready.delete(role);
else ready.add(role);
s.swapReady = [...ready];
return ready.size === 2 ? startLevel(game, ctx, s) : OK;
}
function startLevel(game: DefuseGame, ctx: Ctx, s: DefuseState): ActionResult {
s.device = generateDevice(game, s.level, s.seed);
// Never arm a device the solver can't solve.
for (let i = 0; i < 20 && !isSolvable(game, s.device); i++) {
s.seed = newSeed();
s.device = generateDevice(game, s.level, s.seed);
}
s.strikes = 0;
s.clock = { remaining: game.levels[s.level].timeSeconds, anchor: ctx.now, speed: 1 };
s.status = 'active';
s.swapReady = [];
return OK;
}
@@ -429,7 +473,7 @@ export function defuseAction(
action: DefuseAction,
): ActionResult {
const s = state(ctx);
if (role !== 'A') return fail('wrong_role', 'Only the Technician can touch the device.');
if (role !== technicianOf(game, s.level)) return fail('wrong_role', 'Only the Technician can touch the device.');
if (s.status !== 'active' || !s.device) return fail('not_active', 'The device is not live.');
if (remainingAt(s, ctx.now) <= 0) {
explode(ctx, s, 'time');
@@ -438,8 +482,9 @@ export function defuseAction(
const d = s.device;
const edge = edgework(d);
if (moduleId === 'wires' && d.wires && !d.wires.solved && action.type === 'cut') {
if (action.index === wireSolution(game.ruleset, edge, d.wires.colors).index) d.wires.solved = true;
const wires = wireModules(d).find((w) => w.instance === moduleId)?.module;
if (wires && !wires.solved && action.type === 'cut') {
if (action.index === wireSolution(game.ruleset, edge, wires.colors).index) wires.solved = true;
else strike(game, ctx, s);
} else if (moduleId === 'button' && d.button && !d.button.solved) {
const b = d.button;
+176
View File
@@ -0,0 +1,176 @@
import { normalizeAnswer, otherRole, type DialRoundRecord, type Role } from '@two-keys/shared';
import type { DialGame } from '../content/schemas';
import { OK, fail, type ActionResult, type Ctx } from './types';
export interface DialState {
/** 0-based. */
round: number;
status: 'clue' | 'guess' | 'reveal';
spectrumId: string;
/** SERVER ONLY until the guesser locks in. */
target: number;
clue: string | null;
/** The guesser's live needle. Never shown to the clue-giver. */
needle: number | null;
guess: number | null;
points: number | null;
total: number;
/** Spectra already dealt this session. */
used: string[];
/** Normalized clue words used so far (for `noRepeatWords`). */
usedWords: string[];
history: DialRoundRecord[];
}
const state = (ctx: Ctx) => ctx.room.engine as DialState;
/** A on odd rounds (1, 3, ...), B on even ones. */
export const giverOf = (round: number): Role => (round % 2 === 0 ? 'A' : 'B');
/** Uppercase, accents and punctuation gone, one trailing S dropped. */
export function normalizeClueWord(word: string): string {
const w = normalizeAnswer(word);
return w.length > 1 ? w.replace(/S$/, '') : w;
}
/** The words of a label, so "Old-fashioned" bans OLD, FASHIONED and OLDFASHIONED. */
export function labelWords(label: string): string[] {
const parts = label.split(/[\s\-–—/]+/).map(normalizeClueWord);
return [...new Set([...parts, normalizeClueWord(label.replace(/\s+/g, ''))])].filter(Boolean);
}
export function pointsFor(game: DialGame, distance: number): number {
return game.dial.scoring.find((b) => distance <= b.within)?.points ?? 0;
}
export const maxTotal = (game: DialGame): number =>
Math.max(...game.dial.scoring.map((b) => b.points)) * game.dial.rounds;
export const spectrumOf = (game: DialGame, s: DialState) => game.spectra.find((x) => x.id === s.spectrumId)!;
function deal(game: DialGame, s: DialState, random: () => number) {
const fresh = game.spectra.filter((x) => !s.used.includes(x.id));
const pool = fresh.length > 0 ? fresh : game.spectra;
const spectrum = pool[Math.floor(random() * pool.length)];
const [lo, hi] = game.dial.range;
const margin = game.dial.targetMargin;
s.spectrumId = spectrum.id;
s.used.push(spectrum.id);
s.target = lo + margin + Math.floor(random() * (hi - lo - 2 * margin + 1));
s.status = 'clue';
s.clue = null;
s.needle = null;
s.guess = null;
s.points = null;
}
export function initDial(game: DialGame, random: () => number = Math.random): DialState {
const s: DialState = {
round: 0,
status: 'clue',
spectrumId: '',
target: 0,
clue: null,
needle: null,
guess: null,
points: null,
total: 0,
used: [],
usedWords: [],
history: [],
};
deal(game, s, random);
return s;
}
/** Why a clue isn't allowed, or null if it is. */
export function clueProblem(game: DialGame, s: DialState, text: string): string | null {
const rules = game.dial.clue;
const clue = text.trim();
const words = clue.split(/\s+/).filter((w) => normalizeClueWord(w) !== '');
if (words.length === 0) return 'Type a clue first.';
if (clue.length > rules.maxChars) return `Keep it to ${rules.maxChars} characters.`;
if (words.length > rules.maxWords) {
return rules.maxWords === 1 ? 'One word only!' : `Up to ${rules.maxWords} words.`;
}
const normalized = words.map(normalizeClueWord);
if (rules.banLabelWords) {
const { left, right } = spectrumOf(game, s);
const banned = new Set([...labelWords(left), ...labelWords(right)]);
const hit = words.find((_, i) => banned.has(normalized[i]));
if (hit) return `"${hit}" is on the dial. Try another word.`;
}
if (rules.noRepeatWords) {
const hit = words.find((_, i) => s.usedWords.includes(normalized[i]));
if (hit) return `"${hit}" was already used this game.`;
}
return null;
}
export function giveClue(game: DialGame, ctx: Ctx, role: Role, text: string): ActionResult {
const s = state(ctx);
if (s.status !== 'clue') return fail('not_open', 'The clue is already in.');
if (role !== giverOf(s.round)) return fail('wrong_role', "It's your partner's turn to give the clue.");
// The rejection reaches the clue-giver as a friendly toast.
const problem = clueProblem(game, s, text);
if (problem) return fail('bad_clue', problem);
s.clue = text.trim().replace(/\s+/g, ' ');
if (game.dial.clue.noRepeatWords) s.usedWords.push(...s.clue.split(' ').map(normalizeClueWord));
s.status = 'guess';
return OK;
}
const clampDial = (game: DialGame, value: number) => {
const [lo, hi] = game.dial.range;
return Math.min(hi, Math.max(lo, Math.round(value)));
};
/** The guesser's live needle: kept for a refresh, never broadcast. */
export function moveNeedle(game: DialGame, ctx: Ctx, role: Role, value: number): ActionResult {
const s = state(ctx);
if (s.status !== 'guess' || role === giverOf(s.round)) return fail('not_open', 'Not your dial right now.');
s.needle = clampDial(game, value);
return OK;
}
export function lockGuess(game: DialGame, ctx: Ctx, role: Role, value: number): ActionResult {
const s = state(ctx);
if (s.status !== 'guess') return fail('not_open', 'Nothing to lock in yet.');
if (role === giverOf(s.round)) return fail('wrong_role', 'Only the guesser turns the dial.');
const guess = clampDial(game, value);
const distance = Math.abs(guess - s.target);
const points = pointsFor(game, distance);
const before = s.total;
s.guess = guess;
s.needle = guess;
s.points = points;
s.total += points;
s.status = 'reveal';
// Keys land the moment the running total reaches each threshold.
for (const key of game.dial.keys) if (before < key.at && s.total >= key.at) ctx.addKey(key.name);
const { left, right } = spectrumOf(game, s);
s.history.push({ left, right, clue: s.clue ?? '', giver: giverOf(s.round), target: s.target, guess, points });
return OK;
}
export function continueDial(game: DialGame, ctx: Ctx, random: () => number = Math.random): ActionResult {
const s = state(ctx);
if (s.status !== 'reveal') return fail('not_done', 'Finish this round first.');
if (s.round < game.dial.rounds - 1) {
s.round++;
deal(game, s, random);
return OK;
}
const keys = ctx.room.keys.length;
const total = game.dial.keys.length;
ctx.finish(
keys === total,
keys === total
? game.successEnding
: `**${s.total} points, ${keys} of ${total} keys.** ${keys > 0 ? 'Nearly on the same wavelength.' : 'Static on the line this time.'} Play again?`,
);
return OK;
}
/** The partner of the clue-giver this round. */
export const guesserOf = (round: number): Role => otherRole(giverOf(round));
+56 -2
View File
@@ -1,8 +1,11 @@
import type { Game } from '../content/schemas';
import { continueDefuse, initDefuse } from './defuse';
import { continueBlueprint, initBlueprint, tickBlueprint } from './blueprint';
import { continueDefuse, initDefuse, tickDefuse } from './defuse';
import { continueDial, initDial } from './dial';
import { initLabyrinth } from './labyrinth';
import { continueChapter, initStory } from './story';
import { continueChapter, initStory, tickStory } from './story';
import { continueSync, initSync } from './sync';
import { continueTwin, initTwin, tickScene } from './twin';
import type { ActionResult, Ctx } from './types';
export function initEngine(game: Game): unknown {
@@ -15,6 +18,12 @@ export function initEngine(game: Game): unknown {
return initSync(game);
case 'labyrinth':
return initLabyrinth(game);
case 'dial':
return initDial(game);
case 'twin':
return initTwin();
case 'blueprint':
return initBlueprint(game);
}
}
@@ -28,6 +37,12 @@ export function totalKeys(game: Game): number {
return game.rounds.length;
case 'labyrinth':
return game.lanterns.length;
case 'dial':
return game.dial.keys.length;
case 'twin':
return game.scenes.length;
case 'blueprint':
return game.patterns.length;
}
}
@@ -42,5 +57,44 @@ export function continueGame(game: Game, ctx: Ctx): ActionResult {
return continueSync(game, ctx);
case 'labyrinth':
return { ok: false, code: 'no_stages', message: 'Nothing to continue.' };
case 'dial':
return continueDial(game, ctx);
case 'twin':
return continueTwin(game, ctx);
case 'blueprint':
return continueBlueprint(game, ctx);
}
}
/** The hard limit on elapsed + penalty time, for the games that have one. */
export function timeLimitSeconds(game: Game): number | null {
if (game.engine !== 'story' && game.engine !== 'dial' && game.engine !== 'twin' && game.engine !== 'blueprint') return null;
const minutes = game.rules.timeLimitMinutes;
return minutes ? Math.round(minutes * 60) : null;
}
export function timeLimitLabel(game: Game): string | null {
if (game.engine !== 'story' && game.engine !== 'dial' && game.engine !== 'twin' && game.engine !== 'blueprint') return null;
return timeLimitSeconds(game) === null ? null : (game.rules.timeLimitLabel ?? null);
}
/** Whether a room of this game has clocks that can run out with nobody touching anything. */
export function hasClock(game: Game): boolean {
return timeLimitSeconds(game) !== null || game.engine === 'defuse' || game.engine === 'twin' || game.engine === 'blueprint';
}
/** Called on a timer; returns true if the room changed. */
export function tickGame(game: Game, ctx: Ctx): boolean {
const { room } = ctx;
if (room.phase !== 'playing') return false;
if (game.engine === 'story') return tickStory(game, ctx);
const limit = timeLimitSeconds(game);
if (limit !== null && room.startedAt !== null && (ctx.now - room.startedAt) / 1000 + room.penaltySeconds >= limit) {
ctx.finish(false, "**Time's up.** The clock beat you this time. Run it back?", null, true);
return true;
}
if (game.engine === 'defuse') return tickDefuse(ctx);
if (game.engine === 'twin') return tickScene(game, ctx);
if (game.engine === 'blueprint') return tickBlueprint(game, ctx);
return false;
}
+50 -7
View File
@@ -75,15 +75,26 @@ export function analyseMaze(
grid: string[],
gates: readonly Gate[],
lanterns: readonly Point[],
): { winnable: boolean; stuck: Point[] } {
items: readonly (Point & { id: string })[] = [],
doors: readonly (Point & { needs: string })[] = [],
): { winnable: boolean; stuck: Point[]; safeCells: Set<string> } {
const [start] = findCells(grid, 'S');
if (!start) return { winnable: false, stuck: [] };
if (!start) return { winnable: false, stuck: [], safeCells: new Set() };
// One bit per lantern, then one per item.
const full = (1 << lanterns.length) - 1;
const pickups: Point[] = [...lanterns, ...items];
const itemBit = (itemId: string) => {
const i = items.findIndex((it) => it.id === itemId);
return i < 0 ? 0 : 1 << (lanterns.length + i);
};
const id = (p: Point, mask: number, safe: boolean) => `${p.x},${p.y},${mask},${safe ? 1 : 0}`;
const pickUp = (p: Point, mask: number) => {
const i = lanterns.findIndex((l) => l.x === p.x && l.y === p.y);
const i = pickups.findIndex((l) => l.x === p.x && l.y === p.y);
return i >= 0 ? mask | (1 << i) : mask;
};
/** A door stays shut until its item is carried (an unknown item never opens it). */
const doorShut = (p: Point, mask: number) =>
doors.some((d) => d.x === p.x && d.y === p.y && !(itemBit(d.needs) && mask & itemBit(d.needs)));
// `safe` tracks whether the run so far is trap-free.
type Node = { p: Point; mask: number; safe: boolean };
@@ -96,14 +107,14 @@ export function analyseMaze(
while (queue.length > 0) {
const node = queue.shift()!;
const from = id(node.p, node.mask, node.safe);
if (cellAt(grid, node.p.x, node.p.y) === 'E' && node.mask === full) {
if (cellAt(grid, node.p.x, node.p.y) === 'E' && (node.mask & full) === full) {
goals.push(from);
continue;
}
for (const dir of CLOCKWISE) {
const step = { x: node.p.x + DELTAS[dir].x, y: node.p.y + DELTAS[dir].y };
const c = cellAt(grid, step.x, step.y);
if (c === '#' || gateBlocks(gates, node.p.x, node.p.y, dir)) continue;
if (c === '#' || gateBlocks(gates, node.p.x, node.p.y, dir) || doorShut(step, node.mask)) continue;
const next: Node =
c === 'T'
? { p: start, mask: node.mask, safe: false }
@@ -129,8 +140,12 @@ export function analyseMaze(
}
const stuck = new Map<string, Point>();
for (const [nodeId, node] of seen) if (!canFinish.has(nodeId)) stuck.set(key(node.p), node.p);
return { winnable: goals.some((g) => g.endsWith(',1')), stuck: [...stuck.values()] };
const safeCells = new Set<string>();
for (const [nodeId, node] of seen) {
if (!canFinish.has(nodeId)) stuck.set(key(node.p), node.p);
if (node.safe) safeCells.add(key(node.p));
}
return { winnable: goals.some((g) => g.endsWith(',1')), stuck: [...stuck.values()], safeCells };
}
export interface LabyrinthState {
@@ -142,6 +157,10 @@ export interface LabyrinthState {
moves: number;
falls: number;
message: string;
/** Item ids carried. Kept after traps. */
inventory?: string[];
/** Doors passed at least once, as "x,y" (their openText shows only the first time). */
opened?: string[];
}
export function initLabyrinth(game: LabyrinthGame): LabyrinthState {
@@ -153,9 +172,13 @@ export function initLabyrinth(game: LabyrinthGame): LabyrinthState {
moves: 0,
falls: 0,
message: '',
inventory: [],
opened: [],
};
}
export const doorAt = (game: LabyrinthGame, x: number, y: number) => game.doors.find((d) => d.x === x && d.y === y);
export const facingOf = (game: LabyrinthGame, s: LabyrinthState): Dir => s.facing ?? game.explorer.startFacing;
export function moveExplorer(game: LabyrinthGame, ctx: Ctx, role: Role, move: MoveDir): ActionResult {
@@ -172,6 +195,14 @@ export function moveExplorer(game: LabyrinthGame, ctx: Ctx, role: Role, move: Mo
if (isWall(game.grid, next.x, next.y)) return fail('wall', "Solid stone. You can't go that way.");
if (gateBlocks(game.oneWay, s.x, s.y, dir)) return fail('gate', "The gate won't open from this side.");
const inventory = (s.inventory ??= []);
const door = doorAt(game, next.x, next.y);
if (door && !inventory.includes(door.needs)) {
// The Explorer only finds out by trying: they stay put and hear why.
s.message = door.lockedText;
return OK;
}
const flavour = game.explorerView.flavour;
const throughGate = game.oneWay.some((g) => g.x === s.x && g.y === s.y && g.dir === dir);
s.moves++;
@@ -180,6 +211,18 @@ export function moveExplorer(game: LabyrinthGame, ctx: Ctx, role: Role, move: Mo
s.facing = dir;
s.message = throughGate ? (flavour.gate ?? "A gate swings shut behind you. It won't open from this side.") : '';
const opened = (s.opened ??= []);
if (door && !opened.includes(`${door.x},${door.y}`)) {
opened.push(`${door.x},${door.y}`);
s.message = door.openText;
}
// Items are picked up on entry, without telling the Navigator.
const item = game.items.find((it) => it.x === next.x && it.y === next.y);
if (item && !inventory.includes(item.id)) {
inventory.push(item.id);
s.message = item.explorerText;
}
const cell = cellAt(game.grid, next.x, next.y);
if (cell === 'T') {
const [start] = findCells(game.grid, 'S');
+2 -1
View File
@@ -176,7 +176,8 @@ export function continueSync(game: SyncGame, ctx: Ctx): ActionResult {
const total = game.rounds.length;
const ending =
keys === total
? `**${keys} out of ${total} keys.** You two are frighteningly in sync. Finishing each other's sentences is next. 💞🔑`
? (game.successEnding ??
`**${keys} out of ${total} keys.** You two are frighteningly in sync. Finishing each other's sentences is next. 💞🔑`)
: keys > 0
? `**${keys} out of ${total} keys.** In sync where it counts, with a few surprises left to discover. That's the fun part. 💞`
: `**No keys this time.** Which just means you have a lot of excellent conversations ahead of you. Rematch? 😏`;
+144
View File
@@ -0,0 +1,144 @@
import { otherRole, type Role } from '@two-keys/shared';
import type { TwinGame } from '../content/schemas';
import { OK, fail, type ActionResult, type Ctx } from './types';
type Scene = TwinGame['scenes'][number];
export interface TwinTap {
/** In A's coordinates (B's taps are un-mirrored first). */
x: number;
y: number;
at: number;
}
export interface TwinState {
scene: number;
status: 'ready' | 'playing' | 'won' | 'failed';
/** 0 on the first try, 1 on the retry. */
attempt: number;
startedAt: number | null;
/** Seconds taken off this scene's clock by wrong claims. */
scenePenalty: number;
/** Indexes into the scene's `changes`. */
found: number[];
taps: Partial<Record<Role, TwinTap>>;
}
const state = (ctx: Ctx) => ctx.room.engine as TwinState;
export function initTwin(): TwinState {
return { scene: 0, status: 'ready', attempt: 0, startedAt: null, scenePenalty: 0, found: [], taps: {} };
}
/** A row's cells. Emoji can be several code points (🐈‍⬛), so rows split on spaces, never by character. */
export const cellsOf = (row: string): string[] => row.split(' ');
export const gridA = (scene: Scene): string[][] => scene.grid.map(cellsOf);
/** B's room: A's with the changes applied. */
export function gridB(scene: Scene): string[][] {
const grid = gridA(scene);
for (const c of scene.changes) grid[c.y][c.x] = c.to;
return grid;
}
export const widthOf = (scene: Scene): number => cellsOf(scene.grid[0]).length;
/** Mirrored rooms flip x for B, both ways. */
export const mirrored = (game: TwinGame, role: Role) => role === 'B' && game.twin.mirrorB;
export const flipX = (game: TwinGame, scene: Scene, role: Role, x: number) =>
mirrored(game, role) ? widthOf(scene) - 1 - x : x;
export function sceneRemaining(game: TwinGame, s: TwinState, now: number): number {
const scene = game.scenes[s.scene];
if (s.startedAt === null) return scene.timeSeconds;
return scene.timeSeconds - (now - s.startedAt) / 1000 - s.scenePenalty;
}
/** A tap still counts while it's inside the claim window. */
export const liveTap = (game: TwinGame, tap: TwinTap | undefined, now: number): TwinTap | null =>
tap && now - tap.at <= game.twin.claimWindowSeconds * 1000 ? tap : null;
export const canRetry = (s: TwinState) => s.status === 'failed' && s.attempt === 0;
function startScene(s: TwinState, now: number) {
Object.assign(s, { status: 'playing', startedAt: now, scenePenalty: 0, found: [], taps: {} });
}
export function tapCell(game: TwinGame, ctx: Ctx, role: Role, x: number, y: number): ActionResult {
const s = state(ctx);
if (s.status !== 'playing') return fail('not_playing', 'The room is not open right now.');
if (tickScene(game, ctx)) return OK; // time ran out first
const scene = game.scenes[s.scene];
const width = widthOf(scene);
if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= width || y >= scene.grid.length) {
return fail('bad_cell', 'That square is off the grid.');
}
const ax = flipX(game, scene, role, x);
const change = scene.changes.findIndex((c) => c.x === ax && c.y === y);
if (change >= 0 && s.found.includes(change)) return fail('found', 'You already found that one.');
const partner = otherRole(role);
const theirs = liveTap(game, s.taps[partner], ctx.now);
if (theirs && theirs.x === ax && theirs.y === y) {
// Both tapped the same square in time: claim it.
s.taps = {};
if (change >= 0) {
s.found.push(change);
ctx.toast('all', `✅ Difference found! ${s.found.length} / ${scene.changes.length}`);
if (s.found.length === scene.changes.length) {
s.status = 'won';
ctx.addKey(scene.keyName);
}
} else {
const penalty = game.twin.wrongClaimPenaltySeconds;
s.scenePenalty += penalty;
ctx.addPenalty(penalty);
ctx.room.wrongGuesses++;
ctx.toast('all', `❌ No difference there. −${penalty}s`);
tickScene(game, ctx);
}
return OK;
}
// A different square (or none): the earlier tap expires, this one waits for the partner.
delete s.taps[partner];
s.taps[role] = { x: ax, y, at: ctx.now };
ctx.toast(partner, 'Your partner tapped a square.');
return OK;
}
/** Starts a scene, retries a failed one, or moves on. */
export function continueTwin(game: TwinGame, ctx: Ctx): ActionResult {
const s = state(ctx);
if (s.status === 'ready') {
startScene(s, ctx.now);
return OK;
}
if (s.status === 'playing') return fail('busy', 'The clock is running!');
if (canRetry(s)) {
s.attempt = 1;
startScene(s, ctx.now);
return OK;
}
if (s.scene < game.scenes.length - 1) {
Object.assign(s, { scene: s.scene + 1, status: 'ready', attempt: 0, startedAt: null, scenePenalty: 0, found: [], taps: {} });
return OK;
}
const keys = ctx.room.keys.length;
const total = game.scenes.length;
ctx.finish(
keys === total,
keys === total ? game.successEnding : `**${keys} of ${total} rooms solved.** So close. The differences are still out there. Play again?`,
);
return OK;
}
/** Called on a timer and before each tap; returns true if the scene clock ran out. */
export function tickScene(game: TwinGame, ctx: Ctx): boolean {
const s = state(ctx);
if (!s || s.status !== 'playing' || sceneRemaining(game, s, ctx.now) > 0) return false;
s.status = 'failed';
s.taps = {};
ctx.toast('all', "⏰ Time's up for this room.");
return true;
}
+3 -4
View File
@@ -1,8 +1,7 @@
import { rankFor, type GameResult, type Role, type RoomState } from '@two-keys/shared';
import type { Game } from '../content/schemas';
import { engineView, gameMeta } from '../content/sanitize';
import { totalKeys } from '../engines/index';
import { timeLimitSeconds } from '../engines/story';
import { timeLimitLabel, timeLimitSeconds, totalKeys } from '../engines/index';
import type { PlayerRec, Room } from './store';
export function computeResult(room: Room, game: Game): GameResult | null {
@@ -75,8 +74,8 @@ export function buildState(
finishedAt: room.finishedAt,
penaltySeconds: room.penaltySeconds,
serverNow: now,
limitSeconds: game.engine === 'story' ? timeLimitSeconds(game) : null,
limitLabel: game.engine === 'story' ? (game.rules.timeLimitLabel ?? null) : null,
limitSeconds: timeLimitSeconds(game),
limitLabel: timeLimitLabel(game),
},
stats: { hintsUsed: room.hintsUsed, wrongGuesses: room.wrongGuesses },
views,
+3 -1
View File
@@ -1,11 +1,13 @@
import { loadAllFiles } from '../content/loader';
import { describeGame } from '../content/validate';
const files = loadAllFiles();
let failed = 0;
for (const { file, game, errors } of files) {
if (errors.length === 0 && game) {
console.log(`✔ ${file} (${game.engine})`);
const head = `${game.difficulty}★ ${String(game.estimatedMinutes).padStart(2)}m ${game.engine.padEnd(9)}`;
console.log(`✔ ${head} ${file.padEnd(32)} ${describeGame(game)}`);
} else {
failed++;
console.error(`✖ ${file}`);
+63 -10
View File
@@ -12,8 +12,10 @@ import {
type Role,
} from '@two-keys/shared';
import type { Game } from '../content/schemas';
import { defuseAction, tickDefuse } from '../engines/defuse';
import { continueGame, initEngine } from '../engines/index';
import { paint, peek, submitCanvas } from '../engines/blueprint';
import { defuseAction, levelReady } from '../engines/defuse';
import { giveClue, lockGuess, moveNeedle } from '../engines/dial';
import { continueGame, hasClock, initEngine, tickGame } from '../engines/index';
import { moveExplorer } from '../engines/labyrinth';
import {
agreeAccusation,
@@ -21,10 +23,10 @@ import {
requestHint,
submitAccusation,
submitLock,
tickStory,
togglePin,
} from '../engines/story';
import { TELEPATHY_MAX_LENGTH, answerSync } from '../engines/sync';
import { tapCell } from '../engines/twin';
import { createCtx, fail, type ActionResult, type Ctx } from '../engines/types';
import { cleanCode } from '../rooms/codes';
import { buildState } from '../rooms/state';
@@ -44,7 +46,8 @@ const schemas = {
pin: z.object({ clueId: z.string().max(100), ...asRole }),
propose: z.object({ answers: z.record(z.string()), ...asRole }),
defuse: z.object({
moduleId: z.enum(DEFUSE_MODULES),
// `wires-1`, `wires-2`... when a level has more than one of a module.
moduleId: z.string().regex(new RegExp(`^(${DEFUSE_MODULES.join('|')})(-\d)?$`)),
action: z.discriminatedUnion('type', [
z.object({ type: z.literal('cut'), index: z.number().int() }),
z.object({ type: z.literal('tap') }),
@@ -70,6 +73,16 @@ const schemas = {
.transform((d): MoveDir => (d.length > 1 ? (d[0] as RelDir) : (d as MoveDir))),
...asRole,
}),
dialClue: z.object({ text: z.string().max(200), ...asRole }),
dialValue: z.object({ value: z.number().finite(), ...asRole }),
twinTap: z.object({ x: z.number().int(), y: z.number().int(), ...asRole }),
paint: z.object({
cells: z
.array(z.object({ x: z.number().int(), y: z.number().int(), color: z.string().max(2).nullable() }))
.min(1)
.max(256),
...asRole,
}),
notepad: z.object({ text: z.string() }),
ping: z.object({ emoji: z.enum(PING_EMOJIS), ...asRole }),
view: z.object({ label: z.string().max(80) }),
@@ -133,7 +146,8 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
schema: S,
phase: Phase | null,
handler: (args: ActionArgs<z.infer<S>>) => ActionResult | void,
opts: { echo?: boolean } = {},
/** `echo: false` skips the sender; `silent` tells nobody (state that no one else may see). */
opts: { echo?: boolean; silent?: boolean } = {},
) {
socket.on(event, (payload: unknown) => {
const parsed = schema.safeParse(payload ?? {});
@@ -152,7 +166,7 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
room.lastActivity = ctx.now;
flushToasts(room, ctx);
broadcast(room, opts.echo === false ? socket : undefined);
if (!opts.silent) broadcast(room, opts.echo === false ? socket : undefined);
});
}
@@ -270,6 +284,41 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
game.engine === 'labyrinth' ? moveExplorer(game, ctx, role, data.dir) : fail('wrong_engine', 'No maze here.'),
);
on('level:ready', empty, 'playing', ({ game, role, ctx }) =>
game.engine === 'defuse' ? levelReady(game, ctx, role) : fail('wrong_engine', 'Nothing to get ready for.'),
);
const noDial = fail('wrong_engine', 'No dial here.');
on('dial:clue', schemas.dialClue, 'playing', ({ game, data, role, ctx }) =>
game.engine === 'dial' ? giveClue(game, ctx, role, data.text) : noDial,
);
// Throttled by the client, kept for a refresh, and never broadcast: the clue-giver mustn't see it move.
on(
'dial:needle',
schemas.dialValue,
'playing',
({ game, data, role, ctx }) => (game.engine === 'dial' ? moveNeedle(game, ctx, role, data.value) : noDial),
{ silent: true },
);
on('dial:lock', schemas.dialValue, 'playing', ({ game, data, role, ctx }) =>
game.engine === 'dial' ? lockGuess(game, ctx, role, data.value) : noDial,
);
on('twin:tap', schemas.twinTap, 'playing', ({ game, data, role, ctx }) =>
game.engine === 'twin' ? tapCell(game, ctx, role, data.x, data.y) : fail('wrong_engine', 'No rooms here.'),
);
const noCanvas = fail('wrong_engine', 'No canvas here.');
on('blueprint:paint', schemas.paint, 'playing', ({ game, data, role, ctx }) =>
game.engine === 'blueprint' ? paint(game, ctx, role, data.cells) : noCanvas,
);
on('blueprint:peek', empty, 'playing', ({ game, role, ctx }) =>
game.engine === 'blueprint' ? peek(game, ctx, role) : noCanvas,
);
on('blueprint:submit', empty, 'playing', ({ game, role, ctx }) =>
game.engine === 'blueprint' ? submitCanvas(game, ctx, role) : noCanvas,
);
// ----- presence -----
on(
@@ -301,14 +350,18 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
});
});
// A defuse clock or a story time limit can run out with nobody touching anything.
// Clocks (a defuse level, a twin room, a blueprint picture, a game's time limit) can run out
// with nobody touching anything. A peek ending also needs a fresh state for the Architect.
const peekEnds = new Map<string, number>();
const ticker = setInterval(() => {
for (const room of store.rooms.values()) {
const game = games.get(room.gameId);
if (room.phase !== 'playing' || !game) continue;
if (game.engine !== 'defuse' && !(game.engine === 'story' && game.rules.timeLimitMinutes)) continue;
if (room.phase !== 'playing' || !game || !hasClock(game)) continue;
const ctx = createCtx(room);
if (game.engine === 'defuse' ? tickDefuse(ctx) : tickStory(game, ctx)) {
const peekUntil = game.engine === 'blueprint' ? (room.engine as { peekUntil: number | null }).peekUntil : null;
const peekOver = peekUntil !== null && ctx.now >= peekUntil && peekEnds.get(room.code) !== peekUntil;
if (peekOver) peekEnds.set(room.code, peekUntil);
if (tickGame(game, ctx) || peekOver) {
flushToasts(room, ctx);
broadcast(room);
}
+11 -7
View File
@@ -7,16 +7,20 @@ import { ROOM_TTL_MS, RoomStore } from '../src/rooms/store';
import { games, getGame, labyrinth, sync } from './helpers';
describe('content', () => {
it('loads all 18 games with no validation errors', () => {
it('loads all 40 games with no validation errors', () => {
const files = loadAllFiles();
expect(files.flatMap((f) => f.errors.map((e) => `${f.file}: ${e}`))).toEqual([]);
expect(files).toHaveLength(18);
expect(files).toHaveLength(40);
const engines = [...games.values()].map((g) => g.engine);
expect(Object.fromEntries(['story', 'defuse', 'sync', 'labyrinth'].map((e) => [e, engines.filter((x) => x === e).length]))).toEqual({
story: 11,
defuse: 2,
sync: 2,
labyrinth: 3,
const ids = ['story', 'defuse', 'sync', 'labyrinth', 'dial', 'twin', 'blueprint'];
expect(Object.fromEntries(ids.map((e) => [e, engines.filter((x) => x === e).length]))).toEqual({
story: 21,
defuse: 3,
sync: 3,
labyrinth: 4,
dial: 3,
twin: 3,
blueprint: 3,
});
});
+24
View File
@@ -191,6 +191,13 @@ describe('results', () => {
expect(stateOf(room, 'A').result).toMatchObject({ success: false, outOfTime: true, rank: 'Out of time', nightmareSurvivors: false });
});
it('tells the hint sheet when hints come off a countdown', () => {
const timed = getGame('kestrel-heist', 'story');
const untimed = getGame('alchemists-vault', 'story');
expect(stateOf(playingRoom(timed), 'A').views.A).toMatchObject({ chapters: [{ lock: { timeLimited: true } }] });
expect(stateOf(playingRoom(untimed), 'A').views.A).toMatchObject({ chapters: [{ lock: { timeLimited: false } }] });
});
it('a difficulty-5 game with zero hints earns Nightmare Survivors', () => {
const game = getGame('kestrel-heist', 'story');
expect(game.difficulty).toBe(5);
@@ -240,6 +247,23 @@ describe('content validation additions', () => {
expect(validateGame(game).join('\n')).toMatch(/exactly 4 items/);
});
it('keeps options that contain commas whole', () => {
const deep = getGame('in-sync-deep-cuts', 'sync');
expect(deep.predict.find((q) => q.id === 'd12')!.options).toContain('A full, noisy home');
expect(deep.predict.find((q) => q.id === 'd15')!.options).toEqual(['An instrument', 'A language', 'A sport', 'A craft (woodwork, pottery, etc.)']);
const masq = getGame('masquerade-montague', 'story');
if (masq.finale.type !== 'accusation') throw new Error('expected accusation');
expect(masq.finale.questions.flatMap((q) => q.options.map((o) => o.label))).toContain(
'Stabbed with the armoury dagger, then buried it in a dry cactus pot',
);
});
it('rejects a sync option split by an unquoted comma', () => {
const game = clone(getGame('in-sync-deep-cuts', 'sync'));
game.predict[0].options = ['A craft (woodwork', 'pottery', 'etc.)', 'A sport'];
expect(validateGame(game).join('\n')).toMatch(/looks split by an unquoted comma/);
});
it("rejects Overtime tables that don't match the solver's", () => {
const game = clone(getGame('tick-tock-overtime', 'defuse'));
game.modules.passwords!.words[0] = 'ZEBRA';
+689
View File
@@ -0,0 +1,689 @@
// GAMES-PACK-3.md: the dial, twin and blueprint engines, labyrinth items and doors, defuse
// `manualFrom` and role swaps, and sanitized payloads for all 22 new games.
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { io as connect } from 'socket.io-client';
import type {
BlueprintView,
DialView,
JoinAck,
NavigatorView,
ExplorerView,
Role,
RoomState,
TechnicianView,
TwinView,
} from '@two-keys/shared';
import { createApp } from '../src/app';
import { loadGameFile } from '../src/content/loader';
import type { Game } from '../src/content/schemas';
import { validateGame } from '../src/content/validate';
import {
architectOf,
continueBlueprint,
displayedToStored,
overlapScore,
paint,
peek,
submitCanvas,
type BlueprintState,
} from '../src/engines/blueprint';
import {
continueDefuse,
defuseAction,
levelReady,
wireModules,
wireSolution,
edgework,
type DefuseState,
} from '../src/engines/defuse';
import {
clueProblem,
continueDial,
giveClue,
giverOf,
initDial,
lockGuess,
moveNeedle,
pointsFor,
type DialState,
} from '../src/engines/dial';
import { tickGame } from '../src/engines/index';
import { moveExplorer, type LabyrinthState } from '../src/engines/labyrinth';
import { answerSync, continueSync, type SyncState } from '../src/engines/sync';
import { continueTwin, gridA, gridB, tapCell, widthOf, type TwinState } from '../src/engines/twin';
import { buildState } from '../src/rooms/state';
import type { Room } from '../src/rooms/store';
import { clock, games, getGame, playingRoom } from './helpers';
const PACK_3 = [
'the-treehouse',
'twin-sunday-morning',
'blueprint-first-sketch',
'wavelength-tune-in',
'death-at-the-braai',
'in-sync-road-trip',
'wavelength-date-night',
'twin-cafes',
'curtain-call',
'magicians-dressing-room',
'blueprint-stained-glass',
'hedge-maze-midnight',
'snowbound-edelweiss',
'pharaohs-tomb',
'twin-looking-glass',
'tick-tock-switch',
'hawthorn-park-cipher',
'inventors-paradox',
'blueprint-blind-mirror',
'wavelength-static',
'til-death-do-us-part',
'the-lighthouse',
];
const FORBIDDEN_KEYS = ['answers', 'answer', 'solution', 'fixtures', 'changes', 'expect', 'seed', 'rows', 'taps'];
const stateOf = (room: Room, role: Role, now = 2_000_000) => {
const player = room.players.find((p) => p.seat === role)!;
return buildState(room, player, games.get(room.gameId)!, () => true, now);
};
const viewOf = <V>(room: Room, role: Role, now?: number) => stateOf(room, role, now).views[role] as V;
function keysOf(value: unknown, out = new Set<string>()): Set<string> {
if (value && typeof value === 'object') {
for (const [k, v] of Object.entries(value)) {
if (!Array.isArray(value)) out.add(k);
keysOf(v, out);
}
}
return out;
}
/** Moves a game a little way in, so there is real state to leak. */
function started(game: Game) {
const room = playingRoom(game);
const t = clock(room);
if (game.engine === 'defuse') {
levelReady(game, t.ctx(), 'A');
levelReady(game, t.ctx(), 'B');
continueDefuse(game, t.ctx());
} else if (game.engine === 'sync') {
continueSync(game, t.ctx());
} else if (game.engine === 'twin') {
continueTwin(game, t.ctx());
tapCell(game, t.ctx(), 'A', 0, 0);
} else if (game.engine === 'blueprint') {
continueBlueprint(game, t.ctx());
paint(game, t.ctx(), 'B', [{ x: 0, y: 0, color: Object.keys(game.patterns[0].palette)[0] }]);
} else if (game.engine === 'dial') {
giveClue(game, t.ctx(), 'A', 'zebra');
moveNeedle(game, t.ctx(), 'B', 42);
}
return { room, t };
}
describe('pack 3 content', () => {
it('has all 22 games', () => {
for (const id of PACK_3) expect(games.get(id), id).toBeDefined();
});
});
describe.each(PACK_3.map((id) => [id, games.get(id)!] as const))('sanitized payloads: %s', (_id, game) => {
it('never carry answers, fixtures, changes, solver state or hidden pictures for either role', () => {
const { room, t } = started(game);
for (const role of ['A', 'B'] as const) {
const keys = keysOf(stateOf(room, role, t.now));
for (const key of FORBIDDEN_KEYS) expect(keys, `${role} got "${key}"`).not.toContain(key);
}
});
});
// ---------- dial ----------
describe('dial: Wavelength', () => {
const tuneIn = getGame('wavelength-tune-in', 'dial');
const staticGame = getGame('wavelength-static', 'dial');
it('alternates the clue-giver: A on odd rounds, B on even', () => {
expect([0, 1, 2, 3].map(giverOf)).toEqual(['A', 'B', 'A', 'B']);
});
it('keeps the target from the guesser until they lock in, and the needle from the clue-giver', () => {
const room = playingRoom(tuneIn);
const t = clock(room);
const s = room.engine as DialState;
expect(viewOf<DialView>(room, 'A').target).toBe(s.target);
expect(viewOf<DialView>(room, 'B')).toMatchObject({ target: null, clue: null });
expect(giveClue(tuneIn, t.ctx(), 'B', 'tea').ok).toBe(false); // not B's turn
expect(giveClue(tuneIn, t.ctx(), 'A', 'a warm bath').ok).toBe(true);
expect(moveNeedle(tuneIn, t.ctx(), 'B', 61).ok).toBe(true);
expect(viewOf<DialView>(room, 'B')).toMatchObject({ target: null, clue: 'a warm bath', needle: 61 });
const giver = viewOf<DialView>(room, 'A');
expect(giver.needle).toBeNull();
expect(JSON.stringify(giver)).not.toContain('61');
expect(lockGuess(tuneIn, t.ctx(), 'A', 50).ok).toBe(false); // the giver can't guess
expect(lockGuess(tuneIn, t.ctx(), 'B', s.target + 7).ok).toBe(true);
expect(viewOf<DialView>(room, 'B')).toMatchObject({ status: 'reveal', target: s.target, guess: s.target + 7, points: 3 });
});
it('scores by distance: the first band that fits, 0 outside all', () => {
expect([0, 5, 6, 10, 15, 20, 21].map((d) => pointsFor(tuneIn, d))).toEqual([4, 4, 3, 3, 2, 1, 0]);
expect([2, 3, 5, 8, 9].map((d) => pointsFor(staticGame, d))).toEqual([4, 3, 3, 2, 0]);
});
it('checks clues: length, words, label words (with a trailing S) and repeats', () => {
const s = initDial(staticGame, () => 0);
const spectrum = staticGame.spectra.find((x) => x.id === s.spectrumId)!;
const labelWord = spectrum.left.split(/\s+/)[0];
expect(clueProblem(staticGame, s, '')).toMatch(/Type a clue/);
expect(clueProblem(staticGame, s, 'two words')).toMatch(/One word only/);
expect(clueProblem(staticGame, s, 'x'.repeat(31))).toMatch(/30 characters/);
expect(clueProblem(staticGame, s, `${labelWord.toLowerCase()}s`)).toMatch(/is on the dial/);
expect(clueProblem(staticGame, s, 'Zebra!')).toBeNull();
s.usedWords.push('ZEBRA');
expect(clueProblem(staticGame, s, 'zebras')).toMatch(/already used/);
expect(clueProblem(tuneIn, initDial(tuneIn), 'a b c d e f')).toMatch(/Up to 5 words/);
});
it("hides the right label (and the numbers) from Static's guesser", () => {
const room = playingRoom(staticGame);
const s = room.engine as DialState;
const spectrum = staticGame.spectra.find((x) => x.id === s.spectrumId)!;
const guesser = viewOf<DialView>(room, 'B');
expect(guesser).toMatchObject({ left: spectrum.left, right: null, showNumbers: false });
expect(JSON.stringify(guesser)).not.toContain(spectrum.right);
expect(viewOf<DialView>(room, 'A')).toMatchObject({ right: spectrum.right, showNumbers: true });
});
it('never repeats a spectrum, awards keys as the total crosses each threshold, and ends after the last round', () => {
const room = playingRoom(tuneIn);
const t = clock(room);
const s = room.engine as DialState;
for (let round = 0; round < tuneIn.dial.rounds; round++) {
const giver = giverOf(round);
const guesser: Role = giver === 'A' ? 'B' : 'A';
expect(giveClue(tuneIn, t.ctx(), giver, 'zebra').ok).toBe(true);
expect(lockGuess(tuneIn, t.ctx(), guesser, s.target).ok).toBe(true); // bullseye, 4 points
expect(room.keys.length).toBe(tuneIn.dial.keys.filter((k) => s.total >= k.at).length);
expect(continueDial(tuneIn, t.ctx()).ok).toBe(true);
}
expect(new Set(s.used).size).toBe(tuneIn.dial.rounds);
expect(s.total).toBe(24);
expect(room.keys).toEqual(tuneIn.dial.keys.map((k) => k.name));
expect(room.result).toMatchObject({ success: true, ending: tuneIn.successEnding });
});
it('fails without every key', () => {
const room = playingRoom(tuneIn);
const t = clock(room);
const s = room.engine as DialState;
for (let round = 0; round < tuneIn.dial.rounds; round++) {
giveClue(tuneIn, t.ctx(), giverOf(round), 'zebra');
lockGuess(tuneIn, t.ctx(), giverOf(round) === 'A' ? 'B' : 'A', s.target > 50 ? 0 : 100);
continueDial(tuneIn, t.ctx());
}
expect(room.result?.success).toBe(false);
});
it('rejects a too-small spectra bank and keys nobody can reach', () => {
const game = structuredClone(tuneIn);
game.spectra = game.spectra.slice(0, 10);
game.dial.keys[1].at = 99;
const errors = validateGame(game).join('\n');
expect(errors).toMatch(/spectra bank has 10/);
expect(errors).toMatch(/is the most possible/);
});
});
// ---------- twin ----------
describe('twin: Twin Rooms', () => {
const sunday = getGame('twin-sunday-morning', 'twin');
const glass = getGame('twin-looking-glass', 'twin');
function playing(game: typeof sunday) {
const room = playingRoom(game);
const t = clock(room);
expect(continueTwin(game, t.ctx()).ok).toBe(true);
return { room, t, s: room.engine as TwinState };
}
it("gives each player only their own room, never the changes", () => {
const { room } = playing(sunday);
const scene = sunday.scenes[0];
expect(viewOf<TwinView>(room, 'A').grid).toEqual(gridA(scene));
expect(viewOf<TwinView>(room, 'B').grid).toEqual(gridB(scene));
expect(gridA(scene)).not.toEqual(gridB(scene));
});
it('splits rows on spaces, so multi-code-point emoji stay whole', () => {
for (const game of [sunday, glass, getGame('twin-cafes', 'twin')]) {
for (const scene of game.scenes) for (const row of gridA(scene)) expect(row).toHaveLength(widthOf(scene));
}
});
it('claims a difference when both tap the same square in time', () => {
const { room, t, s } = playing(sunday);
const [c] = sunday.scenes[0].changes;
expect(tapCell(sunday, t.ctx(), 'A', c.x, c.y).ok).toBe(true);
// The partner hears that a square was tapped, never which.
const b = viewOf<TwinView>(room, 'B', t.now);
expect(b.partnerTap).not.toBeNull();
expect(b.myTap).toBeNull();
expect(JSON.stringify(b.partnerTap)).not.toMatch(/"x"|"y"/);
tapCell(sunday, t.ctx(), 'B', c.x, c.y);
expect(s.found).toEqual([0]);
expect(viewOf<TwinView>(room, 'A', t.now).found).toEqual([{ x: c.x, y: c.y }]);
expect(tapCell(sunday, t.ctx(), 'A', c.x, c.y).ok).toBe(false); // already found
});
it('a wrong claim costs time; different squares just replace the earlier tap; late taps expire', () => {
const { room, t, s } = playing(sunday);
const scene = sunday.scenes[0];
const isChange = (x: number, y: number) => scene.changes.some((c) => c.x === x && c.y === y);
const plain = gridA(scene).flatMap((row, y) => row.map((_, x) => ({ x, y }))).find((p) => !isChange(p.x, p.y))!;
tapCell(sunday, t.ctx(), 'A', plain.x, plain.y);
tapCell(sunday, t.ctx(), 'B', plain.x, plain.y);
expect(room.penaltySeconds).toBe(sunday.twin.wrongClaimPenaltySeconds);
expect(s.scenePenalty).toBe(sunday.twin.wrongClaimPenaltySeconds);
expect(s.found).toEqual([]);
const [c] = scene.changes;
tapCell(sunday, t.ctx(), 'A', c.x, c.y);
tapCell(sunday, t.ctx(), 'B', plain.x, plain.y); // a different square: A's tap expires
expect(s.taps.A).toBeUndefined();
tapCell(sunday, t.ctx(), 'A', c.x, c.y);
tapCell(sunday, t.ctx((sunday.twin.claimWindowSeconds + 1) * 1000), 'B', c.x, c.y); // too late
expect(s.found).toEqual([]);
});
it('mirror mode: tapping the mirrored square of a difference claims it', () => {
const { room, t, s } = playing(glass);
const scene = glass.scenes[0];
const [c] = scene.changes;
const mirroredX = widthOf(scene) - 1 - c.x;
expect(viewOf<TwinView>(room, 'B').grid).toEqual(gridB(scene).map((row) => [...row].reverse()));
tapCell(glass, t.ctx(), 'A', c.x, c.y);
tapCell(glass, t.ctx(), 'B', mirroredX, c.y);
expect(s.found).toEqual([0]);
expect(viewOf<TwinView>(room, 'B', t.now).found).toEqual([{ x: mirroredX, y: c.y }]);
});
it('earns the key when every difference is found; a timed-out room retries once, then moves on', () => {
const { room, t, s } = playing(sunday);
for (const c of sunday.scenes[0].changes) {
tapCell(sunday, t.ctx(), 'A', c.x, c.y);
tapCell(sunday, t.ctx(), 'B', c.x, c.y);
}
expect(s.status).toBe('won');
expect(room.keys).toEqual([sunday.scenes[0].keyName]);
continueTwin(sunday, t.ctx()); // next room
continueTwin(sunday, t.ctx()); // open it
expect(tickGame(sunday, t.ctx(sunday.scenes[1].timeSeconds * 1000))).toBe(true);
expect(viewOf<TwinView>(room, 'A', t.now)).toMatchObject({ status: 'failed', canRetry: true });
continueTwin(sunday, t.ctx());
expect(s).toMatchObject({ status: 'playing', attempt: 1, found: [] });
tickGame(sunday, t.ctx(sunday.scenes[1].timeSeconds * 1000));
expect(viewOf<TwinView>(room, 'A', t.now).canRetry).toBe(false);
continueTwin(sunday, t.ctx());
expect(s).toMatchObject({ scene: 2, status: 'ready' });
});
it("Looking Glass's 25-minute limit ends the game", () => {
const { room, t } = playing(glass);
expect(tickGame(glass, t.ctx(25 * 60_000))).toBe(true);
expect(stateOf(room, 'A', t.now).result).toMatchObject({ outOfTime: true, rank: 'Out of time' });
});
it('rejects a change that changes nothing, and a ragged row', () => {
const game = structuredClone(sunday);
const c = game.scenes[0].changes[0];
c.to = gridA(game.scenes[0])[c.y][c.x];
game.scenes[1].grid[0] += ' 🍄';
const errors = validateGame(game).join('\n');
expect(errors).toMatch(/doesn't change anything/);
expect(errors).toMatch(/rows don't all have/);
});
});
// ---------- blueprint ----------
describe('blueprint: Describe & Draw', () => {
const sketch = getGame('blueprint-first-sketch', 'blueprint');
const mirror = getGame('blueprint-blind-mirror', 'blueprint');
function building(game: typeof sketch) {
const room = playingRoom(game);
const t = clock(room);
continueBlueprint(game, t.ctx());
return { room, t, s: room.engine as BlueprintState };
}
/** Paints the whole picture as the Builder sees it: displayed cell (c, r) on a rotated canvas. */
function paintAsBuilder(game: typeof sketch, room: Room, t: ReturnType<typeof clock>, role: Role, rows: string[]) {
const n = rows.length;
const cells = [];
for (let r = 0; r < n; r++) {
for (let c = 0; c < n; c++) {
const { x, y } = displayedToStored(n, game.blueprint.rotateBuilder, c, r);
const v = rows[y][x];
cells.push({ x, y, color: v === '.' ? null : v });
}
}
expect(paint(game, t.ctx(), role, cells).ok).toBe(true);
}
it('scores overlap: matched painted cells over cells painted in either grid', () => {
expect(overlapScore(['RR', '..'], ['RR', '..'])).toBe(1);
expect(overlapScore(['RR', '..'], ['..', '..'])).toBe(0); // a blank canvas never scores
expect(overlapScore(['RR', '..'], ['RB', '..'])).toBe(0.5);
expect(overlapScore(['RR', '..'], ['RR', 'R.'])).toBeCloseTo(2 / 3);
expect(overlapScore(['..', '..'], ['..', '..'])).toBe(0);
});
it("keeps the picture from the Builder, and the canvas from the Architect except in a peek", () => {
const { room, t } = building(sketch);
const builder = viewOf<BlueprintView>(room, 'B', t.now);
expect(builder).toMatchObject({ seat: 'builder', target: null, title: null });
expect(JSON.stringify(builder)).not.toContain(sketch.patterns[0].title);
expect(viewOf<BlueprintView>(room, 'A', t.now)).toMatchObject({ seat: 'architect', canvas: null });
expect(peek(sketch, t.ctx(), 'B').ok).toBe(false); // only the Architect peeks
expect(peek(sketch, t.ctx(), 'A').ok).toBe(true);
expect(viewOf<BlueprintView>(room, 'A', t.now).canvas).not.toBeNull();
expect(viewOf<BlueprintView>(room, 'A', t.now + 3500).canvas).toBeNull();
peek(sketch, t.ctx(), 'A');
expect(peek(sketch, t.ctx(), 'A').ok).toBe(false); // two peeks per picture
});
it('rotation: a Builder who paints the rotated picture correctly scores 100%', () => {
expect(mirror.blueprint.rotateBuilder).toBe(90);
// Displayed (c, r) on an n×n grid is stored (x = r, y = n − 1 − c).
expect(displayedToStored(10, 90, 0, 0)).toEqual({ x: 0, y: 9 });
expect(displayedToStored(10, 90, 9, 0)).toEqual({ x: 0, y: 0 });
const { room, t, s } = building(mirror);
paintAsBuilder(mirror, room, t, 'B', mirror.patterns[0].rows);
expect(submitCanvas(mirror, t.ctx(), 'B').ok).toBe(true);
expect(s).toMatchObject({ status: 'scored', bestScore: 1, passed: true });
expect(room.keys).toEqual([mirror.patterns[0].keyName]);
});
it('swaps the Architect every picture when the game says so', () => {
expect([0, 1, 2, 3].map((i) => architectOf(mirror, i))).toEqual(['A', 'B', 'A', 'B']);
expect([0, 1, 2].map((i) => architectOf(sketch, i))).toEqual(['A', 'A', 'A']);
const { room, t } = building(mirror);
submitCanvas(mirror, t.ctx(), 'B');
continueBlueprint(mirror, t.ctx());
continueBlueprint(mirror, t.ctx());
expect(viewOf<BlueprintView>(room, 'A', t.now)).toMatchObject({ seat: 'builder', target: null });
expect(viewOf<BlueprintView>(room, 'B', t.now).seat).toBe('architect');
expect(paint(mirror, t.ctx(), 'B', [{ x: 0, y: 0, color: null }]).ok).toBe(false);
});
it('a second submit is allowed and the best score counts; the reveal shows the picture to the Builder', () => {
const { room, t, s } = building(sketch);
const rows = sketch.patterns[0].rows;
const colour = Object.keys(sketch.patterns[0].palette)[0];
paint(sketch, t.ctx(), 'B', [{ x: 0, y: 0, color: colour }]);
submitCanvas(sketch, t.ctx(), 'B');
expect(s).toMatchObject({ status: 'building', submits: 1 });
expect(viewOf<BlueprintView>(room, 'B', t.now).target).toBeNull();
paintAsBuilder(sketch, room, t, 'B', rows);
submitCanvas(sketch, t.ctx(), 'B');
expect(s).toMatchObject({ status: 'scored', bestScore: 1, passed: true });
expect(viewOf<BlueprintView>(room, 'B', t.now)).toMatchObject({ target: rows, title: sketch.patterns[0].title });
});
it('when time runs out, the canvas is scored as it is', () => {
const { t, s } = building(sketch);
expect(tickGame(sketch, t.ctx(sketch.patterns[0].timeSeconds * 1000))).toBe(true);
expect(s).toMatchObject({ status: 'scored', passed: false, bestScore: 0 });
});
it('rejects colours that are not on the palette, and a picture of the wrong size', () => {
const { t } = building(sketch);
expect(paint(sketch, t.ctx(), 'B', [{ x: 0, y: 0, color: 'Q' }]).ok).toBe(false);
expect(paint(sketch, t.ctx(), 'B', [{ x: 6, y: 0, color: null }]).ok).toBe(false);
const game = structuredClone(sketch);
game.patterns[0].rows[0] = 'RR';
game.patterns[1].rows[1] = '.ZZZZ.';
const errors = validateGame(game).join('\n');
expect(errors).toMatch(/rows must be 6×6/);
expect(errors).toMatch(/colour "Z" is not in the palette/);
});
});
// ---------- labyrinth items and doors ----------
describe('labyrinth: items and doors (Hedge Maze)', () => {
const hedge = getGame('hedge-maze-midnight', 'labyrinth');
const [door] = hedge.doors;
const [item] = hedge.items;
/** Walks the Explorer along a path of n/e/s/w steps. */
const walk = (room: Room, t: ReturnType<typeof clock>, steps: string) => {
for (const dir of steps) moveExplorer(hedge, t.ctx(), 'B', dir as 'n');
};
it('a door stays shut until the Explorer carries its item, which survives a trap', () => {
const room = playingRoom(hedge);
const t = clock(room);
const s = room.engine as LabyrinthState;
// Start (1,1) → down to (1,7) → east to the door's neighbour (4,7).
s.x = door.x - 1;
s.y = door.y;
moveExplorer(hedge, t.ctx(), 'B', 'e');
expect({ x: s.x, y: s.y }).toEqual({ x: door.x - 1, y: door.y });
expect(viewOf<ExplorerView>(room, 'B').message).toBe(door.lockedText);
// Pick the key up from the cell next to it.
s.x = item.x - 1;
s.y = item.y;
moveExplorer(hedge, t.ctx(), 'B', 'e');
expect(viewOf<ExplorerView>(room, 'B')).toMatchObject({ inventory: [item.name], message: item.explorerText });
// The Navigator gets no toast about it.
expect(t.ctx().toasts).toEqual([]);
expect(viewOf<NavigatorView>(room, 'A').items).toEqual([{ x: item.x, y: item.y, name: item.name, collected: true }]);
// A trap sends them back with the key.
const [trap] = hedge.grid.flatMap((row, y) => [...row].flatMap((c, x) => (c === 'T' ? [{ x, y }] : [])));
s.x = trap.x - 1;
s.y = trap.y;
if (hedge.grid[trap.y][trap.x - 1] === '#') s.x = trap.x + 1;
moveExplorer(hedge, t.ctx(), 'B', s.x < trap.x ? 'e' : 'w');
expect(s.falls).toBe(1);
expect(s.inventory).toEqual([item.id]);
s.x = door.x - 1;
s.y = door.y;
moveExplorer(hedge, t.ctx(), 'B', 'e');
expect({ x: s.x, y: s.y }).toEqual({ x: door.x, y: door.y });
expect(s.message).toBe(door.openText);
walk(room, t, 'we');
expect(s.message).not.toBe(door.openText); // only the first time
});
it("never sends the Explorer where items and doors are", () => {
const room = playingRoom(hedge);
const keys = keysOf(viewOf<ExplorerView>(room, 'B'));
for (const key of ['items', 'doors', 'x', 'y', 'grid']) expect(keys).not.toContain(key);
});
it('validation: doors need a known item, items and doors sit on plain floor, and the gate must be passable', () => {
const game = structuredClone(hedge);
game.doors[0].needs = 'nothing';
game.items[0].x = 0; // a wall
const errors = validateGame(game).join('\n');
expect(errors).toMatch(/needs unknown item "nothing"/);
expect(errors).toMatch(/must be on plain floor/);
// The key behind its own gate ((3,7) is only reachable through it): the gate's lantern is out of reach.
const locked = structuredClone(hedge);
locked.items[0] = { ...locked.items[0], x: 3, y: 7 };
const lockedErrors = validateGame(locked).join('\n');
expect(lockedErrors).toMatch(/item "Iron Gate Key" .* is not reachable/);
expect(lockedErrors).toMatch(/lantern at \(\d+,\d+\) is not reachable/);
});
});
// ---------- defuse: shared manual and role swaps ----------
describe('defuse: Tick Tock: Switch', () => {
const sw = getGame('tick-tock-switch', 'defuse');
const overtime = getGame('tick-tock-overtime', 'defuse');
it("uses Overtime's device, modules, manual and solver", () => {
expect(sw).toMatchObject({ manualFrom: 'tick-tock-overtime', ruleset: 'overtime', manual: overtime.manual });
expect(sw.modules).toEqual(overtime.modules);
expect(sw.levels).toHaveLength(5);
expect(sw.levels[4].strikesOverride).toBe(1);
});
it('a missing manualFrom game fails to load', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'two-keys-'));
const file = path.join(dir, 'switch-copy.yaml');
const raw = fs.readFileSync(path.join(__dirname, '../../content/games/tick-tock-switch.yaml'), 'utf8');
fs.writeFileSync(file, raw.replace('id: tick-tock-switch', 'id: switch-copy'));
expect(loadGameFile(file).errors.join('\n')).toMatch(/manualFrom: game "tick-tock-overtime" is missing/);
fs.rmSync(dir, { recursive: true });
});
it('swaps roles every level, behind a swap card both players tap Ready on', () => {
const room = playingRoom(sw);
const t = clock(room);
const s = room.engine as DefuseState;
expect(continueDefuse(sw, t.ctx()).ok).toBe(false); // the swap card first
levelReady(sw, t.ctx(), 'A');
expect(viewOf<TechnicianView>(room, 'B').swapReady).toEqual({ you: false, partner: true });
levelReady(sw, t.ctx(), 'B');
expect(s.status).toBe('active');
expect(viewOf<TechnicianView>(room, 'A').seat).toBe('technician');
expect(viewOf<TechnicianView>(room, 'B').seat).toBe('expert');
// Clear level 1 with the solver's answers... or just jump to level 2.
s.status = 'cleared';
continueDefuse(sw, t.ctx());
expect(s).toMatchObject({ level: 1, status: 'ready', swapReady: [] });
levelReady(sw, t.ctx(), 'A');
levelReady(sw, t.ctx(), 'B');
expect(viewOf<TechnicianView>(room, 'B')).toMatchObject({ seat: 'technician', technician: 'B' });
const expert = stateOf(room, 'A').views.A!;
expect(expert).toMatchObject({ seat: 'expert', manual: sw.manual });
expect(keysOf(expert)).not.toContain('device');
// Only this level's Technician may touch the device.
expect(defuseAction(sw, t.ctx(), 'A', 'wires', { type: 'cut', index: 1 }).ok).toBe(false);
});
it('two wire modules on one level are generated and solved separately, as Wires A and Wires B', () => {
const room = playingRoom(sw);
const t = clock(room);
const s = room.engine as DefuseState;
s.level = 3;
levelReady(sw, t.ctx(), 'A');
levelReady(sw, t.ctx(), 'B');
const wires = wireModules(s.device!);
expect(wires.map((w) => [w.instance, w.title])).toEqual([
['wires-1', 'Wires A'],
['wires-2', 'Wires B'],
]);
const tech = technicianOf3(s);
const view = viewOf<TechnicianView>(room, tech, t.now);
expect(view.device!.modules.filter((m) => m.id === 'wires').map((m) => (m as { instance: string }).instance)).toEqual(['wires-1', 'wires-2']);
const cut = (instance: string, i: number) =>
defuseAction(sw, t.ctx(), tech, instance, {
type: 'cut',
index: wireSolution(sw.ruleset, edgework(s.device!), wires[i].module.colors).index,
});
expect(cut('wires-2', 1).ok).toBe(true);
expect(wires[1].module.solved).toBe(true);
expect(wires[0].module.solved).toBe(false);
expect(cut('wires-1', 0).ok).toBe(true);
expect(wires[0].module.solved).toBe(true);
expect(s.strikes).toBe(0);
});
it('only wires may appear twice in a level', () => {
const game = structuredClone(sw);
game.levels[0].modules = ['keypad', 'keypad'];
expect(validateGame(game).join('\n')).toMatch(/only wires can appear more than once/);
});
});
/** Level 4 (index 3) is an even level: B holds the device. */
const technicianOf3 = (s: DefuseState): Role => (s.level % 2 === 1 ? 'B' : 'A');
// ---------- sync: Road Trip ----------
describe('sync: In Sync: Road Trip', () => {
it('plays every round to the end', () => {
const game = getGame('in-sync-road-trip', 'sync');
const room = playingRoom(game);
const t = clock(room);
const s = room.engine as SyncState;
for (let r = 0; r < game.rounds.length; r++) {
continueSync(game, t.ctx()); // intro → question
for (let q = 0; q < s.questions.length; q++) {
const question = s.questions[s.q];
const answer = game.rounds[r].type === 'telepathy' ? 'Road' : 0;
expect(answerSync(game, t.ctx(), 'A', question.id, answer).ok).toBe(true);
expect(answerSync(game, t.ctx(), 'B', question.id, answer).ok).toBe(true);
continueSync(game, t.ctx()); // reveal → next / summary
}
continueSync(game, t.ctx()); // summary → next round (or the end)
}
expect(room.keys).toEqual(game.rounds.map((r) => r.keyName));
expect(room.result).toMatchObject({ success: true, ending: game.successEnding });
});
});
// ---------- over real sockets ----------
describe('dial over sockets', () => {
const app = createApp({ dataFile: null });
let url = '';
beforeAll(async () => {
url = `http://localhost:${await app.listen(0)}`;
});
afterAll(async () => {
await app.close();
});
it('never broadcasts the guesser\'s needle', async () => {
const make = () => {
const socket = connect(url, { transports: ['websocket'], forceNew: true });
const states: RoomState[] = [];
socket.on('room:state', (st: RoomState) => states.push(st));
return { socket, states };
};
const a = make();
const b = make();
const ack = (s: typeof a, event: string, payload: object) =>
new Promise<JoinAck>((resolve) => s.socket.emit(event, payload, resolve));
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
try {
const created = await ack(a, 'room:create', { gameId: 'wavelength-tune-in' });
if (!created.ok) throw new Error(created.message);
await ack(b, 'room:join', { code: created.code });
a.socket.emit('room:pickRole', { role: 'A' });
await wait(100);
a.socket.emit('room:ready');
b.socket.emit('room:ready');
await wait(150);
a.socket.emit('dial:clue', { text: 'zebra' });
await wait(150);
const before = a.states.length;
b.socket.emit('dial:needle', { value: 77 });
await wait(200);
expect(a.states.length).toBe(before);
expect(a.states.every((st) => JSON.stringify(st.views).indexOf('"needle":77') === -1)).toBe(true);
} finally {
a.socket.close();
b.socket.close();
}
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@two-keys/shared",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
+127 -2
View File
@@ -9,7 +9,7 @@ export const otherRole = (role: Role): Role => (role === 'A' ? 'B' : 'A');
export type Seat = Role | 'both';
export type PlayMode = 'duo' | 'couch';
export type Phase = 'lobby' | 'playing' | 'finished';
export type EngineId = 'story' | 'defuse' | 'sync' | 'labyrinth';
export type EngineId = 'story' | 'defuse' | 'sync' | 'labyrinth' | 'dial' | 'twin' | 'blueprint';
export type Category = 'murder-mystery' | 'escape-room' | 'co-op';
export interface Theme {
@@ -87,6 +87,8 @@ export interface LockView {
hintsTotal: number;
/** What the next hint costs. */
hintPenaltySeconds: number;
/** The game has a countdown, so a hint takes time off what's left instead of adding to a stopwatch. */
timeLimited: boolean;
wrongAttempts: number;
nudge: boolean;
successText: string | null;
@@ -154,6 +156,10 @@ export interface DefuseClock {
export interface WiresModuleView {
id: 'wires';
/** What `defuse:action` names this module: `wires`, or `wires-1` / `wires-2` when a level has two. */
instance: string;
/** "Wires", or "Wires A" / "Wires B". */
title: string;
solved: boolean;
wires: string[];
}
@@ -215,6 +221,12 @@ interface DefuseCommon {
strikes: number;
maxStrikes: number;
clock: DefuseClock | null;
/** Who is the Technician this level (it changes every level when roles swap). */
technician: Role;
/** Roles swap every level: both tap Ready on the swap card, and the clock starts. */
swapRoles: boolean;
/** Ready on the swap card: you, and your partner. */
swapReady: { you: boolean; partner: boolean };
}
export interface TechnicianView extends DefuseCommon {
seat: 'technician';
@@ -299,6 +311,8 @@ export interface ExplorerView {
message: string;
moves: number;
falls: number;
/** Items carried (picked up on entry, kept after traps). */
inventory: string[];
}
export interface NavigatorView {
@@ -309,6 +323,8 @@ export interface NavigatorView {
lanterns: { x: number; y: number; name: string; collected: boolean }[];
/** One-way gates: passable from (x, y) in `dir` only. */
oneWay: { x: number; y: number; dir: Dir }[];
items: { x: number; y: number; name: string; collected: boolean }[];
doors: { x: number; y: number; name: string }[];
banner: string | null;
keysCarried: number;
totalKeys: number;
@@ -317,7 +333,116 @@ export interface NavigatorView {
}
export type LabyrinthView = ExplorerView | NavigatorView;
export type EngineView = StoryView | DefuseView | SyncView | LabyrinthView;
// ---------- Dial engine (Wavelength) ----------
export interface DialBand {
within: number;
points: number;
}
export interface DialRoundRecord {
left: string;
right: string;
clue: string;
giver: Role;
target: number;
guess: number;
points: number;
}
export interface DialView {
engine: 'dial';
status: 'clue' | 'guess' | 'reveal';
round: number;
rounds: number;
/** Who gives the clue this round. */
giver: Role;
iAmGiver: boolean;
/** A label the guesser may not see is null. */
left: string | null;
right: string | null;
showNumbers: boolean;
range: [number, number];
bands: DialBand[];
rules: { maxWords: number; maxChars: number; banLabelWords: boolean; noRepeatWords: boolean };
/** Clue words used so far (only kept when words may not repeat). */
usedWords: string[];
clue: string | null;
/** The clue-giver always; the guesser only once they've locked in. */
target: number | null;
/** The guesser's own needle, so a refresh keeps it. Never sent to the clue-giver. */
needle: number | null;
/** Once locked in. */
guess: number | null;
points: number | null;
total: number;
maxTotal: number;
keys: { at: number; name: string; earned: boolean }[];
history: DialRoundRecord[];
}
// ---------- Twin engine (Twin Rooms) ----------
export interface TwinView {
engine: 'twin';
status: 'ready' | 'playing' | 'won' | 'failed';
scene: { index: number; total: number; title: string; keyName: string; differences: number };
/** Your own room, row by row, one emoji per cell. */
grid: string[][];
showCoordinates: boolean;
/** Found differences, in your own view's coordinates. */
found: { x: number; y: number }[];
/** Your pending tap, in your own coordinates, and how long it waits for your partner. */
myTap: { x: number; y: number; secondsLeft: number } | null;
/** Your partner has a pending tap (never where). */
partnerTap: { secondsLeft: number } | null;
/** Seconds left on the scene clock as the state was built. */
remaining: number;
timeSeconds: number;
claimWindowSeconds: number;
wrongClaimPenaltySeconds: number;
/** A failed scene can be retried once. */
canRetry: boolean;
isRetry: boolean;
}
// ---------- Blueprint engine (Describe & Draw) ----------
export interface BlueprintColor {
key: string;
name: string;
hex: string;
}
export interface BlueprintView {
engine: 'blueprint';
status: 'ready' | 'building' | 'scored';
seat: 'architect' | 'builder';
architect: Role;
pattern: { index: number; total: number; size: number; keyName: string };
palette: BlueprintColor[];
/** The picture, row by row ('.' = empty). The Architect always; the Builder only once it's scored. */
target: string[] | null;
/** Ditto. */
title: string | null;
/** The Builder's canvas in target orientation. The Builder always; the Architect only in a peek or once scored. */
canvas: string[] | null;
/** The Builder's canvas is shown rotated this many degrees clockwise. */
rotateBuilder: number;
peeksLeft: number;
/** Server time a running peek ends. */
peekUntil: number | null;
submitsLeft: number;
threshold: number;
/** The best score so far this pattern (0–1), once something was submitted. */
bestScore: number | null;
lastScore: number | null;
passed: boolean | null;
remaining: number;
timeSeconds: number;
}
export type EngineView = StoryView | DefuseView | SyncView | LabyrinthView | DialView | TwinView | BlueprintView;
// ---------- Room ----------