6 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
joshuaandClaude Opus 5.5 6669be11e5 V1.2.0
Bring games 9-18 in line with GAMES-NEW.md:
- Library sorts easiest first, filters by category and difficulty; difficulty 5
  shows a Nightmare chip, and a no-hint finish earns Nightmare Survivors.
- Time limits end on "Out of time" (no rank); Deep Lab Nereid's countdown is a
  rising water gauge behind the header.
- Evidence Locker search, chapter filter and starred clues (partner sees a count).
- One-shot accusations ask "One shot. Are you both sure?" first.
- Symbol font fallback, tight monospace blocks for grids/keypads/SQL.
- Rank rounds reorder by drag or up/down buttons; reveal highlights #1 and #4.
- Telepathy capped at 30 characters; maze:move accepts forward/left/right/back;
  gate buttons read "Gate (won't open from this side)".
- Overtime solver tables checked against Appendix A; keypad generator re-rolls.
- More content checks (rank items, password fixtures, glyphs/gates on traps) and
  sanitize tests for all ten new games.
- GAMES.md carries the full spoiler guide for 9-18.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:44:28 +02:00
joshuaandClaude Opus 5.5 bbe44e84c4 V1.1.0
Add ten new games (18 in total) and the engine features they need.

Story: Dead Air, The Masquerade at Montague House, The Kalahari Expedition,
The Alchemist's Vault, Submerged: Deep Lab Nereid, The Kestrel Bank Job.
Games can now set their own hint penalty and a hard time limit, shown as a
countdown; running out ends the game with the fail ending.

Defuse: Tick Tock: Overtime. Manuals are now rulesets, so each game has its
own solver. Adds indicator lights, per-level strike limits, and the
Passwords, Morse Light and Say It Back modules.

Sync: In Sync: Deep Cuts. Adds telepathy (typed word) and ranking rounds,
and a per-round bar for the key.

Labyrinth: The Drowned Catacombs and The Mirror Maze. Adds one-way gates, a
relative (no compass) Explorer, a mirrored Navigator map, and mazes with a
lantern count other than three. Validation now rejects a maze where a gate
can leave the Explorer stuck.

Difficulty runs from 1 to 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:34:11 +02:00
joshuaandClaude Opus 5.5 62245ca67a Make game time estimates and difficulties realistic
Estimates were 2-8x longer than actual playthroughs. Base them on
recorded best times, rounded up to a multiple of 5 minutes, and drop
difficulties to 1-2 keys since no game warrants 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:41:41 +02:00
93 changed files with 12027 additions and 348 deletions
+102 -21
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 (8 games, 4 engines)
### Game library (40 games, 7 engines)
| # | Game | Category | Engine | File |
|---|------|----------|--------|------|
@@ -21,6 +21,38 @@ A website where **two players (a couple)** play co-op mystery games together. Ea
| 6 | Tick Tock Together | Co-op Defuse | `defuse` | `tick-tock-together.yaml` |
| 7 | In Sync | Couples Connection | `sync` | `in-sync.yaml` |
| 8 | Labyrinth of Lanterns | Co-op Navigation | `labyrinth` | `labyrinth-of-lanterns.yaml` |
| 9 | Dead Air | Murder Mystery | `story` | `dead-air.yaml` |
| 10 | The Masquerade at Montague House | Murder Mystery | `story` | `masquerade-montague.yaml` |
| 11 | The Kalahari Expedition | Murder Mystery | `story` | `kalahari-expedition.yaml` |
| 12 | The Alchemist's Vault | Escape Room | `story` | `alchemists-vault.yaml` |
| 13 | Submerged: Deep Lab Nereid | Escape Room | `story` | `deep-lab-nereid.yaml` |
| 14 | The Kestrel Bank Job | Escape Room | `story` | `kestrel-heist.yaml` |
| 15 | Tick Tock: Overtime | Co-op Defuse | `defuse` | `tick-tock-overtime.yaml` |
| 16 | In Sync: Deep Cuts | Couples Connection | `sync` | `in-sync-deep-cuts.yaml` |
| 17 | The Drowned Catacombs | Co-op Navigation | `labyrinth` | `drowned-catacombs.yaml` |
| 18 | The Mirror Maze | Co-op Navigation | `labyrinth` | `mirror-maze.yaml` |
| 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
@@ -53,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
@@ -86,20 +119,24 @@ 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
difficulty: 1-3
difficulty: 1-5
players: { min: 2, max: 2 }
theme: { primary, accent, background, headingFont }
roles: { A: {name, blurb}, B: {name, blurb} }
intro: markdown
```
### `story` engine (games 1–5)
### `story` engine (games 1–5, 9–14, 19, 23, 27, 28, 31, 32, 35, 36, 39, 40)
```yaml
rules: # optional
hintPenaltySeconds: number # default 120
timeLimitMinutes: number # hard limit on elapsed + penalties; running out ends the game
timeLimitLabel: string # what the countdown is called, e.g. "Water level"
suspects: # optional (murder mysteries)
- { id, name, role, description }
chapters:
@@ -129,6 +166,7 @@ finale:
lock: Lock # same shape as a chapter lock (no keyName needed)
# both:
successEnding: markdown
failEnding: markdown # optional on escape: shown when the time limit runs out
```
Use a zod discriminated union on `finale.type`.
@@ -142,7 +180,8 @@ Use a zod discriminated union on `finale.type`.
- `photo`: polaroid frame; body text is the photo description in italics (no image assets needed)
- `map` / `diagram`: monospace block, preserve whitespace, grid tables
- `signal`: animated blinking light driven by `signal` field (see §6.3)
- Clues from **previous** chapters stay accessible in an "Evidence Locker" drawer.
- Clues from **previous** chapters stay accessible in an "Evidence Locker" drawer. The locker searches clue titles and bodies (client-side, own clues only), filters by chapter, and lets each player star clues (`clue:pin`). The partner only ever gets the **count** of stars, never which clues.
- Clue text uses `Noto Sans Symbols 2` as a fallback (Braille, alchemy symbols). Code fences (stencil grids, box-drawing keypads, SQL) are monospace at `line-height: 1.15`; wide tables and blocks scroll inside their card, never the page.
### Other engines
See each YAML for its schema. Summaries in §6.
@@ -151,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`.
@@ -163,8 +202,9 @@ See each YAML for its schema. Summaries in §6.
- State: `currentChapter`, `keys[]`, `hintsUsed{lockId: tier}`, `wrongAttempts{}`, `startedAt`, `penaltySeconds`, `finaleAttempts`.
- Chapter view: narrative on top → my clues → lock panel → key ring.
- Correct answer → both players see the lock spring open (animation), `successText` reveals, the key flies onto the key ring, **Continue** button (either player can press) advances both.
- **Hints:** either player can request; tiers unlock in order; both players see revealed hints. Each hint adds **+2:00** penalty. Confirm dialog before revealing tier 3 ("This basically solves it. Sure?").
- **Finale:** when all chapter keys are collected, the finale opens. For `accusation`, both players must agree: Player A proposes answers, Player B sees them and taps **Agree** or **Change**. Submit only when both agree. Wrong submission shows "Not quite. N attempts left" without revealing which part was wrong. After `maxAttempts`, show `failEnding` + `reveal`.
- **Hints:** either player can request; tiers unlock in order; both players see revealed hints. Each hint adds **+2:00** penalty (or the game's `rules.hintPenaltySeconds`). Confirm dialog before revealing tier 3 ("This basically solves it. Sure?").
- **Time limit:** games with `rules.timeLimitMinutes` show a countdown instead of a stopwatch. When elapsed + penalty time passes the limit, the server ends the game with `failEnding` (plus `reveal` for an accusation) and the results page shows **Out of time** instead of a rank. A `timeLimitLabel` containing "water" (Deep Lab Nereid) also draws the countdown as water rising behind the header (no wave with `prefers-reduced-motion`).
- **Finale:** when all chapter keys are collected, the finale opens. For `accusation`, both players must agree: Player A proposes answers, Player B sees them and taps **Agree** or **Change**. Submit only when both agree. Wrong submission shows "Not quite. N attempts left" without revealing which part was wrong. After `maxAttempts`, show `failEnding` + `reveal`. Any number of questions works. On the last attempt (always, with `maxAttempts: 1`) submitting asks "One shot. Are you both sure?" first.
- For `escape` finales, it's just the last lock; on success show `successEnding`.
### 6.2 Defuse engine (game 6). See `tick-tock-together.yaml`
@@ -173,6 +213,11 @@ See each YAML for its schema. Summaries in §6.
- Implement the fixtures in the YAML as unit tests. They must pass.
- Strikes: 3 per level. Timer per level. Clock speeds up 25% after each strike.
- Each level cleared = 1 key. Fail a level → retry with a new seed (no key lost).
- **Rulesets:** each manual's rules are code in `engines/defuse.ts`, picked by the YAML's `ruleset` (`classic` = Tick Tock Together, `overtime` = Tick Tock: Overtime). A new manual needs a new ruleset.
- **`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)
`signal: "./-.-./..../---"`: `.` = 250ms on, `-` = 750ms on, 250ms gap between symbols, `/` = 1000ms gap between letters, 3000ms pause, then loop. Render as a glowing circle. Add a "Replay" button. Never render the dot/dash text to that player (they must read the light).
@@ -182,7 +227,10 @@ See each YAML for its schema. Summaries in §6.
- `match` rounds: both pick from the same options. Match = point.
- `predict` rounds: the `subject` role answers about themselves, the other guesses. Match = point.
- 3+ points in a round = key. Failing a round lets them replay it once with fresh questions.
- Draw questions randomly from the bank, never repeating within a session.
- Draw questions randomly from the bank, never repeating within a session. (When a `predict` bank is too small for that, a question may come back once, asked about the other partner.)
- `telepathy` rounds (Deep Cuts): no options, both type one word (max 30 characters). Same word = point; case, spaces, punctuation and a trailing S are ignored.
- `rank` rounds (Deep Cuts): the subject (cycled through `subjectPattern`) orders four items (drag, or up/down buttons), the partner predicts. Point only if **both** the top and the bottom match; the reveal highlights both.
- A round can set its own `pointsForKey`.
### 6.5 Labyrinth engine (game 8). See `labyrinth-of-lanterns.yaml`
- Player B (**Explorer**) sees only their current cell: four direction buttons (disabled where wall), glyph on the floor if any, key if present.
@@ -190,15 +238,42 @@ See each YAML for its schema. Summaries in §6.
- Traps are invisible to the Explorer. Stepping on one sends them back to Start (keys kept) and adds 1:00 penalty.
- Each lantern key collected = key. Exit only opens with all 3.
- Server validates every move.
- `lanternsRequired` sets how many lanterns the maze has (default 3).
- `oneWay` gates (Drowned Catacombs): passable from `{x, y}` in `dir` only. The Navigator sees an arrow; the Explorer sees the way back as a gate, not a wall.
- `explorer.compass: relative` (Mirror Maze): the Explorer moves forward/left/right/back from the way they face (their last step), and never sees N/E/S/W.
- `navigatorView.mapTransform: mirror-x` (Mirror Maze): the Navigator's map, and everything on it, is served flipped left to right.
- `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
Client → server:
`room:create {gameId}`, `room:join {code, playerToken?}`, `room:pickRole {role}`, `room:ready`,
`lock:submit {lockId, value}`, `hint:request {lockId}`, `chapter:continue`,
`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}`,
`maze:move {dir}`, `notepad:update {text}`, `ping {emoji}`
`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), `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}`,
@@ -209,14 +284,15 @@ Keep it simple: after any mutation, broadcast a fresh per-role `room:state` to e
## 8. Scoring & results
- `finalTime = elapsed + penaltySeconds`
- Rank badges: **Soulmate Sleuths** (no hints, under estimate) / **Dream Team** / **Getting There** / **Love Is Patient** 😅
- Rank badges: **Soulmate Sleuths** (no hints, under estimate) / **Dream Team** / **Getting There** / **Love Is Patient** 😅 / **Out of time** (a time limit ran out)
- **Nightmare Survivors ☠️**: a difficulty-5 game finished with zero hints. Shown on the results page and, once earned, on that game's library card.
- Results page: time, hints used, keys earned, wrong guesses, plus a "Play another" carousel. Store best results per game in localStorage.
## 9. Design direction
- Vibe: **date-night cozy meets noir**. Dark mode default, warm candlelit accents, per-game theme from YAML `theme`.
- Fonts: Playfair Display / Inter / Caveat (handwritten notes) / JetBrains Mono (logs, maps) from Google Fonts.
- Home page: hero "Two players. One mystery. Every key needs both of you." + game library grid (cards show category chip, time, difficulty 🔑🔑🔑).
- 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").
@@ -225,10 +301,15 @@ 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
- Defuse: all fixtures solve to their expected solution
- Labyrinth: grid rows equal length; exactly one S and E; exactly 3 K; every K and E reachable from S **without stepping on a trap** (BFS)
- Sync: ≥15 questions per round type
- 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 (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)
@@ -242,7 +323,7 @@ Run it in CI and as a pre-start step.
## 12. Acceptance criteria
- [ ] Two phones can play any of the 8 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
+341 -8
View File
@@ -4,7 +4,7 @@ Every lock below needs info from **both** players. "A has / B has" shows the spl
---
## 1. Death at Vineyard Hollow (murder mystery · 60 min · 🔑🔑)
## 1. Death at Vineyard Hollow (murder mystery · 20 min · 🔑🔑)
Roles: **Inspector** (A) / **Estate Insider** (B)
| Ch | Lock | A has | B has | Answer |
@@ -20,7 +20,7 @@ Roles: **Inspector** (A) / **Estate Insider** (B)
---
## 2. The Last Set at the Blue Lantern (murder mystery · 50 min · 🔑🔑)
## 2. The Last Set at the Blue Lantern (murder mystery · 10 min · 🔑)
Roles: **Detective** (A) / **Regular** (B)
| Ch | Lock | A has | B has | Answer |
@@ -34,7 +34,7 @@ Roles: **Detective** (A) / **Regular** (B)
---
## 3. Murder on the Southern Star (murder mystery · 70 min · 🔑🔑🔑)
## 3. Murder on the Southern Star (murder mystery · 25 min · 🔑🔑)
Roles: **Passenger Detective** (A) / **Railway Inspector** (B)
| Ch | Lock | A has | B has | Answer |
@@ -49,7 +49,7 @@ Roles: **Passenger Detective** (A) / **Railway Inspector** (B)
---
## 4. The Clockmaker's Attic (escape room · 40 min · 🔑) (best "first game")
## 4. The Clockmaker's Attic (escape room · 15 min · 🔑) (best "first game")
Roles: **Tinkerer** (A) / **Collector** (B)
| Lock | A has | B has | Answer |
@@ -61,7 +61,7 @@ Roles: **Tinkerer** (A) / **Collector** (B)
---
## 5. Signal Lost: Station Echo (escape room · 45 min · 🔑🔑)
## 5. Signal Lost: Station Echo (escape room · 15 min · 🔑🔑)
Roles: **Comms Officer** (A) / **Engineer** (B)
| Lock | A has | B has | Answer |
@@ -73,14 +73,14 @@ Roles: **Comms Officer** (A) / **Engineer** (B)
---
## 6. Tick Tock Together (co-op defuse · 25 min · 🔑🔑)
## 6. Tick Tock Together (co-op defuse · 10 min · 🔑)
Technician (A) sees the device, Expert (B) reads the manual. 3 levels = 3 keys.
Device is generated server-side and solved by a solver that implements the manual exactly. Fixtures in the YAML are the unit tests.
## 7. In Sync (couples connection · 20 min · 🔑)
## 7. In Sync (couples connection · 15 min · 🔑)
Round 1 match, Round 2 predict about A, Round 3 predict about B. 3/5 matches = key. One replay per round.
## 8. Labyrinth of Lanterns (co-op navigation · 25 min · 🔑🔑)
## 8. Labyrinth of Lanterns (co-op navigation · 10 min · 🔑)
Navigator (A) has the map, Explorer (B) sees only their own cell. 3 lanterns, 3 hidden traps.
```
@@ -96,3 +96,336 @@ Navigator (A) has the map, Explorer (B) sees only their own cell. 3 lanterns, 3
#..T....#E#
###########
```
---
Difficulty: 1 Easy · 2 Medium · 3 Hard · 4 Expert · 5 Nightmare ☠️
In the harder murder mysteries, **locks only give facts**. The killer is never handed to you; the accusation needs facts from several chapters.
## 9. Dead Air (murder mystery · 30 min · 🔑🔑🔑)
Roles: **Detective** (A) / **Night Intern** (B). Hint penalty 3:00.
| Ch | Lock | A has | B has | Answer |
|----|------|-------|-------|--------|
| 1 | Last song | Track lengths | Run sheet (23:00 start, 2:00 intro, 1:30 talk) + alarm 23:31:35 (15 s rule) | `GHOST SIGNAL` (23:25:00–23:31:20) |
| 2 | Honey last safe | Lab: OKAYEST DJ mug negative, ON AIR mug + honey positive; meeting minutes | Kitchen camera log (offline 23:09–23:21); generator test booked by M. Venter | `23:06` |
| 3 | Studio door card | Card register (decimal); Kyle lost card at 22:30 meeting with Marisa | Door log in hex (0x1A1 at 23:12) | Kyle's card (0x1A1 = 417) |
| 4 | Stencil message | 6×6 letter grid | Card with cut-out windows | `SALE IS FORGED` |
**Killer:** Marisa Venter. Booked the generator test to kill the camera, left the meeting 23:11–23:17, used Kyle's stolen card at 23:12 to take the EpiPen, put peanut oil in the honey. Motive: Rex would reveal on air she forged his signature to sell the station.
**Red herrings:** Zanele (served the fatal tea), Kyle (his card), Tumi (engineer), Owen (superfan, stuck in reception).
---
## 10. The Masquerade at Montague House (murder mystery · 40 min · 🔑🔑🔑🔑)
Roles: **Inspector** (A) / **Lady's Maid** (B). Hint penalty 3:00. **1 accusation attempt, 4 questions.**
| Ch | Lock | A has | B has | Answer |
|----|------|-------|-------|--------|
| 1 | Who arrived as Raven | Logic clues 1–4 | Logic clues 5–8 | Odette (verified unique: Agatha Stag/crimson, Rupert Fox/green, Ivy Swan/black, Silas Lion/silver, Odette Raven/gold) |
| 2 | Who the footman saw | Footman: Raven + black cloak | Cloakroom: Raven ↔ Swan swapped at 23:00, cloaks kept | Ivy |
| 3 | Dagger pot | Gardener: primes not watered | Disturbed pots 4, 9, 11, 15; spotless gloves | `11` |
| 4 | Julian's confession | Polybius numbers + method | Motto FORTUNE FAVOURS THE BOLD; Marsh family gossip | `MARSH WAS INNOCENT` |
**Killer:** Ivy Marsh · wearing Odette's **Raven** mask · armoury dagger, buried in cactus pot 11 · Julian framed her father and took Montague House.
---
## 11. The Kalahari Expedition (murder mystery · 60 min · ☠️ Nightmare)
Roles: **Magistrate** (A) / **Camp Clerk** (B). Hint penalty 4:00. **75-minute time limit. 1 attempt, 4 questions.**
| Ch | Lock | A has | B has | Answer |
|----|------|-------|-------|--------|
| 1 | Lantern time | Star-trail photo: lantern at 60°–75° of arc | Shutter opened 22:00, sky turns 15°/hr; Clara alibis Reyes 22:00–04:00 | `02:00` |
| 2 | Boot size | Print 31.5 cm, chevron, sand adds ~1 cm | Size card + boot inventory | `11` (Reyes & Dunmore have size 11 chevron) |
| 3 | Watchman at 02:00 | Rota rules; Samuel's fake snakebite (4 cm marks, sedative) | Watchkeepers + Night 1 = Reyes; murder on Night 3 | Pienaar → figure went to north row (Dunmore, Kessler, Reyes) |
| 4 | Samuel's note | Vigenère ciphertext + method | Key word KOFFIE; tobacco list (pipe: Reyes, Dunmore) | `HE SMELLED OF PIPE SMOKE` |
| 5 | Diamond source | Gem report: sample K-3, never water-worn | Reference samples; prospectus "found by F. Dunmore" | `KIMBERLEY` |
| 6 | Telegram | Book-cipher numbers | Expedition handbook | `SALTED` |
**Elimination:** size 11 chevron → Reyes, Dunmore · Reyes alibied by Clara · pipe smoke → Dunmore.
**Killer:** Felix Dunmore · struck Marlowe at the dig at 02:00 and put him in the pit · drugged Samuel and faked a snakebite · the diamond find was salted with Kimberley stones.
---
## 12. The Alchemist's Vault (escape room · 25 min · 🔑🔑🔑)
Roles: **Apprentice** (A) / **Scribe** (B).
| Lock | A has | B has | Answer |
|------|-------|-------|--------|
| Cabinet | Flasks: numbers + flame colours | Element table + rainbow order | `GENIUS` (Ge N I U S) |
| Stone dial | Two balanced scales | Silver = 5, gold = 3 × silver | `25` |
| Dotted plaque | Braille ⠍⠑⠗⠉⠥⠗⠽ | Braille card; Mercury = Hg | `80` |
| Vault (finale) | ☉ ☽ ♂ ♄ | Symbol → metal chart (+ element table from ch1) | `234` (79 + 47 + 26 + 82) |
---
## 13. Submerged: Deep Lab Nereid (escape room · 35 min · 🔑🔑🔑🔑)
Roles: **Pilot** (A) / **Diver** (B). **40-minute time limit shown as a rising water gauge.**
| Lock | A has | B has | Answer |
|------|-------|-------|--------|
| Hatch valves | Lights L1 L2 L3 L5 on | Valve → light wiring | `145` (unique solution) |
| Breathing gas | 210 L at 21% | Tank X 36%, Y 15% | `60` |
| Pod location | Sonar legs 300 m E, 200 m S | Chart, lab at E5, 100 m squares | `H7` |
| Launch (finale) | Multi-tap code 555·4·3·66·55·9·222 | Rewired intercom keypad | `SURFACE` |
---
## 14. The Kestrel Bank Job (escape room · 50 min · ☠️ Nightmare)
Roles: **Inside Man** (A) / **The Wheel** (B). Hint penalty 4:00. **55-minute limit.**
| Lock | A has | B has | Answer |
|------|-------|-------|--------|
| Staff door | Worn keys 0, 2, 6 (0 most worn) | Codes are DDMM family dates; social posts | `2006` (Mia, 20 June) |
| Laser hall | Mask bit values per column | Row masks 21, 12, 25, 17, 8 | Column `4` |
| Guards | Guards 1 & 2 timings | Guard 3 timing; need 4 clear minutes | `22:33` |
| Vault dial | Sons' ages in Roman numerals | Atbash note "MY SONS AGES, ELDEST FIRST" | `19-12-07` |
| Box | SQL query + CLIENTS | BOXES table | `317` |
| Getaway (finale) | Rail-fence text | Building has 3 floors (= 3 rails) | `OLD PIER` |
---
## 15. Tick Tock: Overtime (co-op defuse · 20 min · 🔑🔑🔑🔑)
4 levels = 4 keys. New: 3–6 wires, indicators (LUV, HUG, BAE, XOX, DTE), passwords, morse, simon. Level 4 runs all six modules with only 2 strikes.
Rules are the GAMES-NEW.md Appendix A solver, ported to `server/src/engines/defuse.ts` (`ruleset: overtime`); all 40 fixtures in the YAML pass as unit tests.
## 16. In Sync: Deep Cuts (couples connection · 25 min · 🔑🔑🔑)
Telepathy (type the same word, 3/5) · Spotlight A (4/5) · Spotlight B (4/5) · Ranking (top AND bottom must match, 3/5). 4 keys.
## 17. The Drowned Catacombs (co-op navigation · 20 min · 🔑🔑🔑)
13×13, 5 traps, 3 lanterns, **3 one-way flood gates**, sparse carvings.
```
############# S (1,1) E (11,11)
#S..#.......# K (3,11) (7,3) (11,5)
###.#.###.#.# T (2,3) (4,5) (4,7) (10,5) (10,11)
#.T...#K#...# One-way: (5,9)→E (5,11)→N (7,8)→N
#.#.#.#.#.###
#.#.T.#...TK# Verified: all lanterns + exit reachable without traps,
#.##.##.....# respecting gates, and no softlocks.
#.#.T...#.#.#
#.#.###.#.#.#
#.......#.#.#
#.###.#.#.#.#
#..K#.....TE#
#############
```
## 18. The Mirror Maze (co-op navigation · 30 min · ☠️ Nightmare)
15×15, 7 traps, **4 lanterns**, Explorer has **relative controls only**, Navigator's map is **mirrored left-right**, two glyphs appear twice (☾ and ✦).
```
############### True layout (Navigator sees each row reversed)
#S..#.........# S (1,1) facing east E (13,13)
###.#.#.#####.# K (1,3) (1,13) (3,9) (13,9)
#K#.....#...#.# T (2,13) (3,5) (5,6) (5,7) (9,5) (13,6) (13,10)
#.####.##.#.#.#
#..T.....T....#
#.###T#.###..T#
#...#T#.#.....#
#.#.#.#.#.###.#
#..K#.#.....#K#
#.#.#.#.###.#T#
#.#.........#.#
#.#.#.#.###.#.#
#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
@@ -18,7 +18,7 @@ Co-op mystery games for couples. Two players, different clues, every lock needs
|------|------------|
| `CLAUDE.md` | Full build spec: stack, architecture, security, engines, socket events, design, phases |
| `GAMES.md` | Spoiler guide with every answer (don't let your partner read this 👀) |
| `content/games/*.yaml` | All 8 games: 3 murder mysteries, 2 escape rooms, defuse, In Sync, labyrinth |
| `content/games/*.yaml` | All 18 games: 6 murder mysteries, 5 escape rooms, 2 defuse, 2 In Sync, 3 labyrinths |
| `package.json` | Root workspace config |
## Adding a new game later
+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=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.0.1",
"version": "1.3.0",
"private": true,
"type": "module",
"scripts": {
+32 -11
View File
@@ -36,7 +36,13 @@ function Audience({ clue, light }: { clue: ClueView; light: boolean }) {
);
}
function Header({ clue, light, mono }: { clue: ClueView; light: boolean; mono?: boolean }) {
/** Starring a clue: yours to see; your partner only learns how many you starred. */
export interface PinProps {
pinned?: boolean;
onPin?: () => void;
}
function Header({ clue, light, mono, pinned, onPin }: { clue: ClueView; light: boolean; mono?: boolean } & PinProps) {
return (
<div className="mb-3 flex flex-wrap items-start justify-between gap-x-3 gap-y-1.5">
<div className="min-w-0">
@@ -45,7 +51,22 @@ function Header({ clue, light, mono }: { clue: ClueView; light: boolean; mono?:
</div>
<h4 className={`font-semibold leading-snug ${mono ? 'font-mono text-sm' : 'font-sans text-[15px]'}`}>{clue.title}</h4>
</div>
<Audience clue={clue} light={light} />
<div className="flex shrink-0 items-center gap-1.5">
<Audience clue={clue} light={light} />
{onPin && (
<button
type="button"
onClick={onPin}
aria-pressed={pinned}
aria-label={pinned ? `Unstar ${clue.title}` : `Star ${clue.title}`}
className={`-m-1 flex h-9 w-9 items-center justify-center rounded-full font-sans text-lg not-italic transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
pinned ? 'text-amber-400' : light ? 'text-stone-500 hover:text-stone-800' : 'text-stone-500 hover:text-stone-200'
}`}
>
{pinned ? '★' : '☆'}
</button>
)}
</div>
</div>
);
}
@@ -98,19 +119,19 @@ export function SignalLamp({ pattern }: { pattern: string }) {
);
}
export function ClueCard({ clue }: { clue: ClueView }) {
export function ClueCard({ clue, ...pin }: { clue: ClueView } & PinProps) {
switch (clue.type) {
case 'note':
return (
<article className="-rotate-[0.6deg] rounded-sm bg-[#f7edc5] p-4 text-stone-900 shadow-lg shadow-black/40">
<Header clue={clue} light />
<Header {...pin} clue={clue} light />
<Markdown className="paper font-hand text-[1.4rem] leading-7">{clue.body}</Markdown>
</article>
);
case 'letter':
return (
<article className="rounded-sm border border-[#d8c9a3] bg-[#f3ead3] bg-[repeating-linear-gradient(0deg,transparent,transparent_27px,rgba(120,90,40,0.07)_28px)] p-5 text-stone-900 shadow-lg shadow-black/40">
<Header clue={clue} light />
<Header {...pin} clue={clue} light />
<Markdown className="paper font-serif text-[1.05rem] leading-7">{clue.body}</Markdown>
</article>
);
@@ -123,7 +144,7 @@ export function ClueCard({ clue }: { clue: ClueView }) {
{clue.type === 'log' ? '▮ Access log' : clue.type === 'report' ? '▮ Official report' : '▮ On file'}
</div>
<div className="p-4">
<Header clue={clue} light mono />
<Header {...pin} clue={clue} light mono />
<Markdown className={`paper text-[15px] leading-6 ${clue.type === 'log' ? 'font-mono text-[13px]' : ''}`}>{clue.body}</Markdown>
</div>
</article>
@@ -135,7 +156,7 @@ export function ClueCard({ clue }: { clue: ClueView }) {
<Markdown className="italic leading-6">{clue.body}</Markdown>
</div>
<div className="mt-3">
<Header clue={clue} light />
<Header {...pin} clue={clue} light />
</div>
</article>
);
@@ -143,21 +164,21 @@ export function ClueCard({ clue }: { clue: ClueView }) {
case 'diagram':
return (
<article className="rounded-md border border-accent/30 bg-black/50 p-4 text-accent">
<Header clue={clue} light={false} mono />
<Header {...pin} clue={clue} light={false} mono />
<Markdown className="font-mono text-[13px] leading-6">{clue.body}</Markdown>
</article>
);
case 'transcript':
return (
<article className="rounded-md border-l-4 border-accent bg-white/[0.06] p-4">
<Header clue={clue} light={false} />
<Header {...pin} clue={clue} light={false} />
<Markdown className="font-serif text-[1.05rem] italic leading-7">{clue.body}</Markdown>
</article>
);
case 'signal':
return (
<article className="rounded-md border border-red-500/30 bg-black/60 p-4">
<Header clue={clue} light={false} mono />
<Header {...pin} clue={clue} light={false} mono />
{clue.signal && <SignalLamp pattern={clue.signal} />}
<Markdown className="text-sm leading-6 text-stone-300">{clue.body}</Markdown>
</article>
@@ -165,7 +186,7 @@ export function ClueCard({ clue }: { clue: ClueView }) {
case 'table':
return (
<article className="rounded-md border border-white/15 bg-white/[0.05] p-4">
<Header clue={clue} light={false} mono />
<Header {...pin} clue={clue} light={false} mono />
<Markdown className="font-mono text-[13px] leading-6">{clue.body}</Markdown>
</article>
);
+19 -4
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, 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,15 +40,27 @@ 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" aria-label={`Difficulty ${game.difficulty} of 3`}>
<span
className="text-xs"
aria-label={`Difficulty ${game.difficulty} of ${MAX_DIFFICULTY}: ${DIFFICULTY_LABEL[game.difficulty]}`}
title={DIFFICULTY_LABEL[game.difficulty]}
>
{difficultyKeys(game.difficulty)}
<span className="opacity-25 grayscale">{difficultyKeys(3 - game.difficulty)}</span>
<span className="opacity-25 grayscale">{difficultyKeys(MAX_DIFFICULTY - game.difficulty)}</span>
</span>
{game.difficulty >= MAX_DIFFICULTY && (
<span className="chip border-red-400/40 text-red-200">☠️ Nightmare</span>
)}
</div>
<h3 className="mt-3 font-display text-2xl font-bold leading-tight">{game.title}</h3>
<p className="mt-2 flex-1 text-sm leading-6 text-stone-300">{game.tagline}</p>
{best?.nightmareSurvivors && (
<div className="mt-3 self-start rounded-full bg-red-500/15 px-2.5 py-1 text-xs font-semibold text-red-200">
☠️ Nightmare Survivors
</div>
)}
<div className="mt-4 flex items-center justify-between gap-2 text-xs text-stone-400">
<span>{best ? `🏆 Best ${formatClock(best.finalSeconds)} · ${best.rank}` : ENGINE_BLURB[game.engine]}</span>
<span className="font-semibold text-accent transition group-hover:translate-x-0.5">Play →</span>
+16 -5
View File
@@ -1,15 +1,17 @@
import { useState } from 'react';
import type { LockView } from '@two-keys/shared';
import { formatClock, type LockView } from '@two-keys/shared';
import type { Send } from '../lib/socket';
import { Sheet } from './ui';
/** Hints unlock in order, cost +2:00 each, and both players see them. */
/** Hints unlock in order, cost penalty time each, and both players see them. */
export function HintButton({ lock, send }: { lock: LockView; send: Send }) {
const [open, setOpen] = useState(false);
const [confirming, setConfirming] = useState(false);
const used = lock.hints.length;
const left = lock.hintsTotal - used;
const nextIsLast = used === lock.hintsTotal - 1;
// A countdown loses time; a stopwatch gains it.
const cost = `${lock.timeLimited ? '−' : '+'}${formatClock(lock.hintPenaltySeconds)}`;
const reveal = () => {
setConfirming(false);
@@ -29,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">+2:00</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) => (
@@ -53,7 +64,7 @@ export function HintButton({ lock, send }: { lock: LockView; send: Send }) {
</div>
) : (
<button className="btn-primary mt-4 w-full" onClick={() => (nextIsLast ? setConfirming(true) : reveal())}>
Reveal hint {used + 1} of {lock.hintsTotal} (+2:00)
Reveal hint {used + 1} of {lock.hintsTotal} ({cost})
</button>
)}
</Sheet>
+46 -1
View File
@@ -1,12 +1,36 @@
import { formatClock, type RoomState } from '@two-keys/shared';
import { useNow } from '../lib/hooks';
/** Elapsed time plus penalties. Runs locally between server updates. */
/** Elapsed time plus penalties, or a countdown when the game has a time limit. Runs locally between server updates. */
export function Timer({ timer, receivedAt }: { timer: RoomState['timer']; receivedAt: number }) {
const now = useNow(1000);
if (timer.startedAt === null) return null;
const serverNow = timer.serverNow + (now - receivedAt);
const elapsed = ((timer.finishedAt ?? serverNow) - timer.startedAt) / 1000;
if (timer.limitSeconds !== null) {
const used = elapsed + timer.penaltySeconds;
const left = Math.max(0, timer.limitSeconds - used);
const label = timer.limitLabel ?? 'Time left';
return (
<div className="flex shrink-0 flex-col items-end" role="timer" aria-label={`${label}: ${formatClock(left)} left`}>
<div className="flex items-baseline gap-1.5">
<span className="max-w-[7.5rem] truncate text-[10px] font-semibold uppercase tracking-wider text-stone-400">{label}</span>
<span className={`font-mono text-base font-bold tabular-nums ${left <= 300 ? 'text-red-300' : ''}`}>
{formatClock(left)}
</span>
</div>
{/* Fills up as the time (and every hint penalty) runs out. */}
<div className="mt-0.5 h-1 w-28 overflow-hidden rounded-full bg-white/10" aria-hidden>
<div
className={`h-full ${left <= 300 ? 'bg-red-400' : 'bg-accent'}`}
style={{ width: `${Math.min(100, (used / timer.limitSeconds) * 100)}%` }}
/>
</div>
</div>
);
}
return (
<div
className="flex items-baseline gap-1.5 font-mono tabular-nums"
@@ -22,3 +46,24 @@ export function Timer({ timer, receivedAt }: { timer: RoomState['timer']; receiv
</div>
);
}
/** A countdown called a water level is drawn as water rising behind the header, 0% → 100% as time runs out. */
export const isWaterLevel = (timer: RoomState['timer']) => timer.limitSeconds !== null && /water/i.test(timer.limitLabel ?? '');
export function WaterGauge({ timer, receivedAt }: { timer: RoomState['timer']; receivedAt: number }) {
const now = useNow(1000);
if (timer.startedAt === null || timer.limitSeconds === null) return null;
const serverNow = timer.serverNow + (now - receivedAt);
const used = ((timer.finishedAt ?? serverNow) - timer.startedAt) / 1000 + timer.penaltySeconds;
const fill = Math.min(100, Math.max(0, (used / timer.limitSeconds) * 100));
return (
<div className="pointer-events-none absolute inset-0 -z-10 overflow-hidden" aria-hidden>
<div
className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-sky-700/70 to-sky-500/40 transition-[height] duration-1000"
style={{ height: `${fill}%` }}
>
{fill > 0 && fill < 100 && <div className="water-wave absolute inset-x-0 -top-1.5 h-3 motion-reduce:hidden" />}
</div>
</div>
);
}
+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>
);
}
+205 -15
View File
@@ -1,12 +1,18 @@
import { useEffect, useState } from 'react';
import type {
ButtonModuleView,
DefuseAction,
DefuseView,
KeypadModuleView,
MorseModuleView,
PasswordsModuleView,
RoomState,
SimonModuleView,
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';
import type { Send } from '../lib/socket';
@@ -25,6 +31,7 @@ const SWATCH: Record<string, string> = {
yellow: '#facc15',
green: '#22c55e',
white: '#f5f5f4',
black: '#0c0a09',
};
const darkText = (color: string) => color === 'yellow' || color === 'white';
@@ -93,12 +100,12 @@ 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">
<span className="w-5 font-mono text-sm text-stone-400">{i + 1}</span>
<span className="h-3 flex-1 rounded-full shadow-inner" style={{ backgroundColor: SWATCH[color] ?? color }} aria-hidden />
<span className="h-3 flex-1 rounded-full border border-white/25 shadow-inner" style={{ backgroundColor: SWATCH[color] ?? color }} aria-hidden />
<span className="w-16 font-mono text-sm font-bold uppercase">{color}</span>
<button
className="btn !min-h-[40px] px-3 text-sm"
@@ -187,10 +194,137 @@ function Keypad({ module, act }: { module: KeypadModuleView; act: (a: DefuseActi
);
}
/** Five letter columns to cycle. The word list is in the manual, not here. */
function Passwords({ module, act }: { module: PasswordsModuleView; act: (a: DefuseAction) => void }) {
const [rows, setRows] = useState<number[]>(() => module.columns.map(() => 0));
const word = module.columns.map((letters, i) => letters[rows[i]]).join('');
const turn = (column: number, by: number) =>
setRows((r) => r.map((row, i) => (i === column ? (row + by + module.columns[i].length) % module.columns[i].length : row)));
return (
<ModuleFrame title="Passwords" solved={module.solved}>
<div className="flex justify-center gap-1.5">
{module.columns.map((letters, i) => (
<div key={i} className="flex flex-col items-center gap-1">
<button className="btn !min-h-[40px] w-11 px-0" disabled={module.solved} onClick={() => turn(i, -1)} aria-label={`Column ${i + 1}, previous letter`}>
▲
</button>
<div
className="flex h-12 w-11 items-center justify-center rounded-lg border-2 border-stone-500 bg-black font-mono text-2xl font-bold"
aria-label={`Column ${i + 1}: ${letters[rows[i]]}, letter ${rows[i] + 1} of ${letters.length}`}
>
{letters[rows[i]]}
</div>
<button className="btn !min-h-[40px] w-11 px-0" disabled={module.solved} onClick={() => turn(i, 1)} aria-label={`Column ${i + 1}, next letter`}>
▼
</button>
<span className="font-mono text-[10px] text-stone-500" aria-hidden>
{rows[i] + 1}/{letters.length}
</span>
</div>
))}
</div>
<button className="btn-primary mt-3 w-full font-mono" disabled={module.solved} onClick={() => act({ type: 'submit', word })}>
Submit {word}
</button>
</ModuleFrame>
);
}
/** A blinking light and a dial. The pattern is only ever shown as light. */
function Morse({ module, act }: { module: MorseModuleView; act: (a: DefuseAction) => void }) {
const [khz, setKhz] = useState(module.minKhz);
const tune = (by: number) => setKhz((f) => Math.min(module.maxKhz, Math.max(module.minKhz, f + by)));
const mhz = (khz / 1000).toFixed(3);
const steps: [string, number][] = [['−10', -10], ['−1', -1], ['+1', 1], ['+10', 10]];
return (
<ModuleFrame title="Morse Light" solved={module.solved}>
{!module.solved && <SignalLamp pattern={module.signal} />}
<div className="rounded-lg border-2 border-stone-600 bg-black px-3 py-2 text-center font-mono text-2xl font-bold tabular-nums" aria-live="polite">
{mhz} <span className="text-sm text-stone-400">MHz</span>
</div>
<input
type="range"
className="mt-3 w-full accent-[rgb(var(--accent))]"
min={module.minKhz}
max={module.maxKhz}
step={1}
value={khz}
disabled={module.solved}
onChange={(e) => setKhz(Number(e.target.value))}
aria-label="Frequency dial"
aria-valuetext={`${mhz} megahertz`}
/>
<div className="mt-2 grid grid-cols-4 gap-2">
{steps.map(([label, by]) => (
<button key={label} className="btn !min-h-[40px] px-0 font-mono text-sm" disabled={module.solved} onClick={() => tune(by)} aria-label={`${by > 0 ? 'Up' : 'Down'} ${Math.abs(by)} kilohertz`}>
{label}
</button>
))}
</div>
<button className="btn-primary mt-3 w-full font-mono" disabled={module.solved} onClick={() => act({ type: 'transmit', frequency: mhz })}>
TX on {mhz}
</button>
</ModuleFrame>
);
}
/** Pads that flash a sequence, then take the answer. Every pad carries its colour name. */
function Simon({ module, act }: { module: SimonModuleView; act: (a: DefuseAction) => void }) {
const [lit, setLit] = useState<string | null>(null);
const flashes = module.flashes.join(',');
useEffect(() => {
if (module.solved) return setLit(null);
const sequence = flashes.split(',');
let timer: ReturnType<typeof setTimeout>;
let i = 0;
// Each flash 600ms on, 300ms off, then a long pause before the sequence repeats.
const show = () => {
setLit(sequence[i]);
timer = setTimeout(hide, 600);
};
const hide = () => {
setLit(null);
i = (i + 1) % sequence.length;
timer = setTimeout(show, i === 0 ? 1800 : 300);
};
timer = setTimeout(show, 800);
return () => clearTimeout(timer);
}, [flashes, module.solved]);
return (
<ModuleFrame title="Say It Back" solved={module.solved}>
<div className="mb-3 flex items-center justify-between font-mono text-xs text-stone-400" aria-live="polite">
<span>Stage {module.stage} of {module.stages}</span>
<span>{module.entered} of {module.flashes.length} pressed</span>
</div>
<div className="grid grid-cols-2 gap-2">
{module.colors.map((color) => (
<button
key={color}
className={`flex h-20 flex-col items-center justify-center rounded-xl border-4 font-mono text-sm font-bold uppercase transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
lit === color ? 'scale-105 border-white opacity-100 shadow-[0_0_24px_4px_rgba(255,255,255,0.45)]' : 'border-black/50 opacity-45'
} ${darkText(color) ? 'text-stone-900' : 'text-white'}`}
style={{ backgroundColor: SWATCH[color] ?? color }}
disabled={module.solved}
onClick={() => act({ type: 'press', color })}
aria-label={`Press ${color}${lit === color ? ' (flashing now)' : ''}`}
>
{color}
<span className="text-[10px]" aria-hidden>{lit === color ? '● FLASH' : '\u00a0'}</span>
</button>
))}
</div>
</ModuleFrame>
);
}
function Device({ view, send }: { view: TechnicianView; send: Send }) {
if (!view.device) return null;
const act = (moduleId: string) => (action: DefuseAction) => send('defuse:action', { moduleId, action });
const { serial, batteries, modules } = view.device;
const { serial, batteries, indicators, modules } = view.device;
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 rounded-2xl border-2 border-stone-600 bg-stone-900/70 p-4 font-mono">
@@ -204,16 +338,41 @@ function Device({ view, send }: { view: TechnicianView; send: Send }) {
{batteries} <span className="text-base" aria-hidden>{'🔋'.repeat(batteries)}</span>
</div>
</div>
{indicators.length > 0 && (
<div className="col-span-2">
<div className="text-[10px] font-bold uppercase tracking-widest text-stone-400">Indicators</div>
<ul className="mt-1 flex flex-wrap gap-2">
{indicators.map((i) => (
<li
key={i.label}
className={`flex items-center gap-1.5 rounded-md border px-2 py-1 text-sm font-bold ${
i.lit ? 'border-amber-300 bg-amber-300/15 text-amber-200' : 'border-stone-600 text-stone-400'
}`}
>
<span className={`h-2.5 w-2.5 rounded-full ${i.lit ? 'bg-amber-300 shadow-[0_0_8px_2px_rgba(252,211,77,0.7)]' : 'bg-stone-700'}`} aria-hidden />
{i.label} <span className="text-[10px] font-normal uppercase">{i.lit ? 'lit' : 'unlit'}</span>
</li>
))}
</ul>
</div>
)}
</div>
{modules.map((m) =>
m.id === 'wires' ? (
<Wires key={m.id} module={m} act={act(m.id)} />
) : m.id === 'button' ? (
<BigButton key={m.id} module={m} act={act(m.id)} />
) : (
<Keypad key={m.id} module={m} act={act(m.id)} />
),
)}
{modules.map((m) => {
switch (m.id) {
case 'wires':
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':
return <Keypad key={m.id} module={m} act={act(m.id)} />;
case 'passwords':
return <Passwords key={m.columns.join('')} module={m} act={act(m.id)} />;
case 'morse':
return <Morse key={m.signal} module={m} act={act(m.id)} />;
case 'simon':
return <Simon key={m.id} module={m} act={act(m.id)} />;
}
})}
</div>
);
}
@@ -221,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>
@@ -250,7 +440,7 @@ export function DefuseGame({ view, state, receivedAt, send }: Props) {
<div className="text-5xl" aria-hidden>💥</div>
<h2 className="mt-2 font-display text-2xl font-bold">Boom.</h2>
<p className="mt-1 text-stone-200">
{view.failReason === 'time' ? 'The clock ran out.' : 'Three strikes.'} Good thing it was only confetti.
{view.failReason === 'time' ? 'The clock ran out.' : `${view.maxStrikes} strikes.`} Good thing it was only confetti.
</p>
<p className="mt-1 text-sm text-stone-400">You keep your keys. A fresh device is waiting.</p>
<button className="btn-primary mt-4 w-full" onClick={() => send('chapter:continue')}>
+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>
);
}
+89 -20
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import type { Dir, ExplorerView, LabyrinthView, NavigatorView, RoomState } from '@two-keys/shared';
import type { Dir, ExplorerView, LabyrinthView, MoveDir, NavigatorView, RoomState } from '@two-keys/shared';
import type { Send } from '../lib/socket';
interface Props {
@@ -28,13 +28,23 @@ function Lanterns({ carried, total }: { carried: number; total: number }) {
// ---------- Navigator: the whole map, but not where the Explorer is ----------
const GATE_ARROW: Record<Dir, string> = { n: '↑', e: '→', s: '↓', w: '←' };
function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerName: string }) {
const width = view.grid[0].length;
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">
{view.banner && (
<p className="rounded-xl border border-accent/50 bg-accent/10 px-4 py-3 text-center text-sm font-semibold text-accent">
🪞 {view.banner}
</p>
)}
<div className="flex items-center justify-between gap-3">
<Lanterns carried={view.keysCarried} total={view.totalKeys} />
<div className="text-right text-sm text-stone-300">
@@ -58,9 +68,16 @@ 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 !== '#') {
content += GATE_ARROW[gate.dir];
if (tone === 'bg-stone-800/40') tone = 'bg-sky-950 font-bold text-accent';
}
return (
<div key={key} className={`flex aspect-square items-center justify-center text-[clamp(11px,4vw,20px)] leading-none ${tone}`}>
{content}
@@ -93,8 +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>
@@ -104,30 +137,42 @@ function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerNam
// ---------- Explorer: one cell, four directions ----------
const DIRS: { dir: Dir; label: string; arrow: string; area: string; keys: string[] }[] = [
type Control = { dir: MoveDir; label: string; arrow: string; area: string; keys: string[] };
const DIRS: Control[] = [
{ dir: 'n', label: 'North', arrow: '▲', area: 'col-start-2 row-start-1', keys: ['ArrowUp', 'w', 'W'] },
{ dir: 'w', label: 'West', arrow: '◀', area: 'col-start-1 row-start-2', keys: ['ArrowLeft', 'a', 'A'] },
{ dir: 'e', label: 'East', arrow: '▶', area: 'col-start-3 row-start-2', keys: ['ArrowRight', 'd', 'D'] },
{ dir: 's', label: 'South', arrow: '▼', area: 'col-start-2 row-start-3', keys: ['ArrowDown', 's', 'S'] },
];
// No compass: the same pad, but relative to the way the Explorer is facing.
const RELATIVE_DIRS: Control[] = [
{ dir: 'f', label: 'Forward', arrow: '▲', area: 'col-start-2 row-start-1', keys: ['ArrowUp', 'w', 'W'] },
{ dir: 'l', label: 'Left', arrow: '◀', area: 'col-start-1 row-start-2', keys: ['ArrowLeft', 'a', 'A'] },
{ dir: 'r', label: 'Right', arrow: '▶', area: 'col-start-3 row-start-2', keys: ['ArrowRight', 'd', 'D'] },
{ dir: 'b', label: 'Back', arrow: '▼', area: 'col-start-2 row-start-3', keys: ['ArrowDown', 's', 'S'] },
];
function ExplorerCell({ view, send, navigatorName }: { view: ExplorerView; send: Send; navigatorName: string }) {
const relative = view.compass === 'relative';
const controls = relative ? RELATIVE_DIRS : DIRS;
// Arrow keys / WASD move too (ignored while typing in the notepad).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && ['INPUT', 'TEXTAREA'].includes(target.tagName)) return;
const match = DIRS.find((d) => d.keys.includes(e.key));
const match = controls.find((d) => d.keys.includes(e.key));
if (!match || !view.open[match.dir]) return;
e.preventDefault();
send('maze:move', { dir: match.dir });
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [view.open, send]);
}, [view.open, controls, send]);
const [symbol, text] = view.glyphLabel ? splitLabel(view.glyphLabel) : ['', ''];
const open = DIRS.filter((d) => view.open[d.dir]).map((d) => d.label.toLowerCase());
const open = controls.filter((d) => view.open[d.dir]).map((d) => d.label.toLowerCase());
const gated = controls.filter((d) => view.gates.includes(d.dir)).map((d) => d.label.toLowerCase());
return (
<div className="space-y-5">
@@ -135,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"
@@ -151,27 +204,43 @@ function ExplorerCell({ view, send, navigatorName }: { view: ExplorerView; send:
)}
{view.message && <p className="mt-3 text-lg italic text-stone-200">{view.message}</p>}
<p className="mt-3 text-sm text-stone-400">
Open passages: <strong className="text-stone-200">{open.join(', ') || 'none'}</strong>
Open: <strong className="text-stone-200">{open.join(', ') || 'none'}</strong>
</p>
{gated.length > 0 && (
<p className="mt-1 text-sm text-stone-400">
Gate (won't open from this side): <strong className="text-stone-200">{gated.join(', ')}</strong>
</p>
)}
</div>
<div className="mx-auto grid w-56 grid-cols-3 grid-rows-3 gap-2" role="group" aria-label="Move">
{DIRS.map((d) => (
<button
key={d.dir}
className={`btn aspect-square text-2xl ${d.area}`}
disabled={!view.open[d.dir]}
onClick={() => send('maze:move', { dir: d.dir })}
aria-label={`Go ${d.label.toLowerCase()}${view.open[d.dir] ? '' : ' (wall)'}`}
>
{d.arrow}
</button>
))}
<div className="col-start-2 row-start-2 flex items-center justify-center text-3xl" aria-hidden>🧭</div>
{controls.map((d) => {
const gate = view.gates.includes(d.dir);
return (
<button
key={d.dir}
className={`btn aspect-square flex-col gap-0 text-2xl ${d.area}`}
disabled={!view.open[d.dir]}
onClick={() => send('maze:move', { dir: d.dir })}
aria-label={gate ? `${d.label}: Gate (won't open from this side)` : `Go ${d.label.toLowerCase()}${view.open[d.dir] ? '' : ' (wall)'}`}
title={gate ? "Gate (won't open from this side)" : undefined}
>
{gate ? '⛩' : d.arrow}
{gate ? (
<span className="text-[10px] font-semibold uppercase leading-tight tracking-wide">Gate</span>
) : (
relative && <span className="text-[10px] font-semibold uppercase tracking-wide">{d.label}</span>
)}
</button>
);
})}
<div className="col-start-2 row-start-2 flex items-center justify-center text-3xl" aria-hidden>{relative ? '👣' : '🧭'}</div>
</div>
<p className="text-center text-sm text-stone-400">
Tell {navigatorName} what's on the floor and which ways are open. There are traps you can't see. Wait for directions!
{relative && ' You have no compass: every step turns you to face the way you just went.'}
</p>
</div>
);
+126 -29
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { motion } from 'framer-motion';
import type { AccusationView, ChapterView, ClueView, LockView, Role, RoomState, StoryView } from '@two-keys/shared';
import { otherRole } from '@two-keys/shared';
@@ -14,11 +14,16 @@ interface Props {
send: Send;
}
function Clues({ clues }: { clues: ClueView[] }) {
interface Pins {
pins: string[];
onPin: (clueId: string) => void;
}
function Clues({ clues, pins, onPin }: { clues: ClueView[] } & Pins) {
return (
<div className="space-y-5">
{clues.map((clue) => (
<ClueCard key={clue.id} clue={clue} />
<ClueCard key={clue.id} clue={clue} pinned={pins.includes(clue.id)} onPin={() => onPin(clue.id)} />
))}
</div>
);
@@ -71,11 +76,28 @@ function Accusation({ finale, role, state, send }: { finale: AccusationView; rol
finale.proposal !== null && finale.questions.every((q) => draft[q.id] === finale.proposal!.answers[q.id]);
const partnerName = state.game.roles[otherRole(role)].name;
const mine = finale.proposal?.by === role;
const lastShot = finale.attemptsLeft === 1;
const [confirming, setConfirming] = useState(false);
useEffect(() => setConfirming(false), [proposalKey, finale.agreed]);
let action;
if (matchesProposal && finale.agreed) {
if (matchesProposal && finale.agreed && confirming) {
action = (
<button className="btn-primary w-full" onClick={() => send('finale:submit')}>
<div className="rounded-2xl border border-red-400/50 bg-red-500/10 p-3 text-center" role="alertdialog" aria-label="Confirm accusation">
<p className="font-semibold">One shot. Are you both sure?</p>
<div className="mt-3 flex gap-2">
<button className="btn-primary flex-1" onClick={() => send('finale:submit')}>
Yes, accuse
</button>
<button className="btn flex-1" onClick={() => setConfirming(false)}>
Not yet
</button>
</div>
</div>
);
} else if (matchesProposal && finale.agreed) {
action = (
<button className="btn-primary w-full" onClick={() => (lastShot ? setConfirming(true) : send('finale:submit'))}>
You both agree. Make the accusation ⚖️
</button>
);
@@ -136,7 +158,9 @@ function Accusation({ finale, role, state, send }: { finale: AccusationView; rol
<div className="sticky bottom-0 -mx-4 border-t border-accent/30 bg-bg/95 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3 backdrop-blur">
<p className="mb-2 text-center text-xs text-stone-400" aria-live="polite">
{finale.attemptsLeft} of {finale.maxAttempts} attempts left
{finale.maxAttempts === 1
? 'One attempt only. A wrong accusation ends the game.'
: `${finale.attemptsLeft} of ${finale.maxAttempts} attempts left`}
{finale.proposal && !finale.agreed && ` · ${mine ? 'You' : partnerName} proposed an accusation`}
</p>
{action}
@@ -145,9 +169,70 @@ function Accusation({ finale, role, state, send }: { finale: AccusationView; rol
);
}
function EvidenceLocker({ view, chapters }: { view: StoryView; chapters: ChapterView[] }) {
const textOf = (clue: ClueView) => `${clue.title}\n${clue.body}`.toLowerCase();
/** Search, filter and star your own clues. Everything here is already on this device. */
function EvidenceLocker({
view,
chapters,
finaleClues,
currentChapter,
pins,
onPin,
}: { view: StoryView; chapters: ChapterView[]; finaleClues: ClueView[]; currentChapter: number } & Pins) {
const [query, setQuery] = useState('');
const [only, setOnly] = useState<number | 'all' | 'starred'>('all');
const q = query.trim().toLowerCase();
const filtering = q !== '' || only !== 'all';
const sections = useMemo(() => {
const all = [
...chapters.map((ch, i) => ({ key: ch.id, index: i, title: `Chapter ${i + 1}: ${ch.title}`, chapter: ch, clues: ch.clues })),
...(finaleClues.length > 0 ? [{ key: 'finale', index: chapters.length, title: 'Finale', chapter: null, clues: finaleClues }] : []),
];
return all
.filter((s) => typeof only !== 'number' || s.index === only)
.map((s) => ({
...s,
clues: s.clues.filter((c) => (only !== 'starred' || pins.includes(c.id)) && (q === '' || textOf(c).includes(q))),
}));
}, [chapters, finaleClues, only, q, pins]);
const found = sections.reduce((n, s) => n + s.clues.length, 0);
const chip = (value: typeof only, label: string) => (
<button
key={String(value)}
aria-pressed={only === value}
onClick={() => setOnly(only === value ? 'all' : value)}
className={`min-h-[36px] shrink-0 rounded-full border px-3 py-1 text-sm transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
only === value ? 'border-accent bg-accent text-stone-950' : 'border-white/15 bg-white/5 hover:bg-white/10'
}`}
>
{label}
</button>
);
return (
<div className="space-y-6">
<div className="space-y-2">
<input
type="search"
className="field"
placeholder="Search your clues…"
aria-label="Search your clues"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1" role="group" aria-label="Show">
{chip('all', 'All')}
{chip('starred', `★ Starred (${pins.length})`)}
{chapters.map((_, i) => chip(i, `Ch ${i + 1}`))}
</div>
<p className="text-xs text-stone-400" aria-live="polite">
{filtering ? `${found} clue${found === 1 ? '' : 's'} found · ` : ''}
Your partner has starred {view.partnerPins} of their clue{view.partnerPins === 1 ? '' : 's'}.
</p>
</div>
{view.suspects.length > 0 && (
<section>
<h3 className="mb-2 text-xs font-bold uppercase tracking-widest text-accent">Suspects</h3>
@@ -163,22 +248,25 @@ function EvidenceLocker({ view, chapters }: { view: StoryView; chapters: Chapter
</ul>
</section>
)}
{chapters.length === 0 && view.suspects.length === 0 && (
<p className="text-stone-400">Clues from earlier chapters will collect here.</p>
)}
{chapters.map((ch, i) => (
<details key={ch.id} className="group" open={i === chapters.length - 1}>
<summary className="cursor-pointer list-none rounded-xl bg-white/5 px-3 py-2.5 font-semibold marker:hidden">
<span className="mr-2 inline-block transition group-open:rotate-90" aria-hidden>▸</span>
Chapter {i + 1}: {ch.title}
{ch.lock.keyName && <span className="ml-2 text-sm font-normal text-accent">🔑 {ch.lock.keyName}</span>}
</summary>
<div className="mt-4 space-y-4 px-1">
{ch.lock.successText && <Markdown className="rounded-xl bg-accent/10 p-3 text-sm leading-6">{ch.lock.successText}</Markdown>}
<Clues clues={ch.clues} />
</div>
</details>
))}
{filtering && found === 0 && <p className="text-stone-400">Nothing matches.</p>}
{sections
.filter((s) => !filtering || s.clues.length > 0)
.map((s) => (
// Filtering opens every match; otherwise the latest solved chapter starts open.
<details key={`${s.key}:${filtering}`} className="group" open={filtering || s.index === currentChapter - 1}>
<summary className="cursor-pointer list-none rounded-xl bg-white/5 px-3 py-2.5 font-semibold marker:hidden">
<span className="mr-2 inline-block transition group-open:rotate-90" aria-hidden>▸</span>
{s.title}
{s.chapter?.lock.keyName && <span className="ml-2 text-sm font-normal text-accent">🔑 {s.chapter.lock.keyName}</span>}
</summary>
<div className="mt-4 space-y-4 px-1">
{!filtering && s.chapter?.lock.successText && (
<Markdown className="rounded-xl bg-accent/10 p-3 text-sm leading-6">{s.chapter.lock.successText}</Markdown>
)}
<Clues clues={s.clues} pins={pins} onPin={onPin} />
</div>
</details>
))}
</div>
);
}
@@ -188,8 +276,10 @@ export function StoryGame({ view, state, role, send }: Props) {
const atFinale = view.finale !== null;
const chapter = atFinale ? null : view.chapters[view.currentChapter];
const stageLabel = atFinale ? 'the finale' : `chapter ${view.currentChapter + 1}`;
// Everything already solved: all chapters once the finale opens, earlier ones before that.
const past = atFinale ? view.chapters : view.chapters.slice(0, view.currentChapter);
const pins = view.pins ?? [];
const onPin = (clueId: string) => send('clue:pin', { clueId });
const finaleClues = view.finale?.type === 'escape' ? view.finale.clues : [];
const clueCount = view.chapters.reduce((n, c) => n + c.clues.length, 0) + finaleClues.length;
// Tell the partner what we're looking at.
useEffect(() => {
@@ -208,7 +298,7 @@ export function StoryGame({ view, state, role, send }: Props) {
{atFinale ? 'Finale' : `Chapter ${view.currentChapter + 1} of ${view.totalChapters}`}
</div>
<button className="btn shrink-0 !min-h-[40px] px-3 py-1 text-sm" onClick={() => setLockerOpen(true)}>
🗄️ Evidence{past.length > 0 ? ` (${past.reduce((n, c) => n + c.clues.length, 0)})` : ''}
🗄️ Evidence ({clueCount}){pins.length > 0 ? ` · ★${pins.length}` : ''}
</button>
</div>
<div className="mt-1">
@@ -226,7 +316,7 @@ export function StoryGame({ view, state, role, send }: Props) {
{chapter && (
<>
<Markdown className="text-lg leading-8 text-stone-200">{chapter.narrative}</Markdown>
<Clues clues={chapter.clues} />
<Clues clues={chapter.clues} pins={pins} onPin={onPin} />
{chapter.lock.solved && view.awaitingContinue ? (
<Unlocked
lock={chapter.lock}
@@ -249,13 +339,20 @@ export function StoryGame({ view, state, role, send }: Props) {
{view.finale?.type === 'escape' && (
<>
<Markdown className="text-lg leading-8 text-stone-200">{view.finale.prompt}</Markdown>
<Clues clues={view.finale.clues} />
<Clues clues={view.finale.clues} pins={pins} onPin={onPin} />
<LockPanel key={view.finale.lock.id} lock={view.finale.lock} send={send} />
</>
)}
<Sheet open={lockerOpen} onClose={() => setLockerOpen(false)} title="Evidence Locker">
<EvidenceLocker view={view} chapters={past} />
<EvidenceLocker
view={view}
chapters={view.chapters}
finaleClues={finaleClues}
currentChapter={view.currentChapter}
pins={pins}
onPin={onPin}
/>
</Sheet>
</div>
);
+192 -33
View File
@@ -1,5 +1,6 @@
import { useRef, useState, type FormEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react';
import { motion } from 'framer-motion';
import type { Role, RoomState, SyncView } from '@two-keys/shared';
import type { Role, RoomState, SyncAnswer, SyncView } from '@two-keys/shared';
import { otherRole } from '@two-keys/shared';
import { RoleBadge } from '../components/ui';
import type { Send } from '../lib/socket';
@@ -34,7 +35,7 @@ function Marks({ view }: { view: SyncView }) {
}
/** One answer card that flips over when both answers are in. */
function FlipCard({ role, name, answer, delay }: { role: Role; name: string; answer: string; delay: number }) {
function FlipCard({ role, name, answer, delay }: { role: Role; name: string; answer: ReactNode; delay: number }) {
return (
<div className="flex-1 [perspective:800px]">
<motion.div
@@ -50,6 +51,132 @@ function FlipCard({ role, name, answer, delay }: { role: Role; name: string; ans
);
}
/** Telepathy: one typed word, no options to lean on. */
function WordInput({ onSubmit }: { onSubmit: (text: string) => void }) {
const [text, setText] = useState('');
const submit = (e: FormEvent) => {
e.preventDefault();
if (text.trim()) onSubmit(text.trim());
};
return (
<form onSubmit={submit} className="flex gap-2">
<input
className="field text-lg"
value={text}
onChange={(e) => setText(e.target.value)}
maxLength={30}
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
placeholder="One word"
aria-label="Your word"
/>
<button type="submit" className="btn-primary shrink-0" disabled={!text.trim()}>
Lock in
</button>
</form>
);
}
/**
* Ranking: put the items in order, most at the top. Drag the grip (mouse or touch), or use the
* up/down buttons (keyboard and screen readers).
*/
function RankPicker({ items, onSubmit }: { items: string[]; onSubmit: (order: number[]) => void }) {
const [order, setOrder] = useState<number[]>(() => items.map((_, i) => i));
const [dragging, setDragging] = useState<number | null>(null);
const [announce, setAnnounce] = useState('');
const rows = useRef<(HTMLLIElement | null)[]>([]);
const move = (from: number, to: number) => {
if (to < 0 || to >= order.length || from === to) return;
setOrder((o) => {
const next = [...o];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
return next;
});
setAnnounce(`${items[order[from]]} moved to number ${to + 1}.`);
};
// Pointer dragging: whichever row the pointer is over becomes the dragged item's new place.
const onPointerMove = (e: ReactPointerEvent) => {
if (dragging === null) return;
const from = order.indexOf(dragging);
const to = rows.current.findIndex((row) => {
const r = row?.getBoundingClientRect();
return r !== undefined && e.clientY >= r.top && e.clientY <= r.bottom;
});
if (to >= 0 && to !== from) move(from, to);
};
return (
<div className="space-y-3">
<p className="text-center text-xs text-stone-400">Most at the top, least at the bottom.</p>
<ol className="grid gap-2 touch-none select-none" onPointerMove={onPointerMove} onPointerUp={() => setDragging(null)} onPointerCancel={() => setDragging(null)}>
{order.map((item, place) => (
<li
key={item}
ref={(el) => (rows.current[place] = el)}
className={`flex min-h-[56px] items-center gap-2 rounded-xl border px-2 py-2 text-lg font-medium transition-colors ${
dragging === item ? 'border-accent bg-accent/25' : 'border-white/15 bg-white/5'
}`}
>
<button
type="button"
className="flex h-10 w-8 shrink-0 cursor-grab items-center justify-center rounded-lg text-stone-400 active:cursor-grabbing"
aria-hidden
tabIndex={-1}
onPointerDown={(e) => {
e.preventDefault();
(e.currentTarget.closest('ol') as HTMLElement | null)?.setPointerCapture(e.pointerId);
setDragging(item);
}}
>
⠿
</button>
<span
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full border font-mono text-sm font-bold ${
place === 0 || place === order.length - 1 ? 'border-accent bg-accent text-stone-950' : 'border-white/25 text-stone-400'
}`}
aria-hidden
>
{place + 1}
</span>
<span className="min-w-0 flex-1">{items[item]}</span>
<div className="flex shrink-0 gap-1">
<button
type="button"
className="btn !min-h-[40px] !px-2.5 !py-1"
disabled={place === 0}
onClick={() => move(place, place - 1)}
aria-label={`Move ${items[item]} up, now number ${place + 1}`}
>
▲
</button>
<button
type="button"
className="btn !min-h-[40px] !px-2.5 !py-1"
disabled={place === order.length - 1}
onClick={() => move(place, place + 1)}
aria-label={`Move ${items[item]} down, now number ${place + 1}`}
>
▼
</button>
</div>
</li>
))}
</ol>
<p className="sr-only" aria-live="polite">
{announce}
</p>
<button className="btn-primary w-full" onClick={() => onSubmit(order)}>
Lock in this order
</button>
</div>
);
}
export function SyncGame({ view, state, role, send }: Props) {
const roles = state.game.roles;
const partnerName = roles[otherRole(role)].name;
@@ -108,8 +235,40 @@ export function SyncGame({ view, state, role, send }: Props) {
const q = view.question;
if (!q) return null;
const predict = view.round.type === 'predict';
const { type } = view.round;
const subjectName = view.round.subject ? roles[view.round.subject].name : '';
const answered = view.myAnswer !== null;
let instruction: string;
if (type === 'match') instruction = 'Pick what you think your partner will pick.';
else if (type === 'telepathy') instruction = 'Type ONE word. The same word as your partner, hopefully.';
else if (type === 'rank') {
instruction = view.iAmSubject
? "This one's about you. Put them in order, most to least. Be honest!"
: `Put them in the order ${subjectName} would, most to least. The top and the bottom have to match.`;
} else instruction = view.iAmSubject ? "This one's about you. Answer honestly!" : `Guess what ${subjectName} will say.`;
/** An answer as the reveal cards show it. */
const show = (answer: SyncAnswer): ReactNode => {
if (typeof answer === 'string') return answer;
if (typeof answer === 'number') return q.options[answer];
return (
<ol className="w-full space-y-1 text-left text-sm sm:text-base">
{answer.map((item, place) => {
// #1 and the last place are the ones that score.
const scores = place === 0 || place === answer.length - 1;
return (
<li
key={item}
className={scores ? 'rounded-md bg-accent/20 px-1.5 py-0.5 text-accent' : 'px-1.5 font-normal text-stone-400'}
>
{place + 1}. {q.options[item]}
</li>
);
})}
</ol>
);
};
return (
<div className="space-y-5">
@@ -120,39 +279,39 @@ export function SyncGame({ view, state, role, send }: Props) {
Question {view.questionNumber} of {view.questionsPerRound}
</div>
<p className="mt-2 font-display text-2xl font-bold leading-snug">{q.text}</p>
{view.status === 'question' && (
<p className="mt-2 text-sm text-accent">
{!predict
? 'Pick what you think your partner will pick.'
: view.iAmSubject
? "This one's about you. Answer honestly!"
: `Guess what ${subjectName} will say.`}
</p>
)}
{view.status === 'question' && <p className="mt-2 text-sm text-accent">{instruction}</p>}
</div>
{view.status === 'question' && (
<>
<div className="grid gap-2" role="radiogroup" aria-label={q.text}>
{q.options.map((option, i) => (
<button
key={i}
role="radio"
aria-checked={view.myAnswer === i}
disabled={view.myAnswer !== null}
onClick={() => send('sync:answer', { questionId: q.id, optionId: i })}
className={`min-h-[52px] rounded-xl border px-4 py-3 text-left text-lg font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
view.myAnswer === i
? 'border-accent bg-accent/25'
: 'border-white/15 bg-white/5 hover:bg-white/10 disabled:opacity-40'
}`}
>
{option}
</button>
))}
</div>
{answered && type !== 'match' && type !== 'predict' ? (
<div className="card text-center font-display text-lg font-bold">{show(view.myAnswer!)}</div>
) : type === 'telepathy' ? (
<WordInput key={q.id} onSubmit={(text) => send('sync:answer', { questionId: q.id, text })} />
) : type === 'rank' ? (
<RankPicker key={q.id} items={q.options} onSubmit={(order) => send('sync:answer', { questionId: q.id, order })} />
) : (
<div className="grid gap-2" role="radiogroup" aria-label={q.text}>
{q.options.map((option, i) => (
<button
key={i}
role="radio"
aria-checked={view.myAnswer === i}
disabled={answered}
onClick={() => send('sync:answer', { questionId: q.id, optionId: i })}
className={`min-h-[52px] rounded-xl border px-4 py-3 text-left text-lg font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
view.myAnswer === i
? 'border-accent bg-accent/25'
: 'border-white/15 bg-white/5 hover:bg-white/10 disabled:opacity-40'
}`}
>
{option}
</button>
))}
</div>
)}
<p className="text-center text-sm text-stone-400" aria-live="polite">
{view.myAnswer !== null
{answered
? couch
? `Locked in. Pass the device to ${partnerName}.`
: `Locked in. Waiting for ${partnerName}…`
@@ -166,8 +325,8 @@ export function SyncGame({ view, state, role, send }: Props) {
{view.status === 'reveal' && view.reveal && (
<div className="space-y-4" aria-live="polite">
<div className="flex gap-3">
<FlipCard role="A" name={roles.A.name} answer={q.options[view.reveal.A]} delay={0} />
<FlipCard role="B" name={roles.B.name} answer={q.options[view.reveal.B]} delay={0.15} />
<FlipCard role="A" name={roles.A.name} answer={show(view.reveal.A)} delay={0} />
<FlipCard role="B" name={roles.B.name} answer={show(view.reveal.B)} delay={0.15} />
</div>
<motion.p
className="text-center font-display text-2xl font-bold"
+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>
);
}
+21 -3
View File
@@ -101,18 +101,24 @@ body {
.md th:empty {
display: none;
}
/* Stencil grids and box-drawing keypads need tight lines so the boxes join up. */
.md pre {
overflow-x: auto;
font-family: 'JetBrains Mono', monospace;
max-width: 100%;
font-family: 'JetBrains Mono', ui-monospace, 'Noto Sans Symbols 2', monospace;
font-size: 0.9em;
line-height: 1.5;
line-height: 1.15;
white-space: pre;
}
.md pre code.language-sql {
display: block;
line-height: 1.5;
}
.md pre code {
font-size: inherit;
}
.md code {
font-family: 'JetBrains Mono', monospace;
font-family: 'JetBrains Mono', ui-monospace, 'Noto Sans Symbols 2', monospace;
font-size: 0.92em;
}
.md .scroll-x {
@@ -131,6 +137,18 @@ body {
box-shadow: 0 0 28px 10px rgb(255 77 61 / 0.65), inset 0 0 10px rgb(255 220 200 / 0.8);
}
/* ---------- Water gauge (Deep Lab Nereid) ---------- */
.water-wave {
background: radial-gradient(ellipse 12px 6px at 12px 6px, rgb(56 189 248 / 0.55) 98%, transparent) repeat-x;
background-size: 24px 12px;
animation: wave 2.4s linear infinite;
}
@keyframes wave {
to {
background-position-x: 24px;
}
}
@keyframes shake {
20%,
60% {
+7 -1
View File
@@ -36,6 +36,8 @@ export interface BestResult {
rank: string;
hintsUsed: number;
keysEarned: number;
/** Ever finished this (difficulty 5) game with zero hints. Kept once earned. */
nightmareSurvivors?: boolean;
}
export function getBest(gameId: string): BestResult | null {
@@ -53,6 +55,10 @@ export function saveBest(gameId: string, result: BestResult): boolean {
!best ||
result.keysEarned > best.keysEarned ||
(result.keysEarned === best.keysEarned && result.finalSeconds < best.finalSeconds);
if (better) write(`twokeys:best:${gameId}`, JSON.stringify(result));
const nightmareSurvivors = Boolean(result.nightmareSurvivors || best?.nightmareSurvivors);
if (better) write(`twokeys:best:${gameId}`, JSON.stringify({ ...result, nightmareSurvivors }));
else if (nightmareSurvivors && !best?.nightmareSurvivors) {
write(`twokeys:best:${gameId}`, JSON.stringify({ ...best, nightmareSurvivors }));
}
return better;
}
+23 -2
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,9 +37,30 @@ 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' };
export const ROLE_BG: Record<Role, string> = { A: 'bg-roleA/15', B: 'bg-roleB/15' };
export const difficultyKeys = (n: number) => '🔑'.repeat(n);
export const MAX_DIFFICULTY = 5;
export const DIFFICULTY_LABEL: Record<number, string> = { 1: 'Easy', 2: 'Medium', 3: 'Hard', 4: 'Expert', 5: 'Nightmare' };
export const difficultyKeys = (n: number) => '🔑'.repeat(Math.max(0, n));
+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.'],
+41 -16
View File
@@ -1,16 +1,36 @@
import { useState } from 'react';
import type { Category } from '@two-keys/shared';
import { useState, type ReactNode } from 'react';
import { GameCard } from '../components/GameCard';
import { SiteShell } from '../components/SiteShell';
import { useGames } from '../lib/hooks';
import { CATEGORY_LABEL } 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 }) {
return (
<button
aria-pressed={active}
onClick={onClick}
className={`min-h-[40px] rounded-full border px-4 py-1.5 text-sm font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
active ? 'border-accent bg-accent text-stone-950' : 'border-white/15 bg-white/5 hover:bg-white/10'
}`}
>
{children}
</button>
);
}
export function Library() {
const games = useGames();
const [filter, setFilter] = useState<Category | 'all'>('all');
const shown = (games ?? []).filter((g) => filter === 'all' || g.category === filter);
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' || 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);
return (
<SiteShell>
@@ -21,16 +41,20 @@ export function Library() {
<div className="mt-5 flex flex-wrap gap-2" role="group" aria-label="Filter by category">
{FILTERS.map((f) => (
<button
key={f}
aria-pressed={filter === f}
onClick={() => setFilter(f)}
className={`min-h-[40px] rounded-full border px-4 py-1.5 text-sm font-medium transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
filter === f ? 'border-accent bg-accent text-stone-950' : 'border-white/15 bg-white/5 hover:bg-white/10'
}`}
>
{f === 'all' ? 'All games' : CATEGORY_LABEL[f]}
</button>
<Chip key={f} active={filter === f} onClick={() => setFilter(f)}>
{f === 'all' ? 'All games' : FILTER_LABEL[f]}
</Chip>
))}
</div>
<div className="mt-2 flex flex-wrap gap-2" role="group" aria-label="Filter by difficulty">
<Chip active={level === null} onClick={() => setLevel(null)}>
Any difficulty
</Chip>
{LEVELS.map((d) => (
<Chip key={d} active={level === d} onClick={() => setLevel(level === d ? null : d)}>
{DIFFICULTY_LABEL[d]}
{d === MAX_DIFFICULTY ? ' ☠️' : ''}
</Chip>
))}
</div>
@@ -43,6 +67,7 @@ export function Library() {
{shown.map((game) => (
<GameCard key={game.id} game={game} />
))}
{shown.length === 0 && <p className="text-stone-400">No games match those filters.</p>}
</div>
)}
</SiteShell>
+4 -3
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, 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,9 +49,10 @@ 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 3`}>{difficultyKeys(game.difficulty)}</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>}
</div>
<h1 className="mt-3 font-display text-4xl font-bold leading-tight">{game.title}</h1>
<p className="mt-2 text-stone-300">{game.tagline}</p>
+11 -2
View File
@@ -5,12 +5,15 @@ import { KeyRing } from '../components/KeyRing';
import { Notepad } from '../components/Notepad';
import { PartnerStatus, PingOverlay } from '../components/PartnerStatus';
import { PassDeviceScreen } from '../components/PassDeviceScreen';
import { Timer } from '../components/Timer';
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' };
@@ -45,8 +48,9 @@ export function Play({ state, receivedAt }: { state: RoomState; receivedAt: numb
return (
<div className="mx-auto w-full max-w-2xl px-4" style={{ '--role': ROLE_COLOR[activeRole] } as CSSProperties}>
<header
className={`${view?.engine === 'defuse' ? 'relative' : 'sticky top-0'} z-20 -mx-4 border-b border-white/10 bg-bg/95 px-4 py-2 backdrop-blur`}
className={`${view?.engine === 'defuse' ? 'relative' : 'sticky top-0'} isolate z-20 -mx-4 border-b border-white/10 bg-bg/95 px-4 py-2 backdrop-blur`}
>
{isWaterLevel(state.timer) && <WaterGauge timer={state.timer} receivedAt={receivedAt} />}
<div className="flex items-center justify-between gap-3">
<Link to="/" className="-my-2 shrink-0 py-2 text-lg focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent" aria-label="Two Keys home">
🔑🔑
@@ -81,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} />}
+9 -2
View File
@@ -38,6 +38,7 @@ export function Results({ state, result }: { state: RoomState; result: GameResul
rank: result.rank,
hintsUsed: result.hintsUsed,
keysEarned: result.keysEarned,
nightmareSurvivors: result.nightmareSurvivors,
}),
);
}, [game.id, result]);
@@ -55,9 +56,15 @@ export function Results({ state, result }: { state: RoomState; result: GameResul
animate={{ scale: 1, rotate: 0, opacity: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 12 }}
>
{result.success ? (RANK_ICON[result.rank] ?? '🔑') : '🕯️'}
{result.outOfTime ? '⌛' : result.success ? (RANK_ICON[result.rank] ?? '🔑') : '🕯️'}
</motion.div>
<h1 className="mt-3 font-display text-4xl font-bold">{result.rank}</h1>
{/* Out of time gets no rank badge. */}
<h1 className="mt-3 font-display text-4xl font-bold">{result.outOfTime ? 'Out of time' : result.rank}</h1>
{result.nightmareSurvivors && (
<p className="mx-auto mt-3 w-fit rounded-full bg-red-500/15 px-4 py-1.5 font-semibold text-red-200">
☠️ Nightmare Survivors: a difficulty-5 game with zero hints
</p>
)}
{isBest && <p className="mt-1 text-sm font-semibold text-accent">🏆 New personal best</p>}
</header>
+8 -5
View File
@@ -12,12 +12,15 @@ export default {
roleA: '#f5b942',
roleB: '#2dd4bf',
},
// Noto Sans Symbols 2 covers the Braille and alchemy symbols (⠍ ☉ ♄ ☿) the clues use.
fontFamily: {
display: ['var(--heading-font)', 'Playfair Display', 'Georgia', 'serif'],
serif: ['Playfair Display', 'Georgia', 'serif'],
sans: ['Inter', 'system-ui', 'sans-serif'],
hand: ['Caveat', 'cursive'],
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
display: ['var(--heading-font)', 'Playfair Display', 'Georgia', 'Noto Sans Symbols 2', 'serif'],
serif: ['Playfair Display', 'Georgia', 'Noto Sans Symbols 2', 'serif'],
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'],
},
},
},
+208
View File
@@ -0,0 +1,208 @@
id: alchemists-vault
engine: story
category: escape-room
title: The Alchemist's Vault
tagline: Prague, 1602. The alchemist's cellar. The door only opens for those who understand the elements.
estimatedMinutes: 25
difficulty: 3
players: { min: 2, max: 2 }
theme: { primary: "#2d5a27", accent: "#d4af37", background: "#0b100a", headingFont: "Playfair Display" }
rules:
hintPenaltySeconds: 180
roles:
A:
name: The Apprentice
blurb: You're at the workbench. Flasks, scales, plaques and the vault door.
B:
name: The Scribe
blurb: You're in the library alcove. Tables, charts, and the master's books.
intro: |
You came to the old alchemist's house to deliver a package. The cellar door swung shut behind you
and a bar dropped across it.
A brass speaking-tube carries your voices between the workbench and the library alcove.
Carved above the vault door:
> *"Gold is not made. It is understood. — Master Kelley"*
chapters:
- id: ch1
title: The Five Flasks
narrative: |
Five sealed flasks stand on the workbench, each etched with a number and a colour of flame.
A wooden cabinet beside them has a six-letter word lock.
clues:
A:
- id: a1-flasks
title: The five flasks (left to right as they stand)
type: table
body: |
| Flask | Etched number | Flame colour |
|-------|---------------|--------------|
| 1 | 92 | Green |
| 2 | 7 | Orange |
| 3 | 16 | Violet |
| 4 | 32 | Red |
| 5 | 53 | Yellow |
B:
- id: b1-table
title: Master Kelley's table of elements (a page)
type: table
body: |
| No. | Symbol | No. | Symbol | No. | Symbol |
|-----|--------|-----|--------|-----|--------|
| 6 | C | 26 | Fe | 53 | I |
| 7 | N | 29 | Cu | 79 | Au |
| 8 | O | 30 | Zn | 80 | Hg |
| 16 | S | 32 | Ge | 82 | Pb |
| 19 | K | 47 | Ag | 92 | U |
- id: b1-recipe
title: Recipe card on the lectern
type: note
body: |
*Read the flasks by their fire, as the rainbow burns:
**red, orange, yellow, green, violet.** Their signs will name what I see in my pupil.*
lock:
id: lock-flasks
prompt: Enter the cabinet word.
inputType: text
format: 6 letters
answers: ["GENIUS"]
hints:
- Order the flasks by flame colour, using the rainbow order from the recipe card.
- "Red is flask 4 (32), orange is flask 2 (7), yellow is flask 5 (53)..."
- 32 Ge, 7 N, 53 I, 92 U, 16 S = GeNIUS.
successText: |
**GENIUS.** The cabinet opens: a set of brass weights and a lump of dark, heavy stone wrapped in cloth.
Painted on the inside of the door: *"Weigh the stone."*
keyName: Flame Key
- id: ch2
title: The Philosopher's Stone
narrative: |
Two balance scales stand on the workbench, already loaded, perfectly level.
A dial on the wall wants the weight of the stone in drams.
clues:
A:
- id: a2-scales
title: The two balance scales
type: diagram
body: |
```
SCALE ONE (balanced)
left pan: the STONE + 1 lead weight
right pan: 2 gold weights + 1 silver weight
SCALE TWO (balanced)
left pan: 2 lead weights
right pan: 1 gold weight + 1 silver weight
```
The weights carry the symbols of their metals, but no numbers.
B:
- id: b2-ledger
title: Weights ledger, margin notes
type: note
body: |
*The silver weight is stamped **5 drams**. I lost the other tags.
I remember only that a gold weight is **three times** a silver one.*
lock:
id: lock-stone
prompt: How many drams does the stone weigh?
inputType: code
format: A number
answers: ["25"]
hints:
- Work out gold first, then use scale two to find lead.
- Gold = 15. Scale two says 2 lead = 15 + 5, so lead = 10.
- "Scale one: stone + 10 = 15 + 15 + 5 = 35, so the stone is 25."
successText: |
**25 drams.** The dial clicks, and a stone panel slides aside, revealing a brass plaque of raised dots.
keyName: Scale Key
- id: ch3
title: The Dotted Plaque
narrative: |
The plaque is covered in raised dots. Beneath it, a two-digit brass dial.
Master Kelley went blind in his last years. He wrote his secrets for his fingers.
clues:
A:
- id: a3-plaque
title: The plaque
type: diagram
body: |
## ⠍⠑⠗⠉⠥⠗⠽
*Under it, engraved: "Turn the dial to my **number**."*
B:
- id: b3-braille
title: A card of raised-dot letters (from the master's desk)
type: table
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 | |
- id: b3-names
title: Old names for the metals (bookmark)
type: note
body: |
Quicksilver = **Mercury** = Hg. *The only metal that runs like water.*
lock:
id: lock-plaque
prompt: Set the two-digit dial.
inputType: code
format: 2 digits
answers: ["80"]
hints:
- Decode the plaque letter by letter using the dot card.
- The word is MERCURY. "My number" is its number in the table of elements.
- Mercury (Hg) is 80.
successText: |
**80.** Somewhere in the walls, chains rattle. The vault door's last lock lights up:
four alchemical symbols glowing in a row.
keyName: Quicksilver Key
finale:
type: escape
prompt: The vault door has a three-digit wheel and four glowing symbols.
clues:
A:
- id: af-door
title: The vault door
type: diagram
body: |
```
☉ ☽ ♂ ♄
```
*Carved around the wheel: "Add up the hearts of my four metals."*
B:
- id: bf-symbols
title: Chart of the seven metals
type: table
body: |
| Symbol | Planet | Metal |
|--------|---------|---------|
| ☉ | Sun | Gold |
| ☽ | Moon | Silver |
| ☿ | Mercury | Mercury |
| ♀ | Venus | Copper |
| ♂ | Mars | Iron |
| ♃ | Jupiter | Tin |
| ♄ | Saturn | Lead |
*"A metal's heart is its number."*
lock:
id: lock-vault
prompt: Turn the wheel to the right number.
inputType: code
format: 3 digits
answers: ["234"]
hints:
- Name the four metals from the chart, then find each one's number in the table of elements from the first chapter.
- Gold 79, Silver 47, Iron 26, Lead 82.
- 79 + 47 + 26 + 82 = 234.
successEnding: |
**234.** The vault swings open. No gold inside. Just a mirror, and a note:
> *"You found it together. That was always the gold."*
The cellar door's bar lifts. **You escaped!** ⚗️🔑
+4 -4
View File
@@ -3,8 +3,8 @@ engine: story
category: murder-mystery
title: The Last Set at the Blue Lantern
tagline: Cape Town, 1958. The band played on. The boss didn't.
estimatedMinutes: 50
difficulty: 2
estimatedMinutes: 10
difficulty: 1
players: { min: 2, max: 2 }
theme: { primary: "#1f3a68", accent: "#e8b04a", background: "#0b0f1c", headingFont: "Playfair Display" }
roles:
@@ -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.** 🎨🔑
+1 -1
View File
@@ -3,7 +3,7 @@ engine: story
category: escape-room
title: The Clockmaker's Attic
tagline: Locked in an old clockmaker's attic. Every clock is ticking. So are you.
estimatedMinutes: 40
estimatedMinutes: 15
difficulty: 1
players: { min: 2, max: 2 }
theme: { primary: "#8a5a2b", accent: "#f2c66d", background: "#17110b", headingFont: "Playfair Display" }
+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**.
+340
View File
@@ -0,0 +1,340 @@
id: dead-air
engine: story
category: murder-mystery
title: Dead Air
tagline: A legendary DJ goes silent live on air. Four minutes of nothing. One killer in the building.
estimatedMinutes: 30
difficulty: 3
players: { min: 2, max: 2 }
theme: { primary: "#d7263d", accent: "#f9c80e", background: "#0c0a12", headingFont: "Playfair Display" }
rules:
hintPenaltySeconds: 180
roles:
A:
name: The Detective
blurb: You have the police file. Lab results, statements, staff records.
B:
name: The Night Intern
blurb: You work the graveyard shift at Night Owl FM. Logs, run sheets, cameras, gossip.
intro: |
**Night Owl FM, Johannesburg. 23:31.**
Rex Daniels has hosted the midnight show for 22 years. Tonight he promised listeners
"the biggest announcement in the history of this station, at midnight sharp."
He never made it. A song ended, and nothing came after it. Just hiss.
The producer found him slumped over the desk, his tea still warm.
Five people were in the building. **Every one of them had a reason.**
⚠️ *Harder case: the locks give you facts, not the answer. You'll have to connect them yourselves in the accusation.*
suspects:
- id: zanele
name: Zanele Dube
role: Producer
description: Runs the desk, makes Rex's tea, knows his every habit. Has been passed over for promotion three times.
- id: kyle
name: Kyle Pretorius
role: Junior DJ
description: Ambitious, loud, and openly waiting for Rex's slot.
- id: marisa
name: Marisa Venter
role: Station manager
description: Co-owns the station with Rex. Was "in meetings" all night.
- id: tumi
name: Tumi Nkosi
role: Sound engineer
description: Keeps the old place running on duct tape and prayer. Rex once got him written up.
- id: owen
name: Owen Hart
role: Superfan
description: Calls in every night. Turned up at the gate tonight with flowers for Rex.
chapters:
- id: ch1
title: Ghost Signal
narrative: |
The automation computer kept playing what it was told. The dead-air alarm caught the silence.
First question: **which song was playing when Rex went quiet for good?**
clues:
A:
- id: a1-automation
title: Automation log, tonight's queue
type: log
body: |
| # | Track | Length | Status |
|---|-----------------------------|--------|----------|
| 1 | Neon Rain | 4:12 | PLAYED |
| 2 | Highveld Heart | 3:48 | PLAYED |
| 3 | Slow Dance in a Fast Car | 5:05 | PLAYED |
| 4 | Paper Moons | 3:55 | PLAYED |
| 5 | Ghost Signal | 6:20 | PLAYED |
| 6 | Last Train Home | 4:40 | NOT PLAYED (needs manual start) |
- id: a1-security
title: Gate security log
type: log
body: |
22:30 · Owen Hart at gate with flowers. Not on guest list. Refused entry.
22:30–23:50 · Owen sits in reception with guard Sipho. "Wouldn't stop talking about Rex."
B:
- id: b1-runsheet
title: Producer's run sheet
type: document
body: |
**Midnight Show, standard format**
- Show opens **23:00:00** sharp. Rex's intro link: **2:00**.
- Rex talks for **exactly 1:30** after every song. He's never late. Ever.
- Songs play straight from automation. Rex must start each song manually.
- id: b1-alarm
title: Dead-air alarm printout
type: log
body: |
```
** DEAD AIR ALARM ** 23:31:35
Trigger: silence > 15 seconds on main output
```
lock:
id: lock-song
prompt: Which song was the last thing listeners heard?
inputType: text
format: Song title
answers: ["GHOSTSIGNAL"]
hints:
- The alarm fires 15 seconds after silence starts. Work out when the silence started, then build the show's timeline.
- Silence started at 23:31:20. Add the intro, each song, and each 1:30 talk break, starting from 23:00:00.
- Ghost Signal ran from 23:25:00 to 23:31:20, and Rex never came back to talk after it.
successText: |
**Ghost Signal**, 23:25:00 to 23:31:20. Rex's last words on air ended at 23:25:00:
*"Longest song of the night, which means it's tea time. Back in six, Joburg."*
He never started Last Train Home. Whatever happened, it happened during Ghost Signal.
keyName: Needle Key
- id: ch2
title: The Last Cup
narrative: |
The paramedics say anaphylactic shock. Rex had a severe peanut allergy and everyone knew it.
His EpiPen was not in his bag. And he'd been drinking tea all night.
clues:
A:
- id: a2-lab
title: Lab report, rushed
type: report
body: |
**Cause of death:** anaphylaxis (peanut protein, Ara h 2).
| Item tested | Peanut protein |
|---------------------------------------|----------------|
| Mug "ON AIR" (in Rex's hand) | **POSITIVE** |
| Mug "WORLD'S OKAYEST DJ" (studio sink)| negative (honey residue present) |
| Jar labelled "REX ONLY" (honey) | **POSITIVE** |
| Tea bags, kettle water, milk | negative |
- id: a2-minutes
title: Boardroom meeting minutes
type: document
body: |
**Quick staff meeting, 23:08–23:20.** Topic: Rex's "midnight announcement".
Present: Zanele Dube, Tumi Nkosi, Kyle Pretorius, Marisa Venter (chair).
*23:11 · Marisa steps out "to take a call from the lawyers". Returns 23:17.*
Nobody else left the room. (Rex on air, Owen in reception.)
B:
- id: b2-camera
title: Kitchen camera log (motion triggered)
type: log
body: |
| Time | Who | What |
|-------|---------|------|
| 22:45 | Rex | Makes himself rooibos. **Black, no honey.** |
| 22:58 | Tumi | Fills water bottle |
| 23:05 | Kyle | Instant coffee |
| 23:06 | Zanele | Makes Rex's tea in the **WORLD'S OKAYEST DJ** mug, honey from **REX ONLY** jar. Takes it to studio. |
| 23:07 | (empty) | · |
| 23:09 | · | **CAMERA OFFLINE** |
| 23:21 | · | Camera back online. Kitchen empty. |
| 23:24 | Zanele | Makes Rex's tea in the **ON AIR** mug, honey from **REX ONLY** jar. Takes it to studio. |
- id: b2-maintenance
title: Tumi's maintenance whiteboard
type: note
body: |
*Generator test TONIGHT 23:09 (booked by M. Venter this afternoon, last minute??).
Cameras + kitchen plugs drop for about 12 min. Studio is on the UPS, stays live.*
lock:
id: lock-safe-honey
prompt: What's the last time the honey was definitely safe?
inputType: code
format: "HH:MM"
answers: ["2306", "1106", "1106PM"]
hints:
- The honey jar is positive now. Was it always? Look at the first mug with honey in it.
- The OKAYEST DJ mug had honey and tested negative. When was that cup made?
- Zanele made the OKAYEST DJ cup at 23:06, and Rex drank it and was fine.
successText: |
**23:06.** The honey was clean at 23:06 and poisoned by 23:24.
The camera was on from 23:06 to 23:09 and from 23:21 to 23:24, and nobody touched the jar then.
So the honey was poisoned **while the camera was off, 23:09 to 23:21.**
keyName: Honey Key
- id: ch3
title: The Studio Door
narrative: |
Rex's EpiPen always lived in the side pocket of his bag, which sits in the studio lounge.
The lounge has a card reader. Someone went in while Rex was live in the booth.
clues:
A:
- id: a3-register
title: Staff access card register
type: table
body: |
| Staff member | Card number |
|-------------------|-------------|
| Rex Daniels | 401 |
| Zanele Dube | 409 |
| Marisa Venter | 412 |
| Kyle Pretorius | 417 |
| Tumi Nkosi | 423 |
- id: a3-kyle
title: Kyle's statement
type: transcript
body: |
"I had a one-on-one with Marisa in the boardroom at half ten. Left my card on the table.
Went back for it at ten to eleven and it was gone. I reported it to security, check the book.
Then I was stuck in that meeting from eight past till twenty past. Ask anyone."
B:
- id: b3-doorlog
title: Studio lounge door reader
type: log
body: |
```
CARD READER v2.1 · IDs IN HEX (Tumi: "don't ask")
22:45:02 ID 0x191 IN
23:12:40 ID 0x1A1 IN
23:13:55 ID 0x1A1 OUT
23:24:31 ID 0x199 IN
23:31:50 ID 0x199 IN (panic button pressed)
```
- id: b3-reception
title: Reception lost-property book
type: note
body: |
*22:52 · Kyle P. reports staff card missing. Says left in boardroom. Card NOT cancelled (system down for generator test prep).*
lock:
id: lock-card
prompt: Whose card opened the studio lounge at 23:12?
inputType: choice
options:
- { id: rex, label: Rex Daniels }
- { id: zanele, label: Zanele Dube }
- { id: marisa, label: Marisa Venter }
- { id: kyle, label: Kyle Pretorius }
- { id: tumi, label: Tumi Nkosi }
answers: ["kyle"]
hints:
- The reader shows hexadecimal. The register is in normal (decimal) numbers.
- "0x1A1 = 1×256 + 10×16 + 1."
- 0x1A1 = 417, which is Kyle's card.
successText: |
**Kyle's card.** 23:12 to 23:13, in and out in 75 seconds, just long enough to empty a bag pocket.
But Kyle reported that card stolen at 22:52... and the minutes put him in the boardroom at 23:12.
keyName: Access Key
- id: ch4
title: The Midnight Announcement
narrative: |
What was Rex going to say at midnight? His notes were in his jacket: a page of crossword-looking
letters, and a piece of card with little windows cut into it.
clues:
A:
- id: a4-grid
title: Page from Rex's notebook
type: diagram
body: |
```
G S C A L C
H C T E B I
D H W W S F
O U R B H B
T G E O E T
D U D T Y F
```
*Scrawled underneath: "Zan knows how to read these. Lay the card on top."*
B:
- id: b4-stencil
title: Cut card from Rex's jacket
type: diagram
body: |
```
■ □ ■ □ □ ■
■ ■ ■ □ ■ □
■ ■ ■ ■ □ □
□ ■ □ ■ ■ ■
■ □ □ ■ ■ ■
■ ■ □ ■ ■ ■
```
■ = solid card · □ = window cut out. Same size as a notebook page, 6 × 6 squares.
Zanele says: "His old trick. Read the windows left to right, top to bottom."
- id: b4-gossip
title: Overheard in the corridor (last week)
type: transcript
body: |
**Tumi:** "Marisa says some media group's buying us. Rex says he never signed anything."
**Zanele:** "He'd never sell. He'd sooner die on air."
lock:
id: lock-stencil
prompt: What did Rex's hidden message say?
inputType: text
format: The message (3 words)
answers: ["SALEISFORGED", "THESALEISFORGED"]
hints:
- The Detective reads out the letters that sit under each □ window.
- "Row 1 windows are columns 2, 4 and 5: S, A, L."
- S-A-L-E-I-S-F-O-R-G-E-D.
successText: |
**"SALE IS FORGED."** The station's sale contract carries Rex's signature. Rex never signed it.
At midnight, he was going to tell the whole city, live.
The only other owner who could sell the station: **Marisa Venter.**
keyName: Microphone Key
finale:
type: accusation
prompt: Four keys. Put it together. Who killed Rex Daniels?
maxAttempts: 2
questions:
- id: who
label: Who killed Rex?
options:
- { id: zanele, label: Zanele Dube }
- { id: kyle, label: Kyle Pretorius }
- { id: marisa, label: Marisa Venter }
- { id: tumi, label: Tumi Nkosi }
- { id: owen, label: Owen Hart }
answer: marisa
- id: how
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: 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
- id: why
label: Why?
options:
- { id: slot, label: To take over the midnight slot }
- { id: writeup, label: Revenge for a disciplinary write-up }
- { id: forged, label: Rex was about to reveal on air that she'd forged his signature to sell the station }
- { id: obsession, label: Rejected obsession }
answer: forged
successEnding: |
Marisa doesn't argue. She just looks at the "ON AIR" light, still glowing red.
She booked the generator test so the kitchen camera would drop. She took Kyle's card off
the boardroom table at 22:30. At 23:11 she "stepped out for a call", used Kyle's card to get
into the lounge at 23:12, took the EpiPen from Rex's bag, then slipped peanut oil into his honey.
Back in the meeting by 23:17. Zanele carried the poison in herself, never knowing.
**Case closed.** At midnight, Zanele reads Rex's announcement on air. 📻🔑
failEnding: |
The wrong person is arrested. The sale goes through. Night Owl FM becomes a 24-hour playlist with no DJs.
reveal: |
**Marisa Venter** poisoned the honey with **peanut oil** during the generator test she booked,
and used **Kyle's stolen card** to take Rex's **EpiPen**, because Rex was going to reveal live
that she'd **forged his signature to sell the station**.
+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**.
+211
View File
@@ -0,0 +1,211 @@
id: deep-lab-nereid
engine: story
category: escape-room
title: "Submerged: Deep Lab Nereid"
tagline: 300 metres under the sea. The lab is flooding. The clock is literal.
estimatedMinutes: 35
difficulty: 4
players: { min: 2, max: 2 }
theme: { primary: "#0b4f6c", accent: "#20bf55", background: "#03111a", headingFont: "JetBrains Mono" }
rules:
hintPenaltySeconds: 180
timeLimitMinutes: 40
timeLimitLabel: "Water level" # show the countdown as a rising water gauge
roles:
A:
name: Pilot
blurb: You're in the control room. Panels, sonar, and the launch console.
B:
name: Diver
blurb: You're in the wet lab. Pipe diagrams, tank labels, the station map, and a lot of water.
intro: |
**Deep Lab Nereid**, 300 m below the Agulhas Current. Your shift was supposed to be quiet.
At 03:12, a pressure seal fails. Alarms. Red lights. Water pouring through the wet lab.
The rest of the crew made it to the first escape pod. You two are on opposite sides of a sealed hatch.
The second pod is your only way out. **The water gauge is your timer. When it hits the ceiling, it's over.**
chapters:
- id: ch1
title: Pressure Valves
narrative: |
The hatch between you won't open while any pressure warning light is on.
The Diver can reach five valves. Turning a valve flips some of the warning lights.
clues:
A:
- id: a1-lights
title: Pressure warning panel (control room)
type: diagram
body: |
```
LIGHT: L1 L2 L3 L4 L5
STATE: ON ON ON off ON
```
HATCH RELEASE: requires ALL LIGHTS OFF.
Valve entry: type the numbers of the valves to turn, smallest first. Each valve once only.
B:
- id: b1-pipes
title: Valve wiring plate (wet lab wall)
type: diagram
body: |
```
VALVE 1 → flips L1, L2
VALVE 2 → flips L2, L3, L5
VALVE 3 → flips L1, L4
VALVE 4 → flips L3, L4
VALVE 5 → flips L4, L5
```
"Flip" = ON becomes off, off becomes ON. Turning a valve twice undoes it.
lock:
id: lock-valves
prompt: Which valves do you turn?
inputType: code
format: Valve numbers, smallest first (e.g. 23)
answers: ["145"]
hints:
- Try it on paper. Each valve flips its lights, and order doesn't matter.
- L1 is ON. Only valves 1 and 3 touch L1, so you need exactly one of them.
- "Valves 1, 4 and 5. Check: L1 flips once, L2 once, L3 once, L4 twice (back to off), L5 once."
successText: |
**Valves 1, 4, 5.** Every light dies. The hatch hisses open, and you can finally see each other.
Ankle-deep water. Keep moving.
keyName: Valve Key
- id: ch2
title: Breathing Gas
narrative: |
The pod's air tanks are empty. The lab's mixing rig can fill them, but the mix has to be exactly right.
clues:
A:
- id: a2-spec
title: Pod life-support spec (launch console)
type: document
body: |
**Fill pod tank to 210 litres.**
Oxygen content must be exactly **21%**.
Mixer input: litres drawn from **Tank X**. The rest is topped up from Tank Y automatically.
B:
- id: b2-tanks
title: Tank labels (wet lab)
type: table
body: |
| Tank | Contents | Oxygen |
|------|-------------------|--------|
| X | Nitrox | 36% |
| Y | Low-oxygen mix | 15% |
| Z | Pure helium | 0% |
*Tank Z is not connected to the mixer.*
lock:
id: lock-mix
prompt: How many litres from Tank X?
inputType: code
format: Litres
answers: ["60"]
hints:
- You need 21% of 210 litres to be oxygen. That's 44.1 litres of oxygen.
- If x litres come from X, oxygen = 0.36x + 0.15(210 − x).
- 0.21x = 44.1 − 31.5 = 12.6, so x = 60.
successText: |
**60 litres.** The mixer hums. The pod's oxygen gauge settles at 21.0%. Water is at your knees.
keyName: Oxygen Key
- id: ch3
title: Find the Pod
narrative: |
The second pod broke loose from its dock in the quake and drifted. Its beacon is pinging.
You need to steer the tether crawler to the right square of the seabed.
clues:
A:
- id: a3-sonar
title: Sonar contact (control room)
type: report
body: |
```
CONTACT: ESCAPE POD 2
Leg 1: 300 m, bearing 090 (due east) from the lab
Leg 2: 200 m, bearing 180 (due south) from the end of leg 1
```
B:
- id: b3-chart
title: Seabed chart (wet lab)
type: map
body: |
```
A B C D E F G H I J
1 . . . . . . . . . .
2 . . . . . . . . . .
3 . . . . . . . . . .
4 . . . . . . . . . .
5 . . . . [N] . . . . .
6 . . . . . . . . . .
7 . . . . . . . . . .
8 . . . . . . . . . .
```
[N] = Lab Nereid. Each square = **100 m**. North is up.
lock:
id: lock-grid
prompt: Which chart square is the pod in?
inputType: text
format: e.g. B4
answers: ["H7"]
hints:
- Start at the lab's square, E5.
- 300 m east is three squares east. 200 m south is two squares down.
- E → F → G → H, then 5 → 6 → 7. H7.
successText: |
**H7.** The crawler latches on and reels the pod in. It docks with a clunk you feel in your teeth.
Water is at your waist.
keyName: Sonar Key
finale:
type: escape
prompt: The pod's launch console wants a code word, typed on the ancient intercom keypad.
clues:
A:
- id: af-log
title: Station commander's last log entry
type: log
body: |
```
LAUNCH WORD (typed on the intercom keypad, pauses between letters):
555 · 4 · 3 · 66 · 55 · 9 · 222
```
*The intercom is not a normal phone keypad. The engineers rewired it. Ask the wet lab.*
B:
- id: bf-keypad
title: Intercom keypad (wet lab wall)
type: diagram
body: |
```
┌──────┬──────┬───────┐
│ 1 │ 2 │ 3 │
│ │ QWE │ RTY │
├──────┼──────┼───────┤
│ 4 │ 5 │ 6 │
│ UIO │ PAS │ DFG │
├──────┼──────┼───────┤
│ 7 │ 8 │ 9 │
│ HJK │ LZX │ CVBNM │
└──────┴──────┴───────┘
```
Press a key once for its first letter, twice for its second, and so on.
lock:
id: lock-launch
prompt: Enter the launch word.
inputType: text
format: One word
answers: ["SURFACE"]
hints:
- "555 means key 5 pressed three times."
- Key 5 is P, A, S. Three presses = S.
- S · U · R · F · A · C · E.
successEnding: |
**SURFACE.** The pod seals. A thump, a lurch, and you're rising, fast.
Through the porthole, the dark turns blue, then green, then gold. You break the surface
into the sunrise, holding hands and laughing like idiots.
**You escaped!** 🌊🔑
failEnding: |
The water reaches the ceiling. The lights flicker out. (Don't worry, it's a game. Try again?)
+90
View File
@@ -0,0 +1,90 @@
id: drowned-catacombs
engine: labyrinth
category: co-op
title: The Drowned Catacombs
tagline: Ankle-deep water. Gates that only swing one way. Five places the floor gives out.
estimatedMinutes: 20
difficulty: 3
players: { min: 2, max: 2 }
theme: { primary: "#1e3d59", accent: "#7ec8e3", background: "#050b10", headingFont: "Playfair Display" }
roles:
A:
name: Navigator
blurb: You hold a water-stained survey map. Walls, carvings, traps, and the old one-way flood gates.
B:
name: Explorer
blurb: You're down in the dark with a torch. You see the floor under you and the ways you can go. That's it.
intro: |
Under the old harbour town, the catacombs flood with every spring tide. Three **tide lanterns**
were left down there a century ago. Light all three at the sea gate, and the water drains away.
**The flood gates swing one way only.** Go through one and you can't come back that way.
And five stretches of the floor have rotted through.
Navigator, you have the map. Explorer, you have the torch. Talk.
# Grid legend: # wall · . floor · S start · E exit (needs all lanterns) · K lantern · T trap
grid:
- "#############"
- "#S..#.......#"
- "###.#.###.#.#"
- "#.T...#K#...#"
- "#.#.#.#.#.###"
- "#.#.T.#...TK#"
- "#.##.##.....#"
- "#.#.T...#.#.#"
- "#.#.###.#.#.#"
- "#.......#.#.#"
- "#.###.#.#.#.#"
- "#..K#.....TE#"
- "#############"
lanternsRequired: 3
# One-way gates. {x, y, dir}: you may step FROM (x,y) in direction `dir`, but never back the other way.
# Navigator sees an arrow on the map. Explorer only sees "a heavy gate swings shut behind you" after passing,
# and the reverse direction button is disabled with the label "Gate (won't open from this side)".
oneWay:
- { x: 5, y: 9, dir: e }
- { x: 5, y: 11, dir: n }
- { x: 7, y: 8, dir: n }
# Only some cells have carvings. Blank cells say nothing, so the Navigator must count steps.
glyphs:
- { x: 1, y: 1, glyph: arch, label: "⌒ A stone archway (the entrance)" }
- { x: 3, y: 1, glyph: urn, label: "⚱ A cracked urn" }
- { x: 9, y: 1, glyph: candle, label: "🕯 A melted candle stub" }
- { x: 5, y: 3, glyph: bones, label: "☠ Crossed bones carved in the floor" }
- { x: 10, y: 3, glyph: drip, label: "💧 Water dripping from the ceiling" }
- { x: 1, y: 5, glyph: shell, label: "🐚 A seashell set in the stone" }
- { x: 8, y: 5, glyph: eye, label: "👁 A carved eye" }
- { x: 7, y: 6, glyph: anchor, label: "⚓ An anchor carving" }
- { x: 3, y: 9, glyph: wave, label: "🌊 Three wavy lines" }
- { x: 9, y: 9, glyph: fish, label: "🐟 A fish" }
- { x: 11, y: 8, glyph: moon, label: "☾ A crescent moon" }
- { x: 6, y: 11, glyph: keyhole, label: "🗝 A rusted keyhole in the wall" }
lanterns:
- { x: 3, y: 11, name: Lantern of the Low Tide }
- { x: 7, y: 3, name: Lantern of the Deep }
- { x: 11, y: 5, name: Lantern of the Turning Tide }
explorerView:
flavour:
blank: "Cold water around your ankles. Nothing carved here."
trap: "The floor collapses into black water! You surface, gasping... back at the archway."
lantern: "You strike the tide lantern and it flares blue. ({name})"
exitLocked: "A rusted sea gate with three lantern brackets. Locked tight."
exitOpen: "You hang the lanterns. Somewhere, a huge drain groans open. The sea gate rises."
gate: "A heavy flood gate swings shut behind you. It won't open from this side."
navigatorView:
showMoveCount: true
scoring:
trapPenaltySeconds: 60
parMoves: 80
successEnding: |
The water drains with a roar, and the Explorer climbs the sea-gate stairs into the harbour moonlight.
The Navigator's map is soaked, but nobody cares. 🏮🔑
+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. 🏮🔑
+118
View File
@@ -0,0 +1,118 @@
id: in-sync-deep-cuts
engine: sync
category: co-op
title: "In Sync: Deep Cuts"
tagline: You think you know each other? Prove it without the multiple choice.
estimatedMinutes: 25
difficulty: 3
players: { min: 2, max: 2 }
theme: { primary: "#6a0572", accent: "#ff6f91", background: "#120612", headingFont: "Playfair Display" }
roles:
A:
name: Partner A
blurb: In the spotlight for Round 2, and for questions 1, 3 and 5 of Round 4.
B:
name: Partner B
blurb: In the spotlight for Round 3, and for questions 2 and 4 of Round 4.
intro: |
The sequel to In Sync. Same idea, much harder bar.
- **Round 1, Telepathy:** no options. You both **type** one word. Same word = point.
- **Rounds 2 & 3, Spotlight:** deeper questions about each of you. You need **4 out of 5**.
- **Round 4, Ranking:** put four things in order for your partner. Nail the **top AND bottom** to score.
One replay per round. No talking during a question. (Yes, we mean it. Not even eyebrows.)
rules:
questionsPerRound: 5
replaysPerRound: 1
rounds:
- id: r1
title: Telepathy
type: telepathy
pointsForKey: 3
blurb: Read the prompt. Both type ONE word, privately. Normalized exact match (case, spaces, and a trailing S are ignored) = point.
keyName: Telepathy Key
- id: r2
title: Spotlight on A
type: predict
subject: A
pointsForKey: 4
blurb: Partner A answers honestly about themselves. Partner B guesses. 4 of 5 to earn the key.
keyName: Deep Key A
- id: r3
title: Spotlight on B
type: predict
subject: B
pointsForKey: 4
blurb: Partner B answers honestly about themselves. Partner A guesses. 4 of 5 to earn the key.
keyName: Deep Key B
- id: r4
title: Ranking
type: rank
subjectPattern: [A, B, A, B, A]
pointsForKey: 3
blurb: The subject ranks four things from most to least. The partner predicts the order. Point only if BOTH the #1 and the #4 match.
keyName: Ranking Key
# TELEPATHY bank: free-text prompts. Both type one word.
telepathy:
- { id: t01, q: "A fruit." }
- { id: t02, q: "Something you find in a kitchen drawer." }
- { id: t03, q: "A colour that feels like us." }
- { id: t04, q: "An animal that would be a great pet for us." }
- { id: t05, q: "A city we should visit together." }
- { id: t06, q: "Something you'd take to a picnic." }
- { id: t07, q: "A word that describes our first date." }
- { id: t08, q: "A breakfast food." }
- { id: t09, q: "Something cold." }
- { id: t10, q: "A board or card game." }
- { id: t11, q: "Something you plug in." }
- { id: t12, q: "A pizza topping." }
- { id: t13, q: "Something with wheels." }
- { id: t14, q: "A word that rhymes with 'love'." }
- { id: t15, q: "Something you find at the beach." }
- { id: t16, q: "A dessert." }
- { id: t17, q: "A famous movie couple's first name (either one)." }
- { id: t18, q: "Something that makes noise at night." }
# PREDICT bank (deeper). "{subject}" is replaced with the subject's role name.
predict:
- { id: d01, q: "What does {subject} wish people asked them about more?", options: [Their dreams, Their faith or beliefs, Their work, Their childhood] }
- { id: d02, q: "When {subject} feels most loved, it's usually because...", options: [Someone made time, Someone said it out loud, Someone did something for them, Someone remembered a small detail] }
- { id: d03, q: "{subject}'s biggest fear about the future?", options: [Money, Health, Not doing something that matters, Drifting apart from people] }
- { id: d04, q: "Which compliment means the most to {subject}?", options: ["You're so kind", "You're so smart", "You're so funny", "You're so strong"] }
- { id: d05, q: "If {subject} could redo one year of their life, which kind?", options: [A school year, Their first year working, A year with a hard loss, They wouldn't redo any] }
- { id: d06, q: "After a hard day, {subject} secretly wants...", options: [To vent without advice, Advice and a plan, Distraction, To be left alone for a bit] }
- { id: d07, q: "What would {subject} do with a free Saturday, nobody else's plans?", options: [Create something, Be outdoors, Sleep and snack, See people] }
- { id: d08, q: "Which does {subject} find harder?", options: [Saying sorry, Saying no, Asking for help, Accepting a compliment] }
- { 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: 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: 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] }
# RANK bank: subject orders the 4 items from MOST to LEAST. Partner predicts.
rank:
- { id: k01, q: "Rank these date nights for {subject}.", items: [Fancy dinner, Movie at home, Something active, A road trip] }
- { id: k02, q: "Rank these for {subject}, best to worst.", items: [Breakfast in bed, A surprise gift, A handwritten letter, A whole day planned for them] }
- { id: k03, q: "Rank these chores, {subject}'s favourite to most hated.", items: [Dishes, Laundry, Cooking, Grocery shopping] }
- { id: k04, q: "Rank these holidays for {subject}.", items: [Bush or safari, Beach, Snow, A big city] }
- { id: k05, q: "Rank these snacks for {subject}.", items: [Chips, Chocolate, Popcorn, Biltong] }
- { id: k06, q: "Rank these pet peeves, most annoying to least, for {subject}.", items: [Being late, Loud chewing, Phone at dinner, Leaving lights on] }
- { id: k07, q: "Rank these superpowers for {subject}.", items: [Flying, Invisibility, Teleporting, Reading minds] }
- { id: k08, q: "Rank these seasons for {subject}.", items: [Summer, Autumn, Winter, Spring] }
- { id: k09, q: "Rank these ways to spend R1000 for {subject}.", items: [Experience, Clothes, Food, Save it] }
- { id: k10, q: "Rank these film genres for {subject}.", items: [Comedy, Thriller, Romance, Action] }
- { id: k11, q: "Rank these for {subject}: what they'd miss most on a desert island.", items: [Phone, Coffee, Music, Their bed] }
- { id: k12, q: "Rank these love languages for {subject}.", items: [Words, Time, Touch, Acts of service] }
- { id: k13, q: "Rank these sounds for {subject}.", items: [Rain on a roof, Ocean waves, A crackling fire, Birds in the morning] }
- { id: k14, q: "Rank these for {subject}: dream house feature.", items: [Big kitchen, Pool, Library, Big garden] }
- { id: k15, q: "Rank these drinks for {subject}.", items: [Coffee, Tea, Hot chocolate, Something fizzy] }
- { id: k16, q: "Rank these for {subject}: best part of a holiday.", items: [Planning it, Getting there, Being there, The photos after] }
+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.** 🚗🔑
+1 -1
View File
@@ -3,7 +3,7 @@ engine: sync
category: co-op
title: In Sync
tagline: How well do you really know each other? Prove it. Three keys, three rounds.
estimatedMinutes: 20
estimatedMinutes: 15
difficulty: 1
players: { min: 2, max: 2 }
theme: { primary: "#c2185b", accent: "#ffb3c7", background: "#14080d", headingFont: "Playfair Display" }
+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?)
+422
View File
@@ -0,0 +1,422 @@
id: kalahari-expedition
engine: story
category: murder-mystery
title: The Kalahari Expedition
tagline: 1931. A diamond find. Two bodies in the sand. Nobody leaves until the truck comes back.
estimatedMinutes: 60
difficulty: 5
players: { min: 2, max: 2 }
theme: { primary: "#b5651d", accent: "#f4d58d", background: "#140d06", headingFont: "Playfair Display" }
rules:
hintPenaltySeconds: 240
timeLimitMinutes: 75
roles:
A:
name: The Magistrate
blurb: Sent from Upington with the post truck. You have the medical notes, the photographs, and the dead man's papers.
B:
name: The Camp Clerk
blurb: You keep the expedition's books. Rotas, inventories, maps, and every rumour around the fire.
intro: |
**The southern Kalahari, March 1931.**
Professor Edwin Marlowe's expedition has been digging for six weeks. Two weeks ago, the sponsor,
Felix Dunmore, announced a find that made headlines in London: **diamonds**, lying in the sand.
Three nights ago, Marlowe was found dead at the bottom of the excavation pit.
Last night, the camp cook, **Samuel Brink**, was found dead by the water barrels. "A snake," they say.
The post truck won't be back for days. Someone in this camp has killed twice.
☠️ *Nightmare case. One accusation. No second chances. The clock is running.*
suspects:
- id: helena
name: Dr Helena Marlowe
role: Botanist, the professor's wife
description: Calm, clinical, and inheriting everything. Acted as camp doctor.
- id: reyes
name: Captain Arthur Reyes
role: Guide and quartermaster
description: Ex-army. Ran the camp with a sergeant's eye. Smokes a pipe.
- id: dunmore
name: Mr Felix Dunmore
role: Sponsor
description: Financier. His Dunmore Kalahari Diamond Company shares have tripled since the find.
- id: bell
name: Miss Clara Bell
role: Photographer and journalist
description: Sent by a London paper. Up all night photographing the stars.
- id: kessler
name: Dr Otto Kessler
role: Geologist
description: Marlowe's old rival. Said loudly at dinner that "the find makes no geological sense."
- id: pienaar
name: Jan Pienaar
role: Driver and mechanic
description: Keeps the two trucks alive. Quiet. Noticed things.
chapters:
- id: ch1
title: Star Trails
narrative: |
Nobody saw Marlowe leave camp. But Clara Bell left her camera open to the sky all night,
and the dig site is in the corner of her photograph.
clues:
A:
- id: a1-photo
title: Clara's long-exposure photograph
type: photo
body: |
*The night sky drawn into curved white arcs around the south celestial pole.
In the bottom corner, the dig. A small orange streak shows a lantern there.
Measured against the star arcs, the lantern streak **begins 60°** into the arc and **ends 75°** in.*
- id: a1-medical
title: Dr Helena Marlowe's notes on her husband
type: report
body: |
Skull fracture, back of the head. Sand in the wound, but **no sand in the mouth or nose**.
He did not breathe after he fell. He was dead **before** he went into the pit.
B:
- id: b1-photolog
title: Clara's photo log
type: log
body: |
Plate 14 · Star trails, camera facing south.
Shutter **opened 22:00**. Closed 05:00.
*(Note to self: the sky turns **15° every hour**, so a 7-hour plate gives 105° arcs.)*
- id: b1-clara
title: Clara's statement (given privately to you)
type: transcript
body: |
"Arthur, Captain Reyes, sat with me by the camera from ten until four. We were... talking.
Please don't put that in the official report. My editor would have a fit."
lock:
id: lock-lantern
prompt: At what time did the lantern at the dig light up?
inputType: code
format: "HH:MM"
answers: ["0200", "200", "200AM"]
hints:
- The star arcs grow at a steady rate from the moment the shutter opened.
- 60° at 15° per hour is 4 hours.
- 22:00 plus 4 hours = 02:00. (It went out at 03:00.)
successText: |
**02:00.** Someone was at the dig with a lantern from 02:00 to 03:00.
Marlowe was killed then, before he was put in the pit.
keyName: Starlight Key
- id: ch2
title: Footprints
narrative: |
The wind dropped that night. Two sets of prints lead to the pit. One is Marlowe's (size 9, hobnail).
The other set leads there and back again.
clues:
A:
- id: a2-sketch
title: Police sketch of the second set of prints
type: diagram
body: |
```
Print length (heel to toe): 31.5 cm
Tread pattern: >>>> chevron
Stride: even, about 78 cm. No limp.
```
*Note: in loose sand, a print spreads about **1 cm longer** than the sole that made it.*
B:
- id: b2-boots
title: Quartermaster's boot inventory
type: table
body: |
| Person | UK size | Tread |
|-----------------|---------|-------|
| Prof. Marlowe | 9 | hobnail |
| Dr H. Marlowe | 5 | smooth |
| Capt. Reyes | 11 | chevron (army issue) |
| Mr Dunmore | 11 | chevron ("insisted on the same as the Captain's") |
| Miss Bell | 6 | smooth |
| Dr Kessler | 11 | hobnail |
| Mr Pienaar | 10 | chevron |
| Samuel Brink | 8 | smooth |
- id: b2-sizes
title: Bootmaker's size card (sole length)
type: table
body: |
| UK size | Sole length |
|---------|-------------|
| 8 | 27.5 cm |
| 9 | 28.5 cm |
| 10 | 29.5 cm |
| 11 | 30.5 cm |
| 12 | 31.5 cm |
lock:
id: lock-boot
prompt: What size boot made the second set of prints?
inputType: code
format: UK size
answers: ["11"]
hints:
- Don't read the print length straight off the size card. Sand changes it.
- The sole is about 1 cm shorter than the print, so 30.5 cm.
- 30.5 cm is a size 11.
successText: |
**Size 11, chevron tread, even stride.** Only two people in camp own that boot.
Check the inventory again. And remember who has an alibi from ten until four.
keyName: Boot Key
- id: ch3
title: The Watch Rota
narrative: |
The camp keeps a fire watch every night. Whoever was on watch at 02:00 might have seen the lantern leave.
clues:
A:
- id: a3-rule
title: Captain Reyes' standing orders
type: document
body: |
**Fire watch.** Two-hour shifts: 20:00, 22:00, 00:00, 02:00, 04:00.
Watch passes through the six watchkeepers in **alphabetical order of surname**, wrapping round.
Each night, the **first shift** starts with the **next person** after the one who started the night before.
- id: a3-samuel
title: Dr Helena Marlowe on Samuel's death
type: report
body: |
Two puncture marks on the ankle, **4 cm apart**, perfectly parallel.
A puff adder's fangs are under 1.5 cm apart. Whatever this was, it wasn't a snake.
Traces of a sedative in his coffee cup.
B:
- id: b3-roster
title: Watchkeepers and the first night
type: log
body: |
Watchkeepers: **Reyes, Bell, Dunmore, Kessler, H. Marlowe, Pienaar**
(the Professor and the cook were excused).
**Night 1:** first watch taken by Capt. Reyes (he drew the short straw).
Marlowe died on **Night 3**.
lock:
id: lock-watch
prompt: Who was on fire watch at 02:00 on the night Marlowe died?
inputType: choice
options:
- { id: helena, label: Dr Helena Marlowe }
- { id: reyes, label: Captain Arthur Reyes }
- { id: dunmore, label: Mr Felix Dunmore }
- { id: bell, label: Miss Clara Bell }
- { id: kessler, label: Dr Otto Kessler }
- { id: pienaar, label: Jan Pienaar }
answers: ["pienaar"]
hints:
- Put the six watchkeepers in alphabetical order by surname first.
- "Order: Bell, Dunmore, Kessler, Marlowe, Pienaar, Reyes. Night 1 starts with Reyes, Night 2 with Bell, Night 3 with Dunmore."
- "Night 3: Dunmore 20:00, Kessler 22:00, Marlowe 00:00, Pienaar 02:00."
successText: |
**Jan Pienaar.** He tells you:
*"Ten past two, someone walked out to the dig with a lantern. Tall, too far to see a face.
Came back around five past three and went into the **north row** of tents. Samuel was up too,
brought me coffee at half two. He said, 'I know who that is.' I told him to leave it."*
North row tents: **Dunmore, Kessler, Reyes.**
keyName: Firewatch Key
- id: ch4
title: Samuel's Note
narrative: |
In Samuel's recipe tin: a scrap of paper in his careful capitals. It's nonsense. On purpose.
clues:
A:
- id: a4-note
title: Note from Samuel's recipe tin
type: note
body: |
## RS XRMPVSI TN TSDJ XUSUS
*On the back: "Prof taught me the secret way. My word goes under the letters,
over and over. Count back by each letter of my word: A is 0, B is 1, C is 2..."*
- id: a4-alphabet
title: Alphabet strip
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: b4-samuel
title: What everyone knew about Samuel
type: note
body: |
Samuel greeted every single person, every single morning, with the same word, and painted it
on his enamel mug: **KOFFIE**. "Everything good starts with it," he'd say.
- id: b4-smokers
title: Camp stores, tobacco issued
type: table
body: |
| Person | Tobacco |
|--------------|---------|
| Capt. Reyes | Pipe tobacco, 2 tins |
| Mr Dunmore | Pipe tobacco, 3 tins (Dutch cherry) |
| Dr Kessler | None ("gave up in '29, doctor's orders") |
| Miss Bell | Cigarettes |
| Mr Pienaar | Cigarettes |
lock:
id: lock-samuel
prompt: What did Samuel's note say?
inputType: text
format: The message (5 words)
answers: ["HESMELLEDOFPIPESMOKE"]
hints:
- The key word is Samuel's word, KOFFIE, repeated under the message and skipping spaces.
- "R − K(10) = H. S − O(14) = E. X − F(5) = S."
- "The message: HE SMELLED OF PIPE SMOKE."
successText: |
**"HE SMELLED OF PIPE SMOKE."** The figure at the dig, returning to the north row, smelled of a pipe.
Samuel knew who, and two nights later he was dead.
keyName: Coffee Tin Key
- id: ch5
title: The Diamonds
narrative: |
Dr Kessler said the find "made no geological sense." Marlowe sent three stones to a gemologist
in Cape Town. The reply arrived in the post bag the day after he died. Nobody had opened it.
clues:
A:
- id: a5-gem
title: Gemologist's report
type: report
body: |
The three stones are genuine diamonds. However:
- They are **sharp-edged octahedra**. Diamonds carried by rivers into the desert sand
are always **water-worn and rounded**. These have never been near water.
- Their trace-element profile matches **reference sample K-3** in our collection exactly.
*Conclusion: these stones were not found where you say they were.*
B:
- id: b5-reference
title: Reference samples (Cape Town Gem Society)
type: table
body: |
| Sample | Source mine |
|--------|-------------|
| K-1 | Jagersfontein |
| K-2 | Koffiefontein |
| K-3 | **Kimberley**, the Big Hole |
| K-4 | Premier Mine |
- id: b5-prospectus
title: Dunmore Kalahari Diamond Co. prospectus
type: document
body: |
*"Alluvial diamonds, discovered personally by our chairman **Mr F. Dunmore** at Site 7.
Shares available now at a very favourable price."*
lock:
id: lock-mine
prompt: Where did the "Kalahari" diamonds really come from?
inputType: text
format: One word
answers: ["KIMBERLEY", "KIMBERLY", "BIGHOLE", "THEBIGHOLE", "KIMBERLEYBIGHOLE"]
hints:
- The report gives a sample code. The clerk has the codes.
- Sample K-3.
- Kimberley.
successText: |
**Kimberley.** Bought stones, scattered in the sand to look like a find.
The "discovery" was a fraud, and the man who found them personally was selling the shares.
keyName: Diamond Key
- id: ch6
title: The Unsent Telegram
narrative: |
Marlowe's last act was a telegram he never got to send. Like Samuel, he didn't trust plain words.
clues:
A:
- id: a6-telegram
title: Marlowe's draft telegram
type: letter
body: |
```
TO: SOUTH AFRICAN POLICE, UPINGTON
1.4 3.3 3.9 5.4 7.1 6.2 5.2 4.5 6.7
```
*Pencilled underneath: "Line, then word. The rules we all signed."*
B:
- id: b6-handbook
title: Expedition Handbook, Rules of Camp (page 1)
type: document
body: |
1. Every member of the expedition shall carry water at all times.
2. The camp fire is never to be left without a guard after dark.
3. Report any find to the leader before it is moved or cleaned.
4. No stone is to be called a diamond until it has been tested.
5. Samples must be salted away in sealed tins, labelled and dated.
6. The police at Upington must be informed of any death in camp.
7. Stop all digging at once if the walls of a trench show cracks.
8. A find that seems too good to be true usually is.
lock:
id: lock-telegram
prompt: What word in Marlowe's telegram explains everything?
inputType: text
format: One word
answers: ["SALTED"]
hints:
- "1.4 means line 1, word 4."
- "The telegram reads: THE FIND IS ___ STOP POLICE MUST BE INFORMED."
- Line 5, word 4 is "salted". A "salted" mine is one where someone has planted stones to fake a find.
successText: |
**"THE FIND IS SALTED. STOP. POLICE MUST BE INFORMED."**
Marlowe knew. He wrote this the night he died. Someone made sure it never left camp.
keyName: Telegraph Key
finale:
type: accusation
prompt: The post truck's dust is on the horizon. One accusation. Get every part right.
maxAttempts: 1
questions:
- id: who
label: Who killed Professor Marlowe?
options:
- { id: helena, label: Dr Helena Marlowe }
- { id: reyes, label: Captain Arthur Reyes }
- { id: dunmore, label: Mr Felix Dunmore }
- { id: bell, label: Miss Clara Bell }
- { id: kessler, label: Dr Otto Kessler }
- { id: pienaar, label: Jan Pienaar }
answer: dunmore
- id: samuel
label: What happened to Samuel?
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: 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: shot, label: Shot with the Captain's rifle }
answer: struck
- id: why
label: Why?
options:
- { id: inherit, label: To inherit his estate }
- { id: rival, label: Professional jealousy over the find }
- { id: fraud, label: Marlowe discovered the diamond find was salted and was going to the police }
- { id: affair, label: A love affair gone wrong }
answer: fraud
successEnding: |
Dunmore lights his pipe with steady hands. Dutch cherry. Samuel was right.
"Do you know what those shares are worth? Do you know what I'd have lost?"
He scattered Kimberley stones at Site 7 and "discovered" them himself. When Marlowe found out,
Dunmore asked him to the dig at two in the morning "to explain", struck him with a survey pole,
and rolled him into the pit. He wore the same boots as the Captain on purpose.
When Samuel let slip that he knew, Dunmore drugged his coffee and faked a snakebite.
**Case closed.** The post truck takes one extra passenger back to Upington. 🏜️🔑
failEnding: |
The truck leaves with the wrong person in handcuffs. Dunmore's shares hit a record high the next month.
reveal: |
**Felix Dunmore** lured Marlowe to the dig at **02:00**, **struck him and put him in the pit**,
then **drugged and killed Samuel** to silence him, all to hide that his diamond find was **salted**
with stones from Kimberley.
+309
View File
@@ -0,0 +1,309 @@
id: kestrel-heist
engine: story
category: escape-room
title: The Kestrel Bank Job
tagline: A crooked banker stole Gran's necklace. Tonight you're taking it back. In and out. No mistakes.
estimatedMinutes: 50
difficulty: 5
players: { min: 2, max: 2 }
theme: { primary: "#1b1b1e", accent: "#00e5a0", background: "#050506", headingFont: "JetBrains Mono" }
rules:
hintPenaltySeconds: 240
timeLimitMinutes: 55
timeLimitLabel: "Until the morning shift"
roles:
A:
name: The Inside Man
blurb: You're in the building. You see the doors, panels, lasers and guards. You don't have the plans.
B:
name: The Wheel
blurb: You're in the van outside with the laptop, the blueprints, and everything you've dug up on the banker.
intro: |
Six months ago, banker **Vincent Kestrel** "held" your gran's emerald necklace as security
on a loan, then forged the paperwork and kept it. The police shrugged. The lawyers shrugged.
So tonight, you're getting it back. **Kestrel Private Bank, 21:58.**
One of you is inside in a cleaner's uniform with an earpiece. The other is in the van with a laptop.
Get to his private box. Get the necklace. Get out before the morning shift.
☠️ *Nightmare tier. Longer hint penalty. A hard time limit. Trust each other.*
chapters:
- id: ch1
title: The Staff Door
narrative: |
The staff entrance has a four-digit keypad. Kestrel uses it himself every morning.
The Inside Man dusts the keypad with the cleaner's talc.
clues:
A:
- id: a1-keypad
title: Dusted keypad
type: diagram
body: |
```
[1] [2] [3] Worn, greasy keys: 0, 2, 6
[4] [5] [6] The 0 is the MOST worn.
[7] [8] [9] Every other key is clean.
[0]
```
Code length: 4 digits.
B:
- id: b1-assistant
title: Message from Kestrel's ex-assistant
type: transcript
body: |
"Every code he has is a date, **day then month**, two digits each.
Always someone in his family. He's not as clever as he thinks."
- id: b1-social
title: Kestrel's social media, a few posts
type: document
body: |
- *14 Feb:* "Happy Valentine's to the wife. ❤️" *(deleted a week later)*
- *20 Jun:* "Happy birthday to my princess Mia! 🎂 Daddy loves you."
- *03 Sep:* "Proud dad. Liam made the first XV!"
- *11 Nov:* "Happy birthday Noah, my little man!"
lock:
id: lock-door
prompt: Enter the staff door code.
inputType: code
format: 4 digits (DDMM)
answers: ["2006"]
hints:
- The code uses only the digits 0, 2 and 6, with one of them used twice.
- Which family date uses exactly 0, 2 and 6, with 0 twice?
- Mia's birthday, 20 June = 2006.
successText: |
**2006.** Green light. You're in. Mop bucket rolling, cap pulled low.
Ahead: the long marble corridor to the vault, criss-crossed with red light.
keyName: Staff Door Key
- id: ch2
title: The Laser Hall
narrative: |
Five columns of floor tiles. Five rows of laser beams across them.
Exactly one column of tiles is never crossed by a beam.
clues:
A:
- id: a2-hall
title: What the Inside Man sees
type: diagram
body: |
```
col1 col2 col3 col4 col5
row1 ▢ ▢ ▢ ▢ ▢
row2 ▢ ▢ ▢ ▢ ▢
row3 ▢ ▢ ▢ ▢ ▢
row4 ▢ ▢ ▢ ▢ ▢
row5 ▢ ▢ ▢ ▢ ▢
```
The beams are too faint to see by eye in this light.
Sticker on the laser control box: **"MASK BITS: col1 = 16, col2 = 8, col3 = 4, col4 = 2, col5 = 1"**
B:
- id: b2-config
title: Hacked security config (laptop)
type: log
body: |
```
[LASER_HALL]
ROW_MASK_1 = 21
ROW_MASK_2 = 12
ROW_MASK_3 = 25
ROW_MASK_4 = 17
ROW_MASK_5 = 8
; a set bit means the beam crosses that tile
```
lock:
id: lock-lasers
prompt: Which column is safe to walk?
inputType: code
format: Column number
answers: ["4"]
hints:
- Each row mask is a sum of the column values its beam crosses. Break each number into 16, 8, 4, 2, 1.
- "21 = 16 + 4 + 1 → columns 1, 3, 5."
- "Rows cross: (1,3,5), (2,3), (1,2,5), (1,5), (2). Column 4 is never crossed."
successText: |
**Column 4.** Tile by tile, heel to toe. Not a single alarm. Halfway there.
keyName: Laser Key
- id: ch3
title: The Guards
narrative: |
Past the hall, the vault corridor. Three guards walk through it on fixed loops.
Crossing it to the vault door takes **4 full minutes** of picking the outer lock.
clues:
A:
- id: a3-watching
title: What the Inside Man has clocked from the vent
type: log
body: |
```
Guard 1 (big guy, whistles): passes at 22:03, then every 7 minutes
Guard 2 (torch, limps): passes at 22:05, then every 11 minutes
```
It's 22:00 now.
B:
- id: b3-roster
title: Security rota (hacked)
type: document
body: |
**Guard 3 (Dlamini):** corridor sweep at **22:02**, then **every 6 minutes**.
*A guard occupies the corridor for the whole minute they pass through it.*
You need **4 clear minutes in a row** with no guard passing.
lock:
id: lock-guards
prompt: At what time does the first safe 4-minute window start?
inputType: code
format: "HH:MM"
answers: ["2233", "1033", "1033PM"]
hints:
- "List the busy minutes: Guard 1 → :03, :10, :17, :24, :31, :38..."
- "Guard 2 → :05, :16, :27, :38. Guard 3 → :02, :08, :14, :20, :26, :32, :38."
- "Gaps before :33 are at most 3 minutes long. :33, :34, :35, :36 are all clear."
successText: |
**22:33.** You slip in behind Guard 3, pick the outer lock in three minutes fifty, and close the door
a breath before Guard 1 whistles past. You're in the vault anteroom. One huge dial.
keyName: Shadow Key
- id: ch4
title: The Vault Dial
narrative: |
Three-number combination dial. Kestrel left himself a reminder taped under the anteroom desk.
It's not in any language you know.
clues:
A:
- id: a4-photo
title: Photo on the anteroom desk
type: photo
body: |
*Kestrel with his three sons, a joint birthday party this year. Each boy holds a cake
with candles shaped like numerals: Noah's says **VII**, Liam's says **XIX**, Ben's says **XII**.
Frame engraved: "My boys."*
B:
- id: b4-note
title: Note taped under the desk (Inside Man reads it out letter by letter)
type: note
body: |
## XLNYRMZGRLM: NB HLMH ZTVH, VOWVHG URIHG
*Your laptop's notes on Kestrel: "Ancient-cipher nerd. Favourite: the mirror alphabet. A↔Z, B↔Y, C↔X..."*
lock:
id: lock-dial
prompt: Dial the combination.
inputType: code
format: "NN-NN-NN"
answers: ["191207", "19127"]
hints:
- "Mirror alphabet: A↔Z, B↔Y, C↔X. X → C, L → O, N → M..."
- "The note says: COMBINATION: MY SONS AGES, ELDEST FIRST."
- "Ages are XIX = 19, XII = 12, VII = 7. Eldest first: 19-12-07."
successText: |
**19-12-07.** Twelve tons of steel swing open on silent hinges.
A wall of safe deposit boxes. Hundreds of them. **Which one is his?**
keyName: Vault Key
- id: ch5
title: Box Number
narrative: |
Kestrel doesn't keep stolen goods under his own name. He uses aliases, all birds of prey.
On the anteroom terminal, a sticky note with a database query in his handwriting.
clues:
A:
- id: a5-query
title: Sticky note on the terminal
type: note
body: |
```sql
SELECT b.box_no
FROM boxes b
JOIN clients c ON c.client_id = b.client_id
WHERE c.alias LIKE 'K%'
AND b.size = 'L'
AND b.last_opened >= '2026-06-01';
```
- id: a5-clients
title: Terminal screen, CLIENTS table
type: table
body: |
| client_id | alias |
|-----------|------------|
| 101 | Kestrel |
| 102 | Heron |
| 103 | Kite |
| 104 | Kingfisher |
| 105 | Osprey |
B:
- id: b5-boxes
title: Downloaded BOXES table (laptop)
type: table
body: |
| box_no | client_id | size | last_opened |
|--------|-----------|------|-------------|
| 214 | 101 | M | 2026-07-14 |
| 233 | 103 | L | 2026-05-31 |
| 309 | 103 | L | 2026-03-02 |
| 317 | 104 | L | 2026-08-30 |
| 402 | 102 | L | 2026-09-01 |
| 411 | 101 | L | 2026-05-29 |
| 518 | 105 | L | 2026-09-10 |
| 520 | 104 | S | 2026-09-11 |
lock:
id: lock-box
prompt: Which box do you open?
inputType: code
format: Box number
answers: ["317"]
hints:
- "Aliases starting with K: Kestrel (101), Kite (103), Kingfisher (104)."
- "Large boxes for those clients: 233, 309, 317, 411."
- Only 317 was opened on or after 1 June 2026.
successText: |
**Box 317.** Inside, wrapped in a bank cloth: Gran's emerald necklace. And a stack of other
"held as security" jewellery with little tags. You photograph every tag for the police.
keyName: Emerald Key
finale:
type: escape
prompt: Alarms trip as you leave the vault. The getaway meet point is in a coded text from your driver.
clues:
A:
- id: af-text
title: Text from the driver (on the Inside Man's burner)
type: transcript
body: |
```
MUTLEETSTHODIREAEP
```
*"Zig-zag it. Rails = floors in that building. You know the rest."*
B:
- id: bf-blueprint
title: Kestrel Private Bank blueprint, cover sheet
type: document
body: |
**Floors:** Ground (banking hall), First (offices), Second (vault and anteroom).
*Basement: none. The vault is upstairs, which is why the lift is reinforced.*
- id: bf-railfence
title: Your notes on zig-zag (rail fence) ciphers
type: note
body: |
Write the message in a zig-zag down and up across N rails, then read each rail left to right.
To decode: work out how many letters sit on each rail, split the text, then zig-zag back through.
lock:
id: lock-getaway
prompt: Where's the meet point?
inputType: text
format: The place (a few words)
answers: ["OLDPIER", "THEOLDPIER", "MEETUSATTHEOLDPIER"]
hints:
- The building has 3 floors, so 3 rails.
- "18 letters on 3 rails split 5 / 9 / 4: MUTLE · ETSTHODIR · EAEP."
- Zig-zag back through and it reads MEET US AT THE OLD PIER.
successEnding: |
**The old pier.** You sprint out the loading dock as sirens wake up three blocks away. The van's door
is already open. Gran's necklace is in your pocket, and Kestrel's box of stolen tags is on its way to a
journalist who's been waiting six months for exactly this.
**You got out!** 💎🔑
failEnding: |
The morning shift arrives. You're still inside, holding a mop. (It's a game. Run it back?)
+2 -2
View File
@@ -3,8 +3,8 @@ engine: labyrinth
category: co-op
title: Labyrinth of Lanterns
tagline: One of you is lost in the dark. The other holds the only map.
estimatedMinutes: 25
difficulty: 2
estimatedMinutes: 10
difficulty: 1
players: { min: 2, max: 2 }
theme: { primary: "#3d2c6b", accent: "#ffb347", background: "#09070f", headingFont: "Playfair Display" }
roles:
+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!** 🎩🔑
+313
View File
@@ -0,0 +1,313 @@
id: masquerade-montague
engine: story
category: murder-mystery
title: The Masquerade at Montague House
tagline: 1894. Five masks. One swap. A lord who never unmasked.
estimatedMinutes: 40
difficulty: 4
players: { min: 2, max: 2 }
theme: { primary: "#4a1942", accent: "#e0c068", background: "#0f0a12", headingFont: "Playfair Display" }
rules:
hintPenaltySeconds: 180
roles:
A:
name: The Inspector
blurb: Scotland Yard, called in at midnight. You have the staff's statements and the gardener's notes.
B:
name: The Lady's Maid
blurb: You've served Montague House for years. You saw the guests arrive, and you hear everything.
intro: |
**Montague House, Surrey. New Year's Eve, 1894.**
Lord Julian Montague's masquerade is the event of the season. At midnight, the guests
unmask for the toast. Lord Julian doesn't. He's found in the glass conservatory,
a jewelled dagger from his own armoury missing from its case and nowhere to be found.
Five guests. Five masks. **And at least one of them is lying about which mask they wore.**
⚠️ *Expert case: the locks only give you facts. A wrong accusation costs you dearly.*
suspects:
- id: agatha
name: Lady Agatha Stroud
role: Dowager
description: Julian's aunt. Controls the family purse strings and hates parties.
- id: rupert
name: Colonel Rupert Blackwood
role: Retired soldier
description: Owes Julian a fortune in card debts. Loud, red-faced, and very nervous tonight.
- id: ivy
name: Miss Ivy Marsh
role: Young guest
description: Nobody quite knows who invited her. Charming, quiet, watches Julian closely.
- id: silas
name: Mr Silas Penhaligon
role: Business partner
description: Julian's partner in a shipping company that is quietly going bankrupt.
- id: odette
name: Mrs Odette Vance
role: Widow
description: Julian courted her for a year, then dropped her for a duke's daughter. Everyone saw her scene at dinner.
chapters:
- id: ch1
title: Who Wore What
narrative: |
Every guest arrived masked and cloaked. The footman logged the masks: **Fox, Raven, Swan, Lion, Stag**.
The cloaks were **crimson, green, black, silver, gold**. Nobody wrote down who wore what.
You'll have to work it out together.
clues:
A:
- id: a1-butler
title: The butler's recollections
type: note
body: |
1. The Colonel wore **green**. "Like a proper huntsman," he kept saying.
2. The **Swan** wore **black**. Some joke about black swans.
3. Mr Penhaligon did **not** wear a bird mask.
4. Whoever wore **gold** had a **bird** mask. (Birds: Raven, Swan.)
B:
- id: b1-maid
title: What you noticed in the cloakroom
type: note
body: |
5. The **Lion** wore **silver**.
6. None of the three ladies wore the **Fox** or the **Lion**.
7. Miss Marsh did **not** wear gold.
8. Lady Agatha calls feathers "vulgar". She would **never** wear a bird mask.
shared:
- id: s1-guests
title: The guest list
type: document
body: |
**Ladies:** Lady Agatha Stroud, Miss Ivy Marsh, Mrs Odette Vance
**Gentlemen:** Colonel Rupert Blackwood, Mr Silas Penhaligon
Each guest wore exactly one mask and one cloak. No two guests shared either.
lock:
id: lock-raven
prompt: Who arrived wearing the Raven mask?
inputType: choice
options:
- { id: agatha, label: Lady Agatha Stroud }
- { id: rupert, label: Colonel Rupert Blackwood }
- { id: ivy, label: Miss Ivy Marsh }
- { id: silas, label: Mr Silas Penhaligon }
- { id: odette, label: Mrs Odette Vance }
answers: ["odette"]
hints:
- Start with the men. Clue 6 says the ladies never wore Fox or Lion, so the two men wore Fox and Lion.
- The Colonel wore green and the Lion wore silver, so the Colonel is the Fox and Penhaligon is the Lion (silver). Agatha can't wear a bird, so she's the Stag.
- Ivy and Odette share Raven and Swan. The Swan wears black; gold goes to a bird; Ivy isn't gold. So Ivy is the black Swan, and Odette is the gold Raven.
successText: |
**Mrs Odette Vance arrived as the Raven, in gold.**
The full picture: Agatha (Stag, crimson) · Rupert (Fox, green) · Ivy (Swan, black) · Silas (Lion, silver) · Odette (Raven, gold).
Write that down. You'll need it.
keyName: Feather Key
- id: ch2
title: The Raven in the Conservatory
narrative: |
One witness saw someone go into the conservatory just before midnight.
But a masquerade is a very good place to be someone else.
clues:
A:
- id: a2-footman
title: Footman's statement
type: transcript
body: |
"Ten to midnight, sir. Someone in the **Raven** mask went into the conservatory. Quick, like.
I'd swear to the mask. And the cloak was **black**. I remember thinking it looked like a funeral."
- id: a2-odette
title: Mrs Vance's statement
type: transcript
body: |
"Yes, I hated him. Half the room heard me say so at dinner. But I was in the ballroom
from eleven onward. Ask anyone who danced with the **Swan**." *(She doesn't seem to notice what she said.)*
B:
- id: b2-cloakroom
title: What you saw in the cloakroom at 23:00
type: note
body: |
Two of the ladies came in giggling and **traded masks**, the Raven and the Swan.
They **kept their own cloaks**. One of them said, "The Colonel will never guess, it'll be such a lark."
- id: b2-colonel
title: The Colonel, overheard
type: transcript
body: |
"Julian gave me till Friday to pay. Friday! I'd sooner see him at the bottom of the lake."
*(He was in the card room from 23:30 until midnight with Mr Penhaligon. Both swear to it.)*
lock:
id: lock-seen
prompt: Who did the footman really see enter the conservatory?
inputType: choice
options:
- { id: agatha, label: Lady Agatha Stroud }
- { id: rupert, label: Colonel Rupert Blackwood }
- { id: ivy, label: Miss Ivy Marsh }
- { id: silas, label: Mr Silas Penhaligon }
- { id: odette, label: Mrs Odette Vance }
answers: ["ivy"]
hints:
- The swap was Raven ↔ Swan. Who had those masks at the start, and what colour were their cloaks?
- After 23:00 the Raven mask was worn with whichever cloak the Swan had arrived in.
- Ivy arrived as the black Swan. After the swap she wore the Raven with her own black cloak.
successText: |
**Miss Ivy Marsh.** After the swap, Ivy wore the Raven with her black cloak. Odette wore the Swan with gold.
Anyone looking for "the Raven" would blame Odette, the jilted widow who'd made a scene at dinner.
keyName: Mask Key
- id: ch3
title: The Missing Dagger
narrative: |
The jewelled dagger isn't on the body or in the house. The police think it's still in the conservatory,
buried in one of the sixteen numbered plant pots. They can't dig up every pot before dawn.
clues:
A:
- id: a3-gardener
title: The gardener's evening notes
type: note
body: |
*Watered every pot at 10 o'clock tonight, soaked through, **except the prime-numbered pots**.
Those are His Lordship's cacti. Water rots them.*
B:
- id: b3-police
title: Constable's conservatory notes
type: report
body: |
- Fresh disturbance in the soil of **pots 4, 9, 11 and 15**.
- A pair of **white evening gloves** found burning in the conservatory stove. Unburnt fingertips are **spotless, no mud at all**.
- Pots 4, 9 and 15 were re-potted by the under-gardener this afternoon (his note on the potting bench).
- id: b3-map
title: Sketch of the conservatory pots
type: map
body: |
```
[ 1] [ 2] [ 3] [ 4]
[ 5] [ 6] [ 7] [ 8]
[ 9] [10] [11] [12]
[13] [14] [15] [16]
```
lock:
id: lock-pot
prompt: Which pot is the dagger buried in?
inputType: code
format: Pot number
answers: ["11"]
hints:
- At midnight, which pots would be muddy? Which would be dry?
- Clean gloves means the killer dug in dry soil. Dry soil means a prime-numbered pot.
- Of the disturbed pots, only 11 is prime, and the others have an innocent explanation anyway.
successText: |
**Pot 11.** Under the cactus: the jewelled dagger. Wrapped around the hilt is a strip of black silk
torn from a cloak.
keyName: Dagger Key
- id: ch4
title: Julian's Confession
narrative: |
In Lord Julian's desk, a sealed envelope addressed *"To be opened after my death."*
Inside: rows of numbers, and nothing else. Julian was fond of the old soldiers' square cipher.
clues:
A:
- id: a4-numbers
title: The letter inside the envelope
type: letter
body: |
```
44 23 13 25 31
52 23 25
42 21 21 12 35 22 21 14
```
*Below, in Julian's hand: "God forgive me. I built my square on our motto, as Father taught me."*
- id: a4-howto
title: Police cipher handbook, page 12
type: document
body: |
**The square cipher (Polybius).** Write a keyword into a 5×5 square, row by row, skipping
repeated letters, then fill the rest with the unused alphabet (I and J share a square).
Each letter is written as **row then column**, e.g. 23 = row 2, column 3.
B:
- id: b4-crest
title: The Montague crest above the fireplace
type: photo
body: |
*A carved stone shield with a lion and a ship. Beneath it, the family motto:*
**FORTUNE FAVOURS THE BOLD**
- id: b4-marsh
title: Servants' gossip about the Marsh family
type: note
body: |
Twenty years ago, Mr Edward Marsh owned this house. Lord Julian accused him of cheating at cards.
Marsh lost everything, the house went to Julian, and Marsh died in debtors' prison.
*He had a little daughter. Nobody knows what became of her.*
lock:
id: lock-confession
prompt: What did Julian confess?
inputType: text
format: Three words
answers: ["MARSHWASINNOCENT"]
hints:
- Build the square from FORTUNE FAVOURS THE BOLD, skipping repeats.
- "Row 1: F O R T U. Row 2: N E A V S. Row 3: H B L D C. Row 4: G I K M P. Row 5: Q W X Y Z."
- 44 = row 4, column 4 = M. The message starts MARSH.
successText: |
**"MARSH WAS INNOCENT."** Julian framed Edward Marsh and took his house.
And Edward Marsh had a daughter. **Ivy** Marsh.
keyName: Crest Key
finale:
type: accusation
prompt: The clocks strike one. Make your accusation, all four parts.
maxAttempts: 1
questions:
- id: who
label: Who killed Lord Julian?
options:
- { id: agatha, label: Lady Agatha Stroud }
- { id: rupert, label: Colonel Rupert Blackwood }
- { id: ivy, label: Miss Ivy Marsh }
- { id: silas, label: Mr Silas Penhaligon }
- { id: odette, label: Mrs Odette Vance }
answer: ivy
- id: mask
label: Which mask was the killer wearing in the conservatory?
options:
- { id: fox, label: Fox }
- { id: raven, label: Raven }
- { id: swan, label: Swan }
- { id: lion, label: Lion }
- { id: stag, label: Stag }
answer: raven
- id: how
label: How?
options:
- { 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 }
answer: dagger
- id: why
label: Why?
options:
- { id: jilted, label: He jilted her for a duke's daughter }
- { id: debts, label: To escape gambling debts }
- { id: father, label: He framed her father and stole the family home }
- { id: business, label: To save a failing shipping company }
answer: father
successEnding: |
Ivy takes off the Raven mask and sets it on the table, very gently.
"He took our house. He took my father. Twenty years I've practised being a stranger in it."
She suggested the mask swap as "a lark" so that Odette, the famous jilted widow, would be seen
as the Raven. She followed Julian to the conservatory at ten to midnight, used the dagger
she'd lifted during the Grand March, and buried it in the one pot she knew would be dry.
She forgot one thing: she swapped masks, but not cloaks.
**Case closed.** 🎭🔑
failEnding: |
The Inspector makes an arrest. The real killer curtsies on her way out the door, and is never seen again.
reveal: |
**Miss Ivy Marsh**, wearing **Odette's Raven mask** after the swap, stabbed Julian with the
**armoury dagger** and buried it in **cactus pot 11**, because Julian had **framed her father
and stolen Montague House**.
+94
View File
@@ -0,0 +1,94 @@
id: mirror-maze
engine: labyrinth
category: co-op
title: The Mirror Maze
tagline: The map is backwards. Your partner can't tell north from left. Seven traps. Good luck.
estimatedMinutes: 30
difficulty: 5
players: { min: 2, max: 2 }
theme: { primary: "#5f0f40", accent: "#c0fdff", background: "#07040a", headingFont: "Playfair Display" }
roles:
A:
name: Navigator
blurb: You hold the only map of the maze. It was copied from a mirror, so it's flipped left to right.
B:
name: Explorer
blurb: You're inside a hall of mirrors. You have no compass, only forward, back, left and right.
intro: |
An abandoned carnival on the edge of town. Its famous **Mirror Maze** has four **glass lanterns** hidden inside.
Legend says couples who carry all four out together never lose each other.
☠️ **Nightmare rules:**
- **Explorer:** you have no compass. Your controls are **Forward / Left / Right / Back**, relative to the way you're facing.
Whenever you move, you turn to face that way.
- **Navigator:** the only surviving map was **traced from a mirror**. Everything on it is flipped **left to right**.
- Some carvings appear **twice** in the maze. Don't trust a single glyph.
- **Seven** hidden traps. Four lanterns needed.
# Grid as it REALLY is (server truth). The Navigator is served the mirror image (see navigatorView).
grid:
- "###############"
- "#S..#.........#"
- "###.#.#.#####.#"
- "#K#.....#...#.#"
- "#.####.##.#.#.#"
- "#..T.....T....#"
- "#.###T#.###..T#"
- "#...#T#.#.....#"
- "#.#.#.#.#.###.#"
- "#..K#.#.....#K#"
- "#.#.#.#.###.#T#"
- "#.#.........#.#"
- "#.#.#.#.###.#.#"
- "#KT...#......E#"
- "###############"
lanternsRequired: 4
explorer:
compass: relative # controls are forward/left/right/back instead of N/E/S/W
startFacing: e # the only open way out of S
# Server converts relative → absolute using the Explorer's current facing, then sets facing = move direction.
# Explorer's view lists open directions as relative ("Open: forward, right") and never shows N/E/S/W.
glyphs:
- { x: 1, y: 1, glyph: ticket, label: "🎟 A torn carnival ticket (the entrance)" }
- { x: 3, y: 1, glyph: crescent, label: "☾ A crescent moon etched in the glass" }
- { x: 11, y: 11, glyph: crescent, label: "☾ A crescent moon etched in the glass" }
- { x: 9, y: 3, glyph: star, label: "✦ A four-pointed star" }
- { x: 7, y: 11, glyph: star, label: "✦ A four-pointed star" }
- { x: 13, y: 1, glyph: shard, label: "🪞 A shattered mirror panel" }
- { x: 5, y: 5, glyph: bell, label: "🔔 A tiny brass bell hanging from the ceiling" }
- { x: 11, y: 7, glyph: butterfly, label: "🦋 A painted butterfly" }
- { x: 1, y: 7, glyph: feather, label: "🪶 A feather on the floor" }
- { x: 9, y: 9, glyph: rose, label: "🌹 A paper rose" }
- { x: 5, y: 13, glyph: heart, label: "♥ Two hearts scratched into the glass" }
lanterns:
- { x: 1, y: 3, name: Lantern of Reflection }
- { x: 1, y: 13, name: Lantern of Patience }
- { x: 3, y: 9, name: Lantern of Trust }
- { x: 13, y: 9, name: Lantern of Home }
navigatorView:
mapTransform: mirror-x # serve each row reversed; glyph/lantern/trap x becomes (width - 1 - x)
showMoveCount: true
banner: "This map was traced from a mirror. Left is right. Right is left."
explorerView:
flavour:
blank: "Your own reflection stares back from every side."
trap: "The mirror floor cracks and you drop through... and wake up at the entrance, facing the way in."
lantern: "A glass lantern glows to life in your hands. ({name})"
exitLocked: "A mirrored door with four lantern hooks. It won't budge."
exitOpen: "You hang four lanterns. Every mirror in the maze lights up at once, and the door opens onto the fairground."
scoring:
trapPenaltySeconds: 90
parMoves: 110
successEnding: |
The Explorer stumbles out into the empty fairground. The Navigator is waiting by the ferris wheel,
holding the backwards map upside down, grinning.
**You never lost each other.** 🪞🔑
+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**.
+2 -2
View File
@@ -3,8 +3,8 @@ engine: story
category: murder-mystery
title: Murder on the Southern Star
tagline: An overnight train through the Karoo. A diamond king. A long, cold night.
estimatedMinutes: 70
difficulty: 3
estimatedMinutes: 25
difficulty: 2
players: { min: 2, max: 2 }
theme: { primary: "#2e4a3a", accent: "#c9a227", background: "#0d1411", headingFont: "Playfair Display" }
roles:
+1 -1
View File
@@ -3,7 +3,7 @@ engine: story
category: escape-room
title: "Signal Lost: Station Echo"
tagline: An Antarctic research station. The power's out. The air is running thin.
estimatedMinutes: 45
estimatedMinutes: 15
difficulty: 2
players: { min: 2, max: 2 }
theme: { primary: "#2b6f8a", accent: "#7fe3ff", background: "#060d12", headingFont: "Playfair Display" }
+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!** 🌳🔑
+303
View File
@@ -0,0 +1,303 @@
id: tick-tock-overtime
engine: defuse
category: co-op
title: "Tick Tock: Overtime"
tagline: Six modules. Four levels. A manual that fights back.
estimatedMinutes: 20
difficulty: 4
players: { min: 2, max: 2 }
theme: { primary: "#ff3b30", accent: "#00d1ff", background: "#0a0a0a", headingFont: "JetBrains Mono" }
roles:
A:
name: Technician
blurb: You see the device. You can't see the manual. Describe everything, exactly.
B:
name: Expert
blurb: You have the manual. You can't see the device. Ask the right questions, in the right order.
intro: |
Remember the training device? This is the sequel. Someone upgraded it.
New modules, new rules, indicator lights, and a manual written by someone who clearly hates couples.
**Technician:** start by reading out the serial number, battery count, and every indicator (lit and unlit).
**Expert:** you'll need all of it.
# ---------- Device definition ----------
ruleset: overtime # which manual the server's solver implements (server/src/engines/defuse.ts)
device:
serial:
length: 6
alphabet: "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789" # last char is ALWAYS a digit
batteries: { min: 0, max: 4 }
indicators:
labels: [LUV, HUG, BAE, XOX, DTE]
count: { min: 1, max: 3 } # each shown indicator is randomly lit or unlit
levels:
- id: lvl1
title: Warm-up (that isn't)
timeSeconds: 240
modules: [wires, button, keypad]
keyName: Spark Key
- id: lvl2
title: Words & Waves
timeSeconds: 270
modules: [wires, passwords, morse]
keyName: Frequency Key
- id: lvl3
title: Say It Back
timeSeconds: 300
modules: [wires, button, passwords, simon]
keyName: Echo Key
- id: lvl4
title: Overtime
timeSeconds: 360
strikesOverride: 2 # only 2 strikes on this level
modules: [wires, button, keypad, passwords, morse, simon]
keyName: Overtime Key
strikes: { max: 3, speedUpPerStrike: 0.25 }
# ---------- Module generation ----------
modules:
wires:
count: { min: 3, max: 6 }
colors: [red, blue, yellow, green, white, black]
# Technician: wires top to bottom, colour swatch + colour NAME label. Action { type: "cut", index }
button:
labels: [HOLD, PRESS, ABORT, DETONATE, LOVE]
colors: [red, blue, white, yellow]
stripColors: [blue, white, yellow, red]
# Strip only shown WHILE holding. Actions: tap | hold → release { timerText }
keypad:
symbols:
heart: "♥ Heart"
star: "★ Star"
moon: "☾ Moon"
sun: "☀ Sun"
club: "♣ Club"
flower: "✿ Flower"
key: "⚷ Key"
umbrella: "☂ Umbrella"
plane: "✈ Plane"
music: "♫ Music note"
anchor: "⚓ Anchor"
crown: "♛ Crown"
diamond: "♦ Diamond"
spade: "♠ Spade"
bolt: "⚡ Bolt"
cloud: "☁ Cloud"
columns:
- [heart, star, moon, sun, club, flower, key]
- [umbrella, heart, plane, music, star, anchor, crown]
- [diamond, sun, spade, umbrella, flower, music, bolt]
- [anchor, spade, club, plane, moon, diamond, key]
- [crown, bolt, heart, diamond, plane, sun, cloud]
- [cloud, music, key, spade, club, crown, star]
# Generator: pick a column, pick 4 of its symbols, shuffle. Re-roll if any OTHER column also
# contains all 4 (validated: no 4-subset of these columns is ambiguous).
passwords:
columnsCount: 5
lettersPerColumn: 6
words: [ADORE, ANGEL, BLISS, BLUSH, CANDY, CHARM, CRUSH, DANCE, DREAM, FLAME, FLIRT, HEART,
HONEY, LOVER, MATCH, PEACH, RINGS, ROSES, SMILE, SPARK, SUGAR, SWEET, TEDDY, TRUST,
TULIP, UNITY, WALTZ, BLOOM, CHOSE, PEARL, VOWED, QUEEN]
# Technician sees 5 columns with up/down arrows; each column cycles through 6 letters.
# Generator: pick a target word; fill each column with its letter + 5 random letters; re-roll until
# EXACTLY ONE word in the list can be formed. Action { type: "submit", word }
morse:
# Technician sees a blinking light (reuse the signal renderer, §6.3) and a frequency dial
# from 3.500 to 3.600 MHz in 0.001 steps + a TX button. Action { type: "transmit", frequency }
words:
HUG: "3.505"
KISS: "3.515"
LOVE: "3.522"
DATE: "3.532"
RING: "3.535"
ROSE: "3.542"
WINE: "3.545"
DUET: "3.552"
DANCE: "3.555"
HEART: "3.565"
SWEET: "3.572"
HONEY: "3.575"
CUDDLE: "3.582"
SPARK: "3.592"
VOWS: "3.595"
BLISS: "3.600"
simon:
colors: [red, blue, green, yellow]
stages: { min: 3, max: 5 }
# Device flashes a sequence; each stage adds one flash. Technician presses the MAPPED colours
# for the whole sequence so far. Mapping depends on (serial has a vowel) and CURRENT strike count.
# Action { type: "press", color }
# ---------- The manual (shown ONLY to the Expert) ----------
manual: |
# Defusal Manual: Overtime Edition
**Before anything else, ask for:** serial number · battery count · every indicator and whether it's **lit**.
A "vowel" means A, E, I, O or U.
---
## Module: Wires (3 to 6 wires)
Wires are numbered **top to bottom** from 1. Cut exactly one. Find the section for the number of wires,
then use the **first** rule that applies.
**3 wires**
1. If all three wires are **different colours** → cut the **second** wire.
2. If there are **two or more blue** → cut the **first blue** wire.
3. If the serial contains a **vowel** → cut the **last** wire.
4. Otherwise → cut the **first** wire.
**4 wires**
1. If there is **exactly one green** wire and **2 or more batteries** → cut the **green** wire.
2. If the **first and last** wires are the **same colour** → cut the **third** wire.
3. If there are **no white** wires → cut the **second** wire.
4. Otherwise → cut the **last white** wire.
**5 wires**
1. If there are **more black than red** wires → cut the **first black** wire.
2. If the serial's **last digit is odd** → cut the **fourth** wire.
3. If there is **exactly one yellow** → cut the wire **directly after** the yellow (if yellow is last, cut the **first**).
4. Otherwise → cut the **second** wire.
**6 wires**
1. If there's a **lit LUV** indicator → cut the **sixth** wire.
2. If there are **exactly three red** wires → cut the **third red** wire.
3. If there are **no yellow** wires → cut the **third** wire.
4. Otherwise → cut the **fifth** wire.
---
## Module: The Button
First rule that applies:
1. **Blue** button that says **ABORT** → **hold**.
2. **More than 1 battery** and says **DETONATE** → **tap**.
3. **White** button and a **lit BAE** → **hold**.
4. **More than 2 batteries** and a **lit HUG** → **tap**.
5. **Yellow** button → **hold**.
6. **Red** button that says **HOLD** → **tap**.
7. Otherwise → **hold**.
**If holding**, a strip lights up. Release when the timer shows this digit **anywhere**:
Blue → **4** · White → **1** · Yellow → **5** · Red → **3**
---
## Module: Keypad
Exactly one column below contains all four symbols on the device.
Press them in the order they appear **top to bottom** in that column.
| 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|
| ♥ Heart | ☂ Umbrella | ♦ Diamond | ⚓ Anchor | ♛ Crown | ☁ Cloud |
| ★ Star | ♥ Heart | ☀ Sun | ♠ Spade | ⚡ Bolt | ♫ Music note |
| ☾ Moon | ✈ Plane | ♠ Spade | ♣ Club | ♥ Heart | ⚷ Key |
| ☀ Sun | ♫ Music note | ☂ Umbrella | ✈ Plane | ♦ Diamond | ♠ Spade |
| ♣ Club | ★ Star | ✿ Flower | ☾ Moon | ✈ Plane | ♣ Club |
| ✿ Flower | ⚓ Anchor | ♫ Music note | ♦ Diamond | ☀ Sun | ♛ Crown |
| ⚷ Key | ♛ Crown | ⚡ Bolt | ⚷ Key | ☁ Cloud | ★ Star |
---
## Module: Passwords
The Technician can cycle five letter columns. Exactly **one** word below can be spelled.
Find it and have them submit it.
ADORE · ANGEL · BLISS · BLOOM · BLUSH · CANDY · CHARM · CHOSE · CRUSH · DANCE · DREAM
FLAME · FLIRT · HEART · HONEY · LOVER · MATCH · PEACH · PEARL · QUEEN · RINGS · ROSES
SMILE · SPARK · SUGAR · SWEET · TEDDY · TRUST · TULIP · UNITY · VOWED · WALTZ
*Tip: ask for the letters in column 1 first, then 2. That usually narrows it down fast.*
---
## Module: Morse Light
The light blinks a word in Morse, over and over, with a long pause between repeats.
Short = dot · Long = dash. Decode the word, then have them tune and transmit:
| Word | MHz | Word | MHz |
|------|-----|------|-----|
| HUG | 3.505 | DUET | 3.552 |
| KISS | 3.515 | DANCE | 3.555 |
| LOVE | 3.522 | HEART | 3.565 |
| DATE | 3.532 | SWEET | 3.572 |
| RING | 3.535 | HONEY | 3.575 |
| ROSE | 3.542 | CUDDLE | 3.582 |
| WINE | 3.545 | SPARK | 3.592 |
| BLISS | 3.600 | VOWS | 3.595 |
`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 --..`
---
## Module: Say It Back (Simon)
The device flashes colours. The Technician must press the **mapped** colour for every flash so far.
⚠️ The mapping **changes with the strike count**. Re-check after every strike.
**Serial HAS a vowel**
| Strikes | Red → | Blue → | Green → | Yellow → |
|---------|-------|--------|---------|----------|
| 0 | Blue | Red | Yellow | Green |
| 1 | Yellow | Green | Blue | Red |
| 2 | Green | Red | Yellow | Blue |
**Serial has NO vowel**
| Strikes | Red → | Blue → | Green → | Yellow → |
|---------|-------|--------|---------|----------|
| 0 | Blue | Yellow | Green | Red |
| 1 | Red | Blue | Yellow | Green |
| 2 | Yellow | Green | Blue | Red |
# ---------- Solver fixtures (MUST pass as unit tests) ----------
# 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}}
- {"serial": "QX7M35", "batteries": 0, "indicators": {"lit": [], "unlit": []}, "wires": ["blue", "red", "blue"], "expect": {"type": "cut", "index": 1}}
- {"serial": "ZP2R44", "batteries": 2, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "red", "white"], "expect": {"type": "cut", "index": 1}}
- {"serial": "BC2D48", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "red", "green"], "expect": {"type": "cut", "index": 1}}
- {"serial": "AB9C11", "batteries": 2, "indicators": {"lit": [], "unlit": []}, "wires": ["green", "red", "blue", "white"], "expect": {"type": "cut", "index": 1}}
- {"serial": "LM3N06", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["blue", "red", "yellow", "blue"], "expect": {"type": "cut", "index": 3}}
- {"serial": "RT5W22", "batteries": 0, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "yellow", "red", "black"], "expect": {"type": "cut", "index": 2}}
- {"serial": "HK8P71", "batteries": 0, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "white", "blue", "white"], "expect": {"type": "cut", "index": 4}}
- {"serial": "XY3Z45", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["black", "black", "red", "blue", "white"], "expect": {"type": "cut", "index": 1}}
- {"serial": "PQ7R18", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "yellow", "white", "green"], "expect": {"type": "cut", "index": 4}}
- {"serial": "PQ7R12", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "yellow", "white", "green"], "expect": {"type": "cut", "index": 4}}
- {"serial": "PQ7R12", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "blue", "white", "white", "yellow"], "expect": {"type": "cut", "index": 1}}
- {"serial": "ST4V20", "batteries": 1, "indicators": {"lit": [], "unlit": []}, "wires": ["red", "white", "blue", "green", "black"], "expect": {"type": "cut", "index": 2}}
- {"serial": "BC1D20", "batteries": 3, "indicators": {"lit": ["LUV"], "unlit": []}, "wires": ["red", "red", "red", "blue", "white", "green"], "expect": {"type": "cut", "index": 6}}
- {"serial": "BC1D20", "batteries": 3, "indicators": {"lit": [], "unlit": ["LUV"]}, "wires": ["red", "blue", "red", "white", "red", "green"], "expect": {"type": "cut", "index": 5}}
- {"serial": "BC1D20", "batteries": 3, "indicators": {"lit": [], "unlit": []}, "wires": ["blue", "blue", "white", "green", "black", "red"], "expect": {"type": "cut", "index": 3}}
- {"serial": "BC1D20", "batteries": 3, "indicators": {"lit": [], "unlit": []}, "wires": ["yellow", "blue", "white", "green", "black", "red"], "expect": {"type": "cut", "index": 5}}
button:
- {"batteries": 0, "indicators": {"lit": [], "unlit": []}, "label": "ABORT", "color": "blue", "strip": "yellow", "expect": {"type": "hold", "releaseDigit": 5}}
- {"batteries": 2, "indicators": {"lit": [], "unlit": []}, "label": "DETONATE", "color": "red", "strip": "blue", "expect": {"type": "tap"}}
- {"batteries": 1, "indicators": {"lit": ["BAE"], "unlit": []}, "label": "LOVE", "color": "white", "strip": "red", "expect": {"type": "hold", "releaseDigit": 3}}
- {"batteries": 3, "indicators": {"lit": ["HUG"], "unlit": []}, "label": "PRESS", "color": "red", "strip": "white", "expect": {"type": "tap"}}
- {"batteries": 0, "indicators": {"lit": [], "unlit": []}, "label": "PRESS", "color": "yellow", "strip": "blue", "expect": {"type": "hold", "releaseDigit": 4}}
- {"batteries": 1, "indicators": {"lit": [], "unlit": []}, "label": "HOLD", "color": "red", "strip": "white", "expect": {"type": "tap"}}
- {"batteries": 1, "indicators": {"lit": [], "unlit": ["HUG"]}, "label": "LOVE", "color": "white", "strip": "red", "expect": {"type": "hold", "releaseDigit": 3}}
keypad:
- {"symbols": ["star", "flower", "heart", "sun"], "expect": ["heart", "star", "sun", "flower"]}
- {"symbols": ["spade", "plane", "anchor", "moon"], "expect": ["anchor", "spade", "plane", "moon"]}
- {"symbols": ["music", "umbrella", "diamond", "sun"], "expect": ["diamond", "sun", "umbrella", "music"]}
- {"symbols": ["cloud", "crown", "bolt", "plane"], "expect": ["crown", "bolt", "plane", "cloud"]}
- {"symbols": ["star", "key", "club", "crown"], "expect": ["key", "club", "crown", "star"]}
passwords:
- {"columns": ["XAPUDI", "RVCSEX", "TGAHRQ", "RASIYZ", "CDGKYL"], "expect": "PEARL"}
- {"columns": ["BZRWXO", "GLTUAS", "YHCDLJ", "FVTGIL", "OFZIUM"], "expect": "WALTZ"}
- {"columns": ["KYBZTH", "UPOMRG", "SLXUMN", "SEFZUD", "ROTIAQ"], "expect": "TRUST"}
morse:
- {"signal": "..../..-/--.", "expect": {"word": "HUG", "frequency": "3.505"}}
- {"signal": "...././.-/.-./-", "expect": {"word": "HEART", "frequency": "3.565"}}
- {"signal": "-.../.-../../.../...", "expect": {"word": "BLISS", "frequency": "3.600"}}
- {"signal": "...-/---/.--/...", "expect": {"word": "VOWS", "frequency": "3.595"}}
simon:
- {"serial": "TK4E82", "strikes": 0, "flashes": ["red", "green", "yellow"], "expect": ["blue", "yellow", "green"]}
- {"serial": "TK4E82", "strikes": 1, "flashes": ["red", "green", "yellow"], "expect": ["yellow", "blue", "red"]}
- {"serial": "ZP2R44", "strikes": 0, "flashes": ["blue", "blue", "green", "yellow"], "expect": ["yellow", "yellow", "green", "red"]}
- {"serial": "ZP2R44", "strikes": 2, "flashes": ["yellow", "red", "green"], "expect": ["red", "yellow", "blue"]}
+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. 💣🔑
+2 -2
View File
@@ -3,8 +3,8 @@ engine: defuse
category: co-op
title: Tick Tock Together
tagline: One of you sees the bomb. One of you has the manual. Talk fast.
estimatedMinutes: 25
difficulty: 2
estimatedMinutes: 10
difficulty: 1
players: { min: 2, max: 2 }
theme: { primary: "#b3261e", accent: "#ffcc00", background: "#0e0e0e", headingFont: "JetBrains Mono" }
roles:
+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.** 🪞🔑
+1 -1
View File
@@ -3,7 +3,7 @@ engine: story
category: murder-mystery
title: Death at Vineyard Hollow
tagline: A harvest gala. A priceless vintage. A host who never made it to dessert.
estimatedMinutes: 60
estimatedMinutes: 20
difficulty: 2
players: { min: 2, max: 2 }
theme: { primary: "#7b1e3a", accent: "#d4a373", background: "#160c10", headingFont: "Playfair Display" }
+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.0.1",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "two-keys",
"version": "1.0.1",
"version": "1.3.0",
"workspaces": [
"shared",
"server",
@@ -23,7 +23,7 @@
},
"client": {
"name": "@two-keys/client",
"version": "1.0.1",
"version": "1.3.0",
"dependencies": {
"@two-keys/shared": "*",
"framer-motion": "^11.11.0",
@@ -6847,7 +6847,7 @@
},
"server": {
"name": "@two-keys/server",
"version": "1.0.1",
"version": "1.3.0",
"dependencies": {
"@two-keys/shared": "*",
"express": "^4.21.0",
@@ -7332,7 +7332,7 @@
},
"shared": {
"name": "@two-keys/shared",
"version": "1.0.1",
"version": "1.3.0",
"devDependencies": {
"vitest": "^2.1.0"
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "two-keys",
"private": true,
"version": "1.0.1",
"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.0.1",
"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)
+234 -23
View File
@@ -1,28 +1,65 @@
// 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,
GameSummary,
LabyrinthView,
LockView,
MoveDir,
Role,
StoryView,
SyncView,
TwinView,
} from '@two-keys/shared';
import { otherRole } from '@two-keys/shared';
import { remainingAt, type DefuseState } from '../engines/defuse';
import { totalKeys } from '../engines/index';
import { DELTAS, cellAt, isWall, type LabyrinthState } from '../engines/labyrinth';
import { NUDGE_AFTER_WRONG, type StoryState } from '../engines/story';
import { canReplay, type SyncState } from '../engines/sync';
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,
REL_DIRS,
cellAt,
facingOf,
gateBlocks,
isWall,
toAbsolute,
type LabyrinthState,
} 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 {
@@ -36,6 +73,7 @@ export function gameSummary(game: Game): GameSummary {
theme: { ...game.theme },
roles: { A: { ...game.roles.A }, B: { ...game.roles.B } },
totalKeys: totalKeys(game),
timeLimitMinutes: timeLimitSeconds(game) === null ? null : timeLimitSeconds(game)! / 60,
};
}
@@ -51,7 +89,7 @@ function clueView(clue: Clue, audience: ClueView['audience']): ClueView {
return view;
}
function lockView(lock: Lock, s: StoryState): LockView {
function lockView(game: StoryGame, lock: Lock, s: StoryState): LockView {
const solved = s.solved.includes(lock.id);
const wrongAttempts = s.wrong[lock.id] ?? 0;
return {
@@ -63,6 +101,8 @@ function lockView(lock: Lock, s: StoryState): LockView {
solved,
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,
@@ -81,7 +121,7 @@ export function storyView(game: StoryGame, s: StoryState, role: Role): StoryView
...ch.clues[role].map((c) => clueView(c, 'private')),
...ch.clues.shared.map((c) => clueView(c, 'shared')),
],
lock: lockView(ch.lock, s),
lock: lockView(game, ch.lock, s),
}));
let finale: StoryView['finale'] = null;
@@ -108,7 +148,7 @@ export function storyView(game: StoryGame, s: StoryState, role: Role): StoryView
type: 'escape',
prompt: f.prompt,
clues: f.clues[role].map((c) => clueView(c, 'private')),
lock: lockView(f.lock, s),
lock: lockView(game, f.lock, s),
};
}
@@ -120,33 +160,46 @@ export function storyView(game: StoryGame, s: StoryState, role: Role): StoryView
totalChapters: total,
awaitingContinue: s.awaitingContinue,
finale,
pins: [...(s.pins?.[role] ?? [])],
// Only a count: which clues the partner starred would hint at what they hold.
partnerPins: s.pins?.[otherRole(role)]?.length ?? 0,
};
}
// ---------- defuse ----------
/** The Morse dial runs from 3.500 to 3.600 MHz. */
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 },
status: s.status,
failReason: s.failReason,
strikes: s.strikes,
maxStrikes: game.strikes.max,
maxStrikes: maxStrikes(game, s.level),
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',
@@ -170,14 +223,39 @@ export function defuseView(game: DefuseGame, s: DefuseState, role: Role, now: nu
})),
});
}
return { ...common, seat: 'technician', device: { serial: d.serial, batteries: d.batteries, modules } };
if (d.passwords) modules.push({ id: 'passwords', solved: d.passwords.solved, columns: [...d.passwords.columns] });
if (d.morse) {
// The light is the puzzle: the Technician gets the blink pattern, never the word.
modules.push({ id: 'morse', solved: d.morse.solved, signal: toSignal(d.morse.word), ...MORSE_BAND });
}
if (d.simon) {
modules.push({
id: 'simon',
solved: d.simon.solved,
colors: [...(game.modules.simon?.colors ?? [])],
flashes: d.simon.sequence.slice(0, d.simon.stage),
stage: d.simon.stage,
stages: d.simon.sequence.length,
entered: d.simon.progress,
});
}
return {
...common,
seat: 'technician',
device: {
serial: d.serial,
batteries: d.batteries,
indicators: (d.indicators ?? []).map((i) => ({ label: i.label, lit: i.lit })),
modules,
},
};
}
// ---------- sync ----------
export function syncView(game: SyncGame, s: SyncState, role: Role): SyncView {
const round = game.rounds[s.round];
const subject = round.type === 'predict' ? (round.subject ?? 'A') : null;
const subject = subjectOf(round, s.q);
const q = s.status === 'question' || s.status === 'reveal' ? s.questions[s.q] : undefined;
const answers = q ? (s.answers[q.id] ?? {}) : {};
const both = answers.A !== undefined && answers.B !== undefined;
@@ -196,20 +274,20 @@ export function syncView(game: SyncGame, s: SyncState, role: Role): SyncView {
isReplay: s.attempt > 0,
questionNumber: s.q + 1,
questionsPerRound: s.questions.length,
pointsForKey: game.rules.pointsForKey,
pointsForKey: pointsForKey(game, round),
points: s.marks.filter(Boolean).length,
question: q
? {
id: q.id,
text: q.q.replaceAll('{subject}', subject ? game.roles[subject].name : ''),
options: [...q.options],
options: [...(q.options ?? q.items ?? [])],
}
: null,
iAmSubject: subject === role,
myAnswer: answers[role] ?? null,
// Whether the partner has answered, never what they answered, until both are in.
partnerAnswered: answers[otherRole(role)] !== undefined,
reveal: both ? { A: answers.A!, B: answers.B!, match: answers.A === answers.B } : null,
reveal: both ? { A: answers.A!, B: answers.B!, match: isMatch(round.type, answers.A!, answers.B!) } : null,
marks: [...s.marks],
summary:
s.status === 'summary'
@@ -226,12 +304,25 @@ export function labyrinthView(game: LabyrinthGame, s: LabyrinthState, role: Role
// Navigator: the whole map, but never the Explorer's coordinates.
if (role === 'A') {
// A mirrored map is flipped left to right: rows reversed, x counted from the other side.
const mirror = game.navigatorView.mapTransform === 'mirror-x';
const width = game.grid[0].length;
const fx = (x: number) => (mirror ? width - 1 - x : x);
return {
engine: 'labyrinth',
seat: 'navigator',
grid: [...game.grid],
glyphs: game.glyphs.map((g) => ({ x: g.x, y: g.y, label: g.label })),
lanterns: game.lanterns.map((l, i) => ({ x: l.x, y: l.y, name: l.name, collected: s.collected[i] })),
grid: game.grid.map((row) => (mirror ? [...row].reverse().join('') : row)),
glyphs: game.glyphs.map((g) => ({ x: fx(g.x), y: g.y, label: g.label })),
lanterns: game.lanterns.map((l, i) => ({ x: fx(l.x), y: l.y, name: l.name, collected: s.collected[i] })),
oneWay: game.oneWay.map((g) => ({
x: fx(g.x),
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,
moves: game.navigatorView.showMoveCount ? s.moves : null,
@@ -240,22 +331,136 @@ export function labyrinthView(game: LabyrinthGame, s: LabyrinthState, role: Role
}
// Explorer: only the cell under their feet. No grid, no coordinates, no traps.
const open = {} as Record<Dir, boolean>;
for (const dir of Object.keys(DELTAS) as Dir[]) {
open[dir] = !isWall(game.grid, s.x + DELTAS[dir].x, s.y + DELTAS[dir].y);
// With no compass, the ways out are forward/left/right/back and never N/E/S/W.
const relative = game.explorer.compass === 'relative';
const facing = facingOf(game, s);
const open: Partial<Record<MoveDir, boolean>> = {};
const gates: MoveDir[] = [];
for (const move of relative ? REL_DIRS : (Object.keys(DELTAS) as Dir[])) {
const dir = relative ? toAbsolute(facing, move as (typeof REL_DIRS)[number]) : (move as Dir);
const wall = isWall(game.grid, s.x + DELTAS[dir].x, s.y + DELTAS[dir].y);
const gate = !wall && gateBlocks(game.oneWay, s.x, s.y, dir);
open[move] = !wall && !gate;
if (gate) gates.push(move);
}
const glyph = game.glyphs.find((g) => g.x === s.x && g.y === s.y);
return {
engine: 'labyrinth',
seat: 'explorer',
glyphLabel: glyph?.label ?? null,
compass: game.explorer.compass,
open,
gates,
keysCarried,
totalKeys: total,
atExit: cellAt(game.grid, s.x, s.y) === 'E',
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,
};
}
@@ -269,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);
}
}
+211 -10
View File
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { CLUE_TYPES } from '@two-keys/shared';
import { CLUE_TYPES, DEFUSE_MODULES, SYNC_ROUND_TYPES } from '@two-keys/shared';
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected a #rrggbb colour');
const roleInfo = z.object({ name: z.string().min(1), blurb: z.string() });
@@ -10,7 +10,7 @@ const header = {
title: z.string().min(1),
tagline: z.string().min(1),
estimatedMinutes: z.number().positive(),
difficulty: z.number().int().min(1).max(3),
difficulty: z.number().int().min(1).max(5),
players: z.object({ min: z.literal(2), max: z.literal(2) }),
theme: z.object({ primary: hex, accent: hex, background: hex, headingFont: z.string().min(1) }),
roles: z.object({ A: roleInfo, B: roleInfo }),
@@ -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),
@@ -81,12 +88,20 @@ const finaleSchema = z.discriminatedUnion('type', [
clues: z.object({ A: z.array(clueSchema).default([]), B: z.array(clueSchema).default([]) }),
lock: lockSchema,
successEnding: z.string().min(1),
/** Shown if a time limit runs out. */
failEnding: z.string().min(1).optional(),
}),
]);
const storySchema = z.object({
...header,
engine: z.literal('story'),
rules: z
.object({
hintPenaltySeconds: z.number().int().nonnegative().optional(),
...timeLimitRules,
})
.default({}),
suspects: z
.array(z.object({ id: z.string(), name: z.string(), role: z.string(), description: z.string() }))
.optional(),
@@ -97,14 +112,22 @@ const storySchema = z.object({
// ---------- defuse ----------
const range = z.object({ min: z.number().int().nonnegative(), max: z.number().int().nonnegative() });
const moduleId = z.enum(['wires', 'button', 'keypad']);
const moduleId = z.enum(DEFUSE_MODULES);
const indicators = z.object({ lit: z.array(z.string()), unlit: z.array(z.string()) }).optional();
const defuseSchema = z.object({
...header,
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,
indicators: z.object({ labels: z.array(z.string().min(1)).min(1), count: range }).optional(),
}),
levels: z
.array(
@@ -113,6 +136,7 @@ const defuseSchema = z.object({
title: z.string().min(1),
timeSeconds: z.number().int().positive(),
modules: z.array(moduleId).min(1),
strikesOverride: z.number().int().positive().optional(),
keyName: z.string().min(1),
}),
)
@@ -129,13 +153,25 @@ const defuseSchema = z.object({
symbols: z.record(z.string()),
columns: z.array(z.array(z.string()).min(4)).min(1),
}),
passwords: z
.object({
columnsCount: z.number().int().positive(),
lettersPerColumn: z.number().int().min(2).max(26),
words: z.array(z.string().regex(/^[A-Z]+$/)).min(2),
})
.optional(),
morse: z.object({ words: z.record(z.string().regex(/^\d\.\d{3}$/)) }).optional(),
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({
serial: z.string(),
batteries: z.number().int(),
indicators,
wires: z.array(z.string()),
expect: z.object({ type: z.literal('cut'), index: z.number().int() }),
}),
@@ -143,6 +179,7 @@ const defuseSchema = z.object({
button: z.array(
z.object({
batteries: z.number().int(),
indicators,
label: z.string(),
color: z.string(),
strip: z.string(),
@@ -153,6 +190,20 @@ const defuseSchema = z.object({
}),
),
keypad: z.array(z.object({ symbols: z.array(z.string()), expect: z.array(z.string()) })),
passwords: z.array(z.object({ columns: z.array(z.string()), expect: z.string() })).default([]),
morse: z
.array(z.object({ signal: z.string(), expect: z.object({ word: z.string(), frequency: z.string() }) }))
.default([]),
simon: z
.array(
z.object({
serial: z.string(),
strikes: z.number().int(),
flashes: z.array(z.string()),
expect: z.array(z.string()),
}),
)
.default([]),
}),
});
@@ -163,13 +214,20 @@ const questionSchema = z.object({
q: z.string().min(1),
options: z.array(z.coerce.string()).min(2),
});
const telepathySchema = z.object({ id: z.string().min(1), q: z.string().min(1) });
const rankSchema = z.object({
id: z.string().min(1),
q: z.string().min(1),
items: z.array(z.coerce.string()).min(3),
});
const syncSchema = z.object({
...header,
engine: z.literal('sync'),
rules: z.object({
questionsPerRound: z.number().int().positive(),
pointsForKey: z.number().int().positive(),
/** Default for rounds that don't set their own. */
pointsForKey: z.number().int().positive().default(3),
replaysPerRound: z.number().int().nonnegative(),
}),
rounds: z
@@ -177,19 +235,28 @@ const syncSchema = z.object({
z.object({
id: z.string().min(1),
title: z.string().min(1),
type: z.enum(['match', 'predict']),
type: z.enum(SYNC_ROUND_TYPES),
subject: z.enum(['A', 'B']).optional(),
/** rank rounds: who does the ranking on each question, cycled. */
subjectPattern: z.array(z.enum(['A', 'B'])).min(1).optional(),
pointsForKey: z.number().int().positive().optional(),
blurb: z.string(),
keyName: z.string().min(1),
}),
)
.min(1),
match: z.array(questionSchema),
predict: z.array(questionSchema),
match: z.array(questionSchema).default([]),
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 ----------
const dir = z.enum(['n', 'e', 's', 'w']);
const labyrinthSchema = z.object({
...header,
engine: z.literal('labyrinth'),
@@ -198,6 +265,33 @@ const labyrinthSchema = z.object({
z.object({ x: z.number().int(), y: z.number().int(), glyph: z.string(), label: z.string().min(1) }),
),
lanterns: z.array(z.object({ x: z.number().int(), y: z.number().int(), name: z.string().min(1) })),
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. */
compass: z.enum(['absolute', 'relative']).default('absolute'),
startFacing: dir.default('n'),
})
.default({}),
explorerView: z.object({
flavour: z.object({
blank: z.string(),
@@ -205,25 +299,132 @@ const labyrinthSchema = z.object({
lantern: z.string(),
exitLocked: z.string(),
exitOpen: z.string(),
gate: z.string().optional(),
}),
}),
navigatorView: z.object({ showMoveCount: z.boolean().default(true) }),
navigatorView: z.object({
showMoveCount: z.boolean().default(true),
/** `mirror-x` serves the map flipped left to right. */
mapTransform: z.enum(['none', 'mirror-x']).default('none'),
banner: z.string().optional(),
}),
scoring: z.object({ trapPenaltySeconds: z.number().nonnegative(), parMoves: z.number().int().positive() }),
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>;
export type SyncQuestion = z.infer<typeof questionSchema>;
export type SyncQuestion = { id: string; q: string; options?: string[]; items?: string[] };
+240 -15
View File
@@ -1,7 +1,30 @@
import { normalizeAnswer } from '@two-keys/shared';
import { solveButton, solveKeypad, solveWires } from '../engines/defuse';
import { cellAt, findCells, isReachable, safeReachable } from '../engines/labyrinth';
import type { Clue, DefuseGame, Game, LabyrinthGame, Lock, StoryGame, SyncGame } from './schemas';
import { SYNC_ROUND_TYPES, normalizeAnswer } from '@two-keys/shared';
import {
OVERTIME_TABLES,
buttonSolution,
solveKeypad,
solveMorse,
solvePassword,
solveSimon,
toSignal,
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 { gridA } from '../engines/twin';
import type {
BlueprintGame,
Clue,
DefuseGame,
DialGame,
Game,
LabyrinthGame,
Lock,
StoryGame,
SyncGame,
TwinGame,
} from './schemas';
export const MIN_SYNC_QUESTIONS = 15;
@@ -16,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>();
@@ -75,14 +188,14 @@ function validateStory(game: StoryGame): string[] {
function validateDefuse(game: DefuseGame): string[] {
const errors: string[] = [];
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
const { fixtures, modules } = game;
const { fixtures, modules, ruleset } = game;
fixtures.wires.forEach((f, i) => {
const got = solveWires(f.serial, f.wires);
const got = wireSolution(ruleset, f, f.wires);
if (got.index !== f.expect.index) errors.push(`wires fixture ${i + 1}: expected cut ${f.expect.index}, solver says ${got.index}`);
});
fixtures.button.forEach((f, i) => {
const got = solveButton(f);
const got = buttonSolution(ruleset, { serial: '', ...f }, f);
if (!same(got, f.expect)) errors.push(`button fixture ${i + 1}: expected ${JSON.stringify(f.expect)}, solver says ${JSON.stringify(got)}`);
});
fixtures.keypad.forEach((f, i) => {
@@ -90,6 +203,62 @@ function validateDefuse(game: DefuseGame): string[] {
if (!same(got, f.expect)) errors.push(`keypad fixture ${i + 1}: expected ${f.expect.join(',')}, solver says ${got?.join(',') ?? 'no unique column'}`);
});
fixtures.passwords.forEach((f, i) => {
const p = modules.passwords;
if (p && (f.columns.length !== p.columnsCount || f.columns.some((c) => c.length !== p.lettersPerColumn))) {
errors.push(`passwords fixture ${i + 1}: needs ${p.columnsCount} columns of exactly ${p.lettersPerColumn} letters`);
}
const got = solvePassword(f.columns, modules.passwords?.words ?? []);
if (got !== f.expect) errors.push(`passwords fixture ${i + 1}: expected ${f.expect}, solver says ${got ?? 'no unique word'}`);
});
fixtures.morse.forEach((f, i) => {
const got = solveMorse(f.signal, modules.morse?.words ?? {});
if (!same(got, f.expect)) errors.push(`morse fixture ${i + 1}: expected ${JSON.stringify(f.expect)}, solver says ${JSON.stringify(got)}`);
});
fixtures.simon.forEach((f, i) => {
const got = solveSimon(f.serial, f.strikes, f.flashes);
if (!same(got, f.expect)) errors.push(`simon fixture ${i + 1}: expected ${f.expect.join(',')}, solver says ${got.join(',')}`);
});
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${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) {
const { words, columnsCount } = modules.passwords;
for (const w of words) if (w.length !== columnsCount) errors.push(`password "${w}" is not ${columnsCount} letters`);
if (new Set(words).size !== words.length) errors.push('password list has duplicates');
}
if (modules.morse) {
const entries = Object.entries(modules.morse.words);
for (const [word] of entries) {
if (!solveMorse(toSignal(word), modules.morse.words)) errors.push(`morse word "${word}" can't be sent in Morse`);
}
if (new Set(entries.map(([, f]) => f)).size !== entries.length) errors.push('two morse words share a frequency');
}
if (ruleset === 'overtime') {
// The manual's tables must be the ones the solver was written against.
const t = OVERTIME_TABLES;
if (!same(modules.keypad.columns, t.keypadColumns)) errors.push("keypad columns don't match the Overtime solver's");
if (!same([...(modules.passwords?.words ?? [])].sort(), [...t.passwordWords].sort())) {
errors.push("password word list doesn't match the Overtime solver's");
}
const morse = modules.morse?.words ?? {};
if (Object.keys(morse).length !== Object.keys(t.morseFrequencies).length) errors.push("morse word list doesn't match the Overtime solver's");
for (const [word, freq] of Object.entries(morse)) {
if (t.morseFrequencies[word] !== freq) errors.push(`morse word "${word}" should be on ${t.morseFrequencies[word] ?? 'no frequency'}, the YAML says ${freq}`);
}
}
if (modules.simon && modules.simon.stages.min < 1) errors.push('simon needs at least one stage');
if (!/\d/.test(game.device.serial.alphabet)) errors.push('serial alphabet has no digits');
if (modules.wires.count.min < 1 || modules.wires.count.max < modules.wires.count.min) errors.push('bad wire count range');
const { columns, symbols } = modules.keypad;
@@ -106,18 +275,37 @@ function validateDefuse(game: DefuseGame): string[] {
function validateSync(game: SyncGame): string[] {
const errors: string[] = [];
const perRound = game.rules.questionsPerRound * (1 + game.rules.replaysPerRound);
for (const type of ['match', 'predict'] as const) {
const rounds = game.rounds.filter((r) => r.type === type).length;
if (rounds === 0) continue;
for (const type of SYNC_ROUND_TYPES) {
const rounds = game.rounds.filter((r) => r.type === type);
if (rounds.length === 0) continue;
const bank = game[type];
if (bank.length < MIN_SYNC_QUESTIONS) errors.push(`${type} bank has ${bank.length} questions, needs at least ${MIN_SYNC_QUESTIONS}`);
if (bank.length < rounds * perRound) errors.push(`${type} bank has ${bank.length} questions, but ${rounds * perRound} are needed to never repeat`);
// A question never repeats, except that a spotlight question may be asked once about each partner.
const groups = new Map<string, number>();
for (const r of rounds) groups.set(groupOf(r), (groups.get(groupOf(r)) ?? 0) + 1);
const needed = Math.max(...groups.values()) * perRound;
if (bank.length < needed) errors.push(`${type} bank has ${bank.length} questions, but ${needed} are needed to never repeat`);
if (new Set(bank.map((q) => q.id)).size !== bank.length) errors.push(`${type} bank has duplicate question ids`);
}
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) {
errors.push(`round ${r.id}: subjectPattern should name a subject for each of the ${game.rules.questionsPerRound} questions`);
}
if (pointsForKey(game, r) > game.rules.questionsPerRound) errors.push(`round ${r.id}: pointsForKey is more than questionsPerRound`);
}
if (game.rules.pointsForKey > game.rules.questionsPerRound) errors.push('pointsForKey is more than questionsPerRound');
return errors;
}
@@ -131,24 +319,61 @@ function validateLabyrinth(game: LabyrinthGame): string[] {
const keys = findCells(grid, 'K');
if (starts.length !== 1) errors.push(`grid needs exactly one S, found ${starts.length}`);
if (exits.length !== 1) errors.push(`grid needs exactly one E, found ${exits.length}`);
if (keys.length !== 3) errors.push(`grid needs exactly 3 K, found ${keys.length}`);
const required = game.lanternsRequired;
if (keys.length !== required) errors.push(`grid needs exactly ${required} K, found ${keys.length}`);
if (game.lanterns.length !== keys.length) errors.push('lantern list does not match the K cells in the grid');
for (const l of game.lanterns) {
if (cellAt(grid, l.x, l.y) !== 'K') errors.push(`lantern "${l.name}" at (${l.x},${l.y}) is not on a K cell`);
}
for (const g of game.glyphs) {
if (cellAt(grid, g.x, g.y) === '#') errors.push(`glyph "${g.glyph}" at (${g.x},${g.y}) is inside a wall`);
const c = cellAt(grid, g.x, g.y);
if (c === '#' || c === 'T') errors.push(`glyph "${g.glyph}" at (${g.x},${g.y}) is on a ${c === '#' ? 'wall' : 'trap'}`);
}
for (const g of game.oneWay) {
const through = DELTAS[g.dir];
const ends = [cellAt(grid, g.x, g.y), cellAt(grid, g.x + through.x, g.y + through.y)];
if (ends.includes('#')) errors.push(`one-way gate at (${g.x},${g.y}) ${g.dir} opens into a wall`);
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);
// 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, 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})`);
}
}
return errors;
}
+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;
}
+319 -28
View File
@@ -2,12 +2,26 @@ import type { DefuseAction, DefuseStatus, Role } from '@two-keys/shared';
import type { DefuseGame } from '../content/schemas';
import { OK, fail, type ActionResult, type Ctx } from './types';
// ---------- Solver: implements the manual exactly ----------
// ---------- Solver: implements each manual exactly ----------
// `classic` is the Tick Tock Together manual, `overtime` the Tick Tock: Overtime one.
export type WireSolution = { type: 'cut'; index: number };
export type ButtonSolution = { type: 'tap' } | { type: 'hold'; releaseDigit: number };
export interface Indicators {
lit: string[];
unlit: string[];
}
/** What the Technician reads out before anything else. */
export interface Edgework {
serial: string;
batteries: number;
indicators?: Indicators;
}
/** Returns the 1-based index of the wire to cut. */
const hasVowel = (serial: string) => /[AEIOU]/.test(serial);
const isLit = (e: { indicators?: Indicators }, label: string) => e.indicators?.lit.includes(label) ?? false;
/** Classic manual. Returns the 1-based index of the wire to cut. */
export function solveWires(serial: string, wires: string[]): WireSolution {
const indexesOf = (color: string) => wires.flatMap((w, i) => (w === color ? [i + 1] : []));
const reds = indexesOf('red');
@@ -21,6 +35,43 @@ export function solveWires(serial: string, wires: string[]): WireSolution {
return { type: 'cut', index: 1 };
}
/** Overtime manual: one section per wire count, first rule that applies. */
export function solveWiresOvertime(e: Edgework, wires: string[]): WireSolution {
const indexesOf = (color: string) => wires.flatMap((w, i) => (w === color ? [i + 1] : []));
const cut = (index: number): WireSolution => ({ type: 'cut', index });
const n = wires.length;
if (n === 3) {
if (new Set(wires).size === 3) return cut(2);
if (indexesOf('blue').length >= 2) return cut(indexesOf('blue')[0]);
if (hasVowel(e.serial)) return cut(3);
return cut(1);
}
if (n === 4) {
const greens = indexesOf('green');
const whites = indexesOf('white');
if (greens.length === 1 && e.batteries >= 2) return cut(greens[0]);
if (wires[0] === wires[3]) return cut(3);
if (whites.length === 0) return cut(2);
return cut(whites[whites.length - 1]);
}
if (n === 5) {
const blacks = indexesOf('black');
const yellows = indexesOf('yellow');
const lastDigit = Number(e.serial.match(/\d(?=\D*$)/)?.[0] ?? NaN);
if (blacks.length > indexesOf('red').length) return cut(blacks[0]);
if (lastDigit % 2 === 1) return cut(4);
if (yellows.length === 1) return cut(yellows[0] === 5 ? 1 : yellows[0] + 1);
return cut(2);
}
const reds = indexesOf('red');
if (isLit(e, 'LUV')) return cut(6);
if (reds.length === 3) return cut(reds[2]);
if (!wires.includes('yellow')) return cut(3);
return cut(5);
}
/** Classic manual. */
export function solveButton(b: {
batteries: number;
label: string;
@@ -34,6 +85,85 @@ export function solveButton(b: {
return { type: 'hold', releaseDigit };
}
export function solveButtonOvertime(b: {
batteries: number;
indicators?: Indicators;
label: string;
color: string;
strip: string;
}): ButtonSolution {
const releaseDigit = { blue: 4, white: 1, yellow: 5, red: 3 }[b.strip] ?? 1;
const hold: ButtonSolution = { type: 'hold', releaseDigit };
if (b.color === 'blue' && b.label === 'ABORT') return hold;
if (b.batteries > 1 && b.label === 'DETONATE') return { type: 'tap' };
if (b.color === 'white' && isLit(b, 'BAE')) return hold;
if (b.batteries > 2 && isLit(b, 'HUG')) return { type: 'tap' };
if (b.color === 'yellow') return hold;
if (b.color === 'red' && b.label === 'HOLD') return { type: 'tap' };
return hold;
}
type Ruleset = DefuseGame['ruleset'];
export function wireSolution(ruleset: Ruleset, e: Edgework, wires: string[]): WireSolution {
return ruleset === 'overtime' ? solveWiresOvertime(e, wires) : solveWires(e.serial, wires);
}
export function buttonSolution(
ruleset: Ruleset,
e: Edgework,
button: { label: string; color: string; strip: string },
): ButtonSolution {
const b = { ...button, batteries: e.batteries, indicators: e.indicators };
return ruleset === 'overtime' ? solveButtonOvertime(b) : solveButton(b);
}
/** The one listed word the letter columns can spell, or null unless there is exactly one. */
export function solvePassword(columns: string[], words: string[]): string | null {
const matches = words.filter(
(word) => word.length === columns.length && [...word].every((letter, i) => columns[i].includes(letter)),
);
return matches.length === 1 ? matches[0] : null;
}
const MORSE: Record<string, string> = {
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: '--..',
};
/** "HUG" → "..../..-/--." (the signal format of §6.3). */
export const toSignal = (word: string): string => [...word].map((letter) => MORSE[letter] ?? '').join('/');
export function solveMorse(
signal: string,
words: Record<string, string>,
): { word: string; frequency: string } | null {
const letters = signal.split('/').map((code) => Object.keys(MORSE).find((l) => MORSE[l] === code));
const word = letters.join('');
return letters.every(Boolean) && word in words ? { word, frequency: words[word] } : null;
}
// Say It Back: flashed colour → colour to press, one row per strike count.
const SIMON_MAPS: Record<'vowel' | 'noVowel', Record<string, string>[]> = {
vowel: [
{ red: 'blue', blue: 'red', green: 'yellow', yellow: 'green' },
{ red: 'yellow', blue: 'green', green: 'blue', yellow: 'red' },
{ red: 'green', blue: 'red', green: 'yellow', yellow: 'blue' },
],
noVowel: [
{ red: 'blue', blue: 'yellow', green: 'green', yellow: 'red' },
{ red: 'red', blue: 'blue', green: 'yellow', yellow: 'green' },
{ red: 'yellow', blue: 'green', green: 'blue', yellow: 'red' },
],
};
export function solveSimon(serial: string, strikes: number, flashes: string[]): string[] {
const rows = SIMON_MAPS[hasVowel(serial) ? 'vowel' : 'noVowel'];
const map = rows[Math.min(Math.max(strikes, 0), rows.length - 1)];
return flashes.map((color) => map[color] ?? color);
}
/** The press order, or null unless exactly one column contains every symbol. */
export function solveKeypad(symbols: string[], columns: string[][]): string[] | null {
const matches = columns.filter((col) => symbols.every((s) => col.includes(s)));
@@ -41,6 +171,31 @@ export function solveKeypad(symbols: string[], columns: string[][]): string[] |
return matches[0].filter((s) => symbols.includes(s));
}
/**
* The Overtime manual's tables, as the reference solver has them. The YAML carries its own copy
* (the generator reads that one); validation checks the two agree.
*/
export const OVERTIME_TABLES = {
keypadColumns: [
['heart', 'star', 'moon', 'sun', 'club', 'flower', 'key'],
['umbrella', 'heart', 'plane', 'music', 'star', 'anchor', 'crown'],
['diamond', 'sun', 'spade', 'umbrella', 'flower', 'music', 'bolt'],
['anchor', 'spade', 'club', 'plane', 'moon', 'diamond', 'key'],
['crown', 'bolt', 'heart', 'diamond', 'plane', 'sun', 'cloud'],
['cloud', 'music', 'key', 'spade', 'club', 'crown', 'star'],
],
passwordWords: [
'ADORE', 'ANGEL', 'BLISS', 'BLUSH', 'CANDY', 'CHARM', 'CRUSH', 'DANCE', 'DREAM', 'FLAME', 'FLIRT',
'HEART', 'HONEY', 'LOVER', 'MATCH', 'PEACH', 'RINGS', 'ROSES', 'SMILE', 'SPARK', 'SUGAR', 'SWEET',
'TEDDY', 'TRUST', 'TULIP', 'UNITY', 'WALTZ', 'BLOOM', 'CHOSE', 'PEARL', 'VOWED', 'QUEEN',
],
morseFrequencies: {
HUG: '3.505', KISS: '3.515', LOVE: '3.522', DATE: '3.532', RING: '3.535', ROSE: '3.542', WINE: '3.545',
DUET: '3.552', DANCE: '3.555', HEART: '3.565', SWEET: '3.572', HONEY: '3.575', CUDDLE: '3.582',
SPARK: '3.592', VOWS: '3.595', BLISS: '3.600',
} as Record<string, string>,
};
// ---------- Generator ----------
export function mulberry32(seed: number): () => number {
@@ -54,12 +209,38 @@ 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 }[];
passwords?: { columns: string[]; solved: boolean };
morse?: { word: string; solved: boolean };
/** `stage` flashes are showing; `progress` of them have been answered. */
simon?: { sequence: string[]; stage: number; progress: number; solved: boolean };
}
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
export function edgework(d: DefuseDevice): Edgework {
const shown = d.indicators ?? [];
return {
serial: d.serial,
batteries: d.batteries,
indicators: {
lit: shown.filter((i) => i.lit).map((i) => i.label),
unlit: shown.filter((i) => !i.lit).map((i) => i.label),
},
};
}
export function generateDevice(game: DefuseGame, levelIndex: number, seed: number): DefuseDevice {
@@ -83,11 +264,18 @@ export function generateDevice(game: DefuseGame, levelIndex: number, seed: numbe
serial += pick(digits); // last char is always a digit
const device: DefuseDevice = { serial, batteries: int(batteries.min, batteries.max) };
const ind = game.device.indicators;
if (ind) {
const shown = shuffle(ind.labels).slice(0, int(ind.count.min, ind.count.max));
device.indicators = shown.map((label) => ({ label, lit: rng() < 0.5 }));
}
const mods = game.modules;
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),
@@ -96,21 +284,59 @@ export function generateDevice(game: DefuseGame, levelIndex: number, seed: numbe
holding: false,
solved: false,
};
} else {
const column = pick(mods.keypad.columns);
device.keypad = { symbols: shuffle(shuffle(column).slice(0, 4)), pressed: [], solved: false };
} else if (id === 'keypad') {
// Re-roll until exactly one column holds all four symbols.
let symbols: string[] = [];
for (let attempt = 0; attempt < 200; attempt++) {
symbols = shuffle(pick(mods.keypad.columns)).slice(0, 4);
if (solveKeypad(symbols, mods.keypad.columns)) break;
}
device.keypad = { symbols, pressed: [], solved: false };
} else if (id === 'passwords' && mods.passwords) {
const { words, lettersPerColumn } = mods.passwords;
// Re-roll until exactly one listed word can be spelled.
let columns: string[] = [];
for (let attempt = 0; attempt < 200; attempt++) {
const target = pick(words);
columns = [...target].map((letter) => {
const others = shuffle([...ALPHABET].filter((l) => l !== letter)).slice(0, lettersPerColumn - 1);
return shuffle([letter, ...others]).join('');
});
if (solvePassword(columns, words) === target) break;
}
device.passwords = { columns, solved: false };
} else if (id === 'morse' && mods.morse) {
device.morse = { word: pick(Object.keys(mods.morse.words)), solved: false };
} else if (id === 'simon' && mods.simon) {
const { colors, stages } = mods.simon;
const sequence = Array.from({ length: int(stages.min, stages.max) }, () => pick(colors));
device.simon = { sequence, stage: 1, progress: 0, solved: false };
}
}
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 (device.wires) {
const { index } = solveWires(device.serial, device.wires.colors);
if (index < 1 || index > device.wires.colors.length) return false;
if (!game.levels.some((level) => level.modules.every((id) => device[id] !== undefined))) 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;
if (device.morse && !solveMorse(toSignal(device.morse.word), game.modules.morse?.words ?? {})) return false;
if (device.simon && device.simon.sequence.length < 1) return false;
return true;
}
@@ -125,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);
@@ -135,6 +363,17 @@ export function initDefuse(): DefuseState {
const state = (ctx: Ctx) => ctx.room.engine as DefuseState;
/** Strikes allowed on a level (some levels are stricter than the game default). */
export const maxStrikes = (game: DefuseGame, level: number): number =>
game.levels[level]?.strikesOverride ?? game.strikes.max;
const deviceModules = (d: DefuseDevice) =>
[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;
if (s.status !== 'active') return s.clock.remaining;
@@ -162,20 +401,22 @@ function strike(game: DefuseGame, ctx: Ctx, s: DefuseState) {
s.strikes++;
ctx.room.wrongGuesses++;
s.clock = { remaining, anchor: ctx.now, speed: 1 + game.strikes.speedUpPerStrike * s.strikes };
if (s.strikes >= game.strikes.max) explode(ctx, s, 'strikes');
else ctx.toast('all', `Strike ${s.strikes} of ${game.strikes.max}! The clock speeds up.`);
const max = maxStrikes(game, s.level);
// The Say It Back mapping moves with the strike count, so the stage starts over.
if (s.device?.simon) s.device.simon.progress = 0;
if (s.strikes >= max) explode(ctx, s, 'strikes');
else ctx.toast('all', `Strike ${s.strikes} of ${max}! The clock speeds up.`);
}
function checkCleared(game: DefuseGame, ctx: Ctx, s: DefuseState) {
const d = s.device!;
const modules = [d.wires, d.button, d.keypad].filter((m) => m !== undefined);
if (!modules.every((m) => m.solved)) return;
if (!deviceModules(s.device!).every((m) => m.solved)) return;
freezeClock(s, ctx.now);
s.status = 'cleared';
ctx.addKey(game.levels[s.level].keyName);
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. 💣🔑',
);
}
@@ -185,11 +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);
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') {
@@ -197,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;
}
@@ -209,20 +473,22 @@ 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');
return OK;
}
const d = s.device;
const edge = edgework(d);
if (moduleId === 'wires' && d.wires && !d.wires.solved && action.type === 'cut') {
if (action.index === solveWires(d.serial, 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;
const solution = solveButton({ ...b, batteries: d.batteries });
const solution = buttonSolution(game.ruleset, edge, b);
if (action.type === 'tap' && !b.holding) {
if (solution.type === 'tap') b.solved = true;
else strike(game, ctx, s);
@@ -242,16 +508,41 @@ export function defuseAction(
}
} else if (moduleId === 'keypad' && d.keypad && !d.keypad.solved && action.type === 'press') {
const k = d.keypad;
if (!k.symbols.includes(action.symbol) || k.pressed.includes(action.symbol)) {
const symbol = action.symbol ?? '';
if (!k.symbols.includes(symbol) || k.pressed.includes(symbol)) {
return fail('bad_action', 'That key is not available.');
}
const order = solveKeypad(k.symbols, game.modules.keypad.columns)!;
if (order[k.pressed.length] === action.symbol) {
k.pressed.push(action.symbol);
if (order[k.pressed.length] === symbol) {
k.pressed.push(symbol);
if (k.pressed.length === order.length) k.solved = true;
} else {
strike(game, ctx, s);
}
} else if (moduleId === 'passwords' && d.passwords && !d.passwords.solved && action.type === 'submit') {
const word = action.word.trim().toUpperCase();
if (word === solvePassword(d.passwords.columns, game.modules.passwords?.words ?? [])) d.passwords.solved = true;
else strike(game, ctx, s);
} else if (moduleId === 'morse' && d.morse && !d.morse.solved && action.type === 'transmit') {
const khz = Math.round(Number(action.frequency) * 1000);
if (!Number.isFinite(khz)) return fail('bad_action', "That isn't a frequency.");
const target = Number(game.modules.morse?.words[d.morse.word]);
if (khz === Math.round(target * 1000)) d.morse.solved = true;
else strike(game, ctx, s);
} else if (moduleId === 'simon' && d.simon && !d.simon.solved && action.type === 'press') {
const sim = d.simon;
const color = action.color ?? '';
if (!game.modules.simon?.colors.includes(color)) return fail('bad_action', 'That pad is not on the device.');
const [expected] = solveSimon(d.serial, s.strikes, [sim.sequence[sim.progress]]);
if (color !== expected) {
strike(game, ctx, s); // also restarts the stage
} else if (++sim.progress >= sim.stage) {
if (sim.stage >= sim.sequence.length) sim.solved = true;
else {
sim.stage++;
sim.progress = 0;
}
}
} else {
return fail('bad_action', "That module isn't on this device.");
}
+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;
}
+158 -8
View File
@@ -1,4 +1,4 @@
import type { Dir, Role } from '@two-keys/shared';
import type { Dir, MoveDir, RelDir, Role } from '@two-keys/shared';
import type { LabyrinthGame } from '../content/schemas';
import { OK, fail, type ActionResult, type Ctx } from './types';
@@ -7,16 +7,35 @@ export interface Point {
y: number;
}
/** You may step from (x, y) in `dir`, never back the other way. */
export interface Gate extends Point {
dir: Dir;
}
export const DELTAS: Record<Dir, Point> = {
n: { x: 0, y: -1 },
e: { x: 1, y: 0 },
s: { x: 0, y: 1 },
w: { x: -1, y: 0 },
};
export const OPPOSITE: Record<Dir, Dir> = { n: 's', e: 'w', s: 'n', w: 'e' };
export const REL_DIRS: readonly RelDir[] = ['f', 'r', 'b', 'l'];
const CLOCKWISE: readonly Dir[] = ['n', 'e', 's', 'w'];
/** Forward / right / back / left from the way you face → a compass direction. */
export function toAbsolute(facing: Dir, rel: RelDir): Dir {
return CLOCKWISE[(CLOCKWISE.indexOf(facing) + REL_DIRS.indexOf(rel)) % 4];
}
export const cellAt = (grid: string[], x: number, y: number): string => grid[y]?.[x] ?? '#';
export const isWall = (grid: string[], x: number, y: number): boolean => cellAt(grid, x, y) === '#';
/** True if a one-way gate stops a step from (x, y) in `dir`. */
export function gateBlocks(gates: readonly Gate[], x: number, y: number, dir: Dir): boolean {
const d = DELTAS[dir];
return gates.some((g) => g.x === x + d.x && g.y === y + d.y && g.dir === OPPOSITE[dir]);
}
export function findCells(grid: string[], char: string): Point[] {
const found: Point[] = [];
grid.forEach((row, y) => [...row].forEach((c, x) => c === char && found.push({ x, y })));
@@ -26,7 +45,7 @@ export function findCells(grid: string[], char: string): Point[] {
const key = (p: Point) => `${p.x},${p.y}`;
/** Every cell reachable from S without ever stepping on a trap. */
export function safeReachable(grid: string[]): Set<string> {
export function safeReachable(grid: string[], gates: readonly Gate[] = []): Set<string> {
const [start] = findCells(grid, 'S');
const seen = new Set<string>();
if (!start) return seen;
@@ -34,10 +53,10 @@ export function safeReachable(grid: string[]): Set<string> {
seen.add(key(start));
while (queue.length > 0) {
const p = queue.shift()!;
for (const d of Object.values(DELTAS)) {
const next = { x: p.x + d.x, y: p.y + d.y };
for (const dir of CLOCKWISE) {
const next = { x: p.x + DELTAS[dir].x, y: p.y + DELTAS[dir].y };
const c = cellAt(grid, next.x, next.y);
if (c === '#' || c === 'T' || seen.has(key(next))) continue;
if (c === '#' || c === 'T' || seen.has(key(next)) || gateBlocks(gates, p.x, p.y, dir)) continue;
seen.add(key(next));
queue.push(next);
}
@@ -47,38 +66,169 @@ export function safeReachable(grid: string[]): Set<string> {
export const isReachable = (reach: Set<string>, p: Point) => reach.has(key(p));
/**
* Plays the maze out as a graph of (cell, lanterns held). Returns whether a trap-free run can
* finish, and any cell where the Explorer could end up with no way left to finish (one-way
* gates make that possible). Falling into a trap counts as a way out: it leads back to S.
*/
export function analyseMaze(
grid: string[],
gates: readonly Gate[],
lanterns: readonly 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: [], 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 = 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 };
const first: Node = { p: start, mask: 0, safe: true };
const seen = new Map<string, Node>([[id(start, 0, true), first]]);
const reverse = new Map<string, string[]>();
const queue: Node[] = [first];
const goals: string[] = [];
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) === 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) || doorShut(step, node.mask)) continue;
const next: Node =
c === 'T'
? { p: start, mask: node.mask, safe: false }
: { p: step, mask: pickUp(step, node.mask), safe: node.safe };
const to = id(next.p, next.mask, next.safe);
reverse.set(to, [...(reverse.get(to) ?? []), from]);
if (!seen.has(to)) {
seen.set(to, next);
queue.push(next);
}
}
}
const canFinish = new Set<string>(goals);
const back = [...goals];
while (back.length > 0) {
for (const from of reverse.get(back.pop()!) ?? []) {
if (!canFinish.has(from)) {
canFinish.add(from);
back.push(from);
}
}
}
const stuck = new Map<string, Point>();
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 {
x: number;
y: number;
/** The way the Explorer faces: the direction of their last step. */
facing?: Dir;
collected: boolean[];
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 {
const [start] = findCells(game.grid, 'S');
return { ...start, collected: game.lanterns.map(() => false), moves: 0, falls: 0, message: '' };
return {
...start,
facing: game.explorer.startFacing,
collected: game.lanterns.map(() => false),
moves: 0,
falls: 0,
message: '',
inventory: [],
opened: [],
};
}
export function moveExplorer(game: LabyrinthGame, ctx: Ctx, role: Role, dir: Dir): ActionResult {
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 {
const s = ctx.room.engine as LabyrinthState;
if (role !== 'B') return fail('wrong_role', 'Only the Explorer can move.');
const relative = game.explorer.compass === 'relative';
const isRel = (REL_DIRS as readonly string[]).includes(move);
if (relative !== isRel) return fail('bad_dir', relative ? 'You have no compass in here.' : "You can't move like that here.");
const dir = isRel ? toAbsolute(facingOf(game, s), move as RelDir) : (move as Dir);
const d = DELTAS[dir];
const next = { x: s.x + d.x, y: s.y + d.y };
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++;
s.x = next.x;
s.y = next.y;
s.message = '';
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');
s.x = start.x;
s.y = start.y;
s.facing = game.explorer.startFacing;
s.falls++;
s.message = flavour.trap;
ctx.addPenalty(game.scoring.trapPenaltySeconds);
+47 -3
View File
@@ -1,4 +1,4 @@
import { matchesAnswer, type Role } from '@two-keys/shared';
import { formatClock, matchesAnswer, type Role } from '@two-keys/shared';
import type { Lock, StoryGame } from '../content/schemas';
import { OK, fail, type ActionResult, type Ctx } from './types';
@@ -19,6 +19,8 @@ export interface StoryState {
agreed: boolean;
attempts: number;
};
/** Starred clue ids per role. The partner only ever learns how many. */
pins?: Partial<Record<Role, string[]>>;
}
export function initStory(): StoryState {
@@ -30,10 +32,34 @@ export function initStory(): StoryState {
wrong: {},
lastSubmit: {},
finale: { proposal: null, agreed: false, attempts: 0 },
pins: {},
};
}
const state = (ctx: Ctx) => ctx.room.engine as StoryState;
/** What one hint costs in this game. */
export const hintPenalty = (game: StoryGame): number => game.rules.hintPenaltySeconds ?? HINT_PENALTY_SECONDS;
/** The hard limit on elapsed + penalty time, for the games that have one. */
export const timeLimitSeconds = (game: StoryGame): number | null =>
game.rules.timeLimitMinutes ? Math.round(game.rules.timeLimitMinutes * 60) : null;
/** Called on a timer; returns true if the room changed (the time limit ran out). */
export function tickStory(game: StoryGame, ctx: Ctx): boolean {
const limit = timeLimitSeconds(game);
const { room } = ctx;
if (limit === null || room.phase !== 'playing' || room.startedAt === null) return false;
if ((ctx.now - room.startedAt) / 1000 + room.penaltySeconds < limit) return false;
const { finale } = game;
ctx.finish(
false,
finale.failEnding ?? "**Time's up.** The clock beat you this time. Run it back?",
finale.type === 'accusation' ? finale.reveal : null,
true,
);
return true;
}
const atFinale = (game: StoryGame, s: StoryState) => s.chapter >= game.chapters.length;
/** The one lock that can currently be worked on, if any. */
@@ -82,8 +108,26 @@ export function requestHint(game: StoryGame, ctx: Ctx, lockId: string): ActionRe
if (tier >= open.lock.hints.length) return fail('no_more_hints', "That's every hint for this lock.");
s.hints[lockId] = tier + 1;
ctx.room.hintsUsed++;
ctx.addPenalty(HINT_PENALTY_SECONDS);
ctx.toast('all', `Hint ${tier + 1} revealed (+2:00)`);
ctx.addPenalty(hintPenalty(game));
ctx.toast('all', `Hint ${tier + 1} revealed (+${formatClock(hintPenalty(game))})`);
return OK;
}
/** Every clue id this role can see so far. */
export function visibleClueIds(game: StoryGame, s: StoryState, role: Role): string[] {
const chapters = game.chapters.slice(0, Math.min(s.chapter, game.chapters.length - 1) + 1);
const ids = chapters.flatMap((ch) => [...ch.clues[role], ...ch.clues.shared].map((c) => c.id));
if (atFinale(game, s) && game.finale.type === 'escape') ids.push(...game.finale.clues[role].map((c) => c.id));
return ids;
}
/** Stars or un-stars one of your own clues. */
export function togglePin(game: StoryGame, ctx: Ctx, role: Role, clueId: string): ActionResult {
const s = state(ctx);
if (!visibleClueIds(game, s, role).includes(clueId)) return fail('no_clue', "You don't have that clue.");
const pins = (s.pins ??= {});
const mine = pins[role] ?? [];
pins[role] = mine.includes(clueId) ? mine.filter((id) => id !== clueId) : [...mine, clueId];
return OK;
}
+81 -14
View File
@@ -1,7 +1,11 @@
import type { Role } from '@two-keys/shared';
import { normalizeAnswer, type Role, type SyncAnswer } from '@two-keys/shared';
import type { SyncGame, SyncQuestion } from '../content/schemas';
import { OK, fail, type ActionResult, type Ctx } from './types';
export const TELEPATHY_MAX_LENGTH = 30;
type Round = SyncGame['rounds'][number];
export interface SyncState {
round: number;
/** 0 on the first try, 1+ on a replay. */
@@ -9,10 +13,10 @@ export interface SyncState {
status: 'intro' | 'question' | 'reveal' | 'summary';
questions: SyncQuestion[];
q: number;
answers: Record<string, Partial<Record<Role, number>>>;
answers: Record<string, Partial<Record<Role, SyncAnswer>>>;
marks: boolean[];
earned: boolean;
/** Question ids already drawn this session, so nothing repeats. */
/** Questions already drawn this session, as `group|id`, so nothing repeats. */
used: string[];
}
@@ -34,14 +38,38 @@ export function initSync(game: SyncGame): SyncState {
const state = (ctx: Ctx) => ctx.room.engine as SyncState;
export const pointsForKey = (game: SyncGame, round: Round): number => round.pointsForKey ?? game.rules.pointsForKey;
/** Who question number `q` (0-based) of a round is about, if anyone. */
export function subjectOf(round: Round, q: number): Role | null {
if (round.type === 'predict') return round.subject ?? 'A';
if (round.type === 'rank') {
const pattern = round.subjectPattern ?? ['A', 'B'];
return pattern[q % pattern.length];
}
return null;
}
/** Rounds that ask the same question in the same way share a group. */
export const groupOf = (round: Round): string => `${round.type}:${round.type === 'predict' ? (round.subject ?? 'A') : ''}`;
/** Draws a fresh hand of questions for the current round. */
function deal(game: SyncGame, s: SyncState, random: () => number = Math.random) {
const bank = game[game.rounds[s.round].type].filter((q) => !s.used.includes(q.id));
const round = game.rounds[s.round];
const group = groupOf(round);
const all: SyncQuestion[] = game[round.type];
const usedIds = new Set(s.used.map((u) => u.slice(u.lastIndexOf('|') + 1)));
// Never-seen questions first. A spotlight question may come back only to be asked about the other partner.
const fresh = all.filter((q) => !usedIds.has(q.id));
const recycled = all.filter((q) => usedIds.has(q.id) && !s.used.includes(`${group}|${q.id}`));
const hand: SyncQuestion[] = [];
while (hand.length < game.rules.questionsPerRound && bank.length > 0) {
hand.push(...bank.splice(Math.floor(random() * bank.length), 1));
for (const bank of [fresh, recycled]) {
while (hand.length < game.rules.questionsPerRound && bank.length > 0) {
hand.push(...bank.splice(Math.floor(random() * bank.length), 1));
}
}
s.used.push(...hand.map((q) => q.id));
s.used.push(...hand.map((q) => `${group}|${q.id}`));
s.questions = hand;
s.q = 0;
s.answers = {};
@@ -53,27 +81,65 @@ export function canReplay(game: SyncGame, s: SyncState): boolean {
return !s.earned && s.attempt < game.rules.replaysPerRound;
}
/** Telepathy: case, spaces, punctuation and a trailing S are ignored. */
export function normalizeWord(input: string): string {
const word = normalizeAnswer(input);
return word.length > 1 ? word.replace(/S$/, '') : word;
}
/** Checks an answer against the question's shape and returns it in stored form, or null if it doesn't fit. */
function cleanAnswer(type: Round['type'], question: SyncQuestion, answer: unknown): SyncAnswer | null {
if (type === 'telepathy') {
if (typeof answer !== 'string') return null;
const text = answer.trim();
return text.length > TELEPATHY_MAX_LENGTH || normalizeAnswer(text) === '' ? null : text;
}
if (type === 'rank') {
const count = question.items?.length ?? 0;
if (!Array.isArray(answer) || answer.length !== count) return null;
const valid = answer.every((i) => Number.isInteger(i) && i >= 0 && i < count);
return valid && new Set(answer).size === count ? [...answer] : null;
}
const count = question.options?.length ?? 0;
return typeof answer === 'number' && Number.isInteger(answer) && answer >= 0 && answer < count ? answer : null;
}
export function isMatch(type: Round['type'], a: SyncAnswer, b: SyncAnswer): boolean {
if (type === 'telepathy') return normalizeWord(String(a)) === normalizeWord(String(b));
if (type === 'rank') {
// Only the top and the bottom of the ranking have to agree.
const [x, y] = [a as number[], b as number[]];
return x[0] === y[0] && x[x.length - 1] === y[y.length - 1];
}
return a === b;
}
export function answerSync(
game: SyncGame,
ctx: Ctx,
role: Role,
questionId: string,
option: number,
answer: SyncAnswer | undefined,
): ActionResult {
const s = state(ctx);
const question = s.questions[s.q];
if (s.status !== 'question' || !question || question.id !== questionId) {
return fail('not_open', "That question isn't open.");
}
if (!Number.isInteger(option) || option < 0 || option >= question.options.length) {
return fail('bad_option', 'Pick one of the options.');
const { type } = game.rounds[s.round];
const clean = cleanAnswer(type, question, answer);
if (clean === null) {
return fail(
'bad_option',
type === 'telepathy' ? 'Type one word.' : type === 'rank' ? 'Put every item in order.' : 'Pick one of the options.',
);
}
const answers = (s.answers[questionId] ??= {});
if (answers[role] !== undefined) return fail('answered', "You've already locked in an answer.");
answers[role] = option;
answers[role] = clean;
if (answers.A !== undefined && answers.B !== undefined) {
s.marks.push(answers.A === answers.B);
s.marks.push(isMatch(type, answers.A, answers.B));
s.status = 'reveal';
}
return OK;
@@ -92,7 +158,7 @@ export function continueSync(game: SyncGame, ctx: Ctx): ActionResult {
s.q++;
s.status = 'question';
} else {
s.earned = s.marks.filter(Boolean).length >= game.rules.pointsForKey;
s.earned = s.marks.filter(Boolean).length >= pointsForKey(game, round);
if (s.earned) ctx.addKey(round.keyName);
s.status = 'summary';
}
@@ -110,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 -3
View File
@@ -14,7 +14,7 @@ export interface Ctx {
toast(to: Role | 'all', message: string): void;
addKey(name: string): void;
addPenalty(seconds: number): void;
finish(success: boolean, ending?: string | null, reveal?: string | null): void;
finish(success: boolean, ending?: string | null, reveal?: string | null, outOfTime?: boolean): void;
}
export function createCtx(room: Room, now = Date.now()): Ctx {
@@ -26,10 +26,10 @@ export function createCtx(room: Room, now = Date.now()): Ctx {
toast: (to, message) => void toasts.push({ to, message }),
addKey: (name) => void room.keys.push(name),
addPenalty: (seconds) => void (room.penaltySeconds += seconds),
finish(success, ending = null, reveal = null) {
finish(success, ending = null, reveal = null, outOfTime = false) {
room.phase = 'finished';
room.finishedAt = now;
room.result = { success, ending, reveal };
room.result = { success, ending, reveal, outOfTime };
},
};
}
+8 -1
View File
@@ -1,7 +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 { timeLimitLabel, timeLimitSeconds, totalKeys } from '../engines/index';
import type { PlayerRec, Room } from './store';
export function computeResult(room: Room, game: Game): GameResult | null {
@@ -11,6 +11,7 @@ export function computeResult(room: Room, game: Game): GameResult | null {
const elapsedSeconds = Math.round((room.finishedAt - room.startedAt) / 1000);
const finalSeconds = elapsedSeconds + room.penaltySeconds;
const keys = totalKeys(game);
const outOfTime = room.result.outOfTime ?? false;
return {
success: room.result.success,
elapsedSeconds,
@@ -27,7 +28,11 @@ export function computeResult(room: Room, game: Game): GameResult | null {
estimatedMinutes: game.estimatedMinutes,
keysEarned: room.keys.length,
totalKeys: keys,
outOfTime,
}),
outOfTime,
// Difficulty 5, finished without a single hint.
nightmareSurvivors: room.result.success && game.difficulty >= 5 && room.hintsUsed === 0,
ending: room.result.ending,
reveal: room.result.reveal,
};
@@ -69,6 +74,8 @@ export function buildState(
finishedAt: room.finishedAt,
penaltySeconds: room.penaltySeconds,
serverNow: now,
limitSeconds: timeLimitSeconds(game),
limitLabel: timeLimitLabel(game),
},
stats: { hintsUsed: room.hintsUsed, wrongGuesses: room.wrongGuesses },
views,
+1 -1
View File
@@ -28,7 +28,7 @@ export interface Room {
wrongGuesses: number;
/** Engine-specific state, owned by the game's engine. */
engine: unknown;
result: { success: boolean; ending: string | null; reveal: string | null } | null;
result: { success: boolean; ending: string | null; reveal: string | null; outOfTime?: boolean } | null;
}
export const ROOM_TTL_MS = 24 * 60 * 60 * 1000;
+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}`);
+105 -15
View File
@@ -1,13 +1,32 @@
import { randomUUID } from 'node:crypto';
import type { Server, Socket } from 'socket.io';
import { z } from 'zod';
import { PING_EMOJIS, otherRole, type JoinAck, type Phase, type Role } from '@two-keys/shared';
import {
DEFUSE_MODULES,
PING_EMOJIS,
otherRole,
type JoinAck,
type MoveDir,
type Phase,
type RelDir,
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, proposeAccusation, requestHint, submitAccusation, submitLock } from '../engines/story';
import { answerSync } from '../engines/sync';
import {
agreeAccusation,
proposeAccusation,
requestHint,
submitAccusation,
submitLock,
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';
@@ -24,20 +43,46 @@ const schemas = {
pickRole: z.object({ role }),
lockSubmit: z.object({ lockId: z.string(), value: z.string().max(200), ...asRole }),
hint: z.object({ lockId: z.string(), ...asRole }),
pin: z.object({ clueId: z.string().max(100), ...asRole }),
propose: z.object({ answers: z.record(z.string()), ...asRole }),
defuse: z.object({
moduleId: z.enum(['wires', 'button', 'keypad']),
// `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') }),
z.object({ type: z.literal('hold') }),
z.object({ type: z.literal('release') }),
z.object({ type: z.literal('press'), symbol: z.string() }),
z.object({ type: z.literal('press'), symbol: z.string().optional(), color: z.string().optional() }),
z.object({ type: z.literal('submit'), word: z.string().max(20) }),
z.object({ type: z.literal('transmit'), frequency: z.string().max(10) }),
]),
...asRole,
}),
sync: z.object({ questionId: z.string(), optionId: z.number().int(), ...asRole }),
move: z.object({ dir: z.enum(['n', 'e', 's', 'w']), ...asRole }),
// One of: an option index, a typed word (telepathy), or item indexes in order (rank).
sync: z.object({
questionId: z.string(),
optionId: z.number().int().optional(),
text: z.string().max(TELEPATHY_MAX_LENGTH * 2).optional(),
order: z.array(z.number().int()).max(12).optional(),
...asRole,
}),
move: z.object({
dir: z
.enum(['n', 'e', 's', 'w', 'f', 'l', 'r', 'b', 'forward', 'left', 'right', 'back'])
.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) }),
@@ -101,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 ?? {});
@@ -120,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);
});
}
@@ -204,6 +250,10 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
game.engine === 'story' ? requestHint(game, ctx, data.lockId) : fail('wrong_engine', 'No hints here.'),
);
on('clue:pin', schemas.pin, 'playing', ({ game, data, role, ctx }) =>
game.engine === 'story' ? togglePin(game, ctx, role, data.clueId) : fail('wrong_engine', 'No clues here.'),
);
on('chapter:continue', empty, 'playing', ({ game, ctx }) => continueGame(game, ctx));
on('finale:propose', schemas.propose, 'playing', ({ game, data, role, ctx }) =>
@@ -226,7 +276,7 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
on('sync:answer', schemas.sync, 'playing', ({ game, data, role, ctx }) =>
game.engine === 'sync'
? answerSync(game, ctx, role, data.questionId, data.optionId)
? answerSync(game, ctx, role, data.questionId, data.order ?? data.text ?? data.optionId)
: fail('wrong_engine', 'No questions here.'),
);
@@ -234,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(
@@ -265,13 +350,18 @@ export function registerHandlers(io: Server, games: Map<string, Game>, store: Ro
});
});
// The defuse clock 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?.engine !== 'defuse') continue;
if (room.phase !== 'playing' || !game || !hasClock(game)) continue;
const ctx = createCtx(room);
if (tickDefuse(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);
}
+13 -5
View File
@@ -7,13 +7,21 @@ import { ROOM_TTL_MS, RoomStore } from '../src/rooms/store';
import { games, getGame, labyrinth, sync } from './helpers';
describe('content', () => {
it('loads all 8 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(8);
expect([...games.values()].map((g) => g.engine).sort()).toEqual(
['defuse', 'labyrinth', 'story', 'story', 'story', 'story', 'story', 'sync'],
);
expect(files).toHaveLength(40);
const engines = [...games.values()].map((g) => g.engine);
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,
});
});
it('rejects a story chapter with no clue for one player', () => {
+471
View File
@@ -0,0 +1,471 @@
// The second wave of games: Overtime (defuse), Deep Cuts (sync), the Drowned Catacombs and
// the Mirror Maze (labyrinth), and the story games with their own penalties and time limits.
import { describe, expect, it } from 'vitest';
import type { Dir, MoveDir, Role } from '@two-keys/shared';
import {
buttonSolution,
continueDefuse,
defuseAction,
edgework,
generateDevice,
isSolvable,
remainingAt,
solveKeypad,
solveMorse,
solvePassword,
solveSimon,
timerText,
toSignal,
wireSolution,
type DefuseState,
} from '../src/engines/defuse';
import { validateGame } from '../src/content/validate';
import { DELTAS, cellAt, findCells, gateBlocks, moveExplorer, toAbsolute, type LabyrinthState } from '../src/engines/labyrinth';
import { continueChapter, requestHint, submitLock, tickStory, type StoryState } from '../src/engines/story';
import { answerSync, continueSync, normalizeWord, type SyncState } from '../src/engines/sync';
import { buildState } from '../src/rooms/state';
import type { Room } from '../src/rooms/store';
import { clock, games, getGame, playingRoom } from './helpers';
const overtime = () => getGame('tick-tock-overtime', 'defuse');
const deepCuts = () => getGame('in-sync-deep-cuts', 'sync');
const catacombs = () => getGame('drowned-catacombs', 'labyrinth');
const mirrorMaze = () => getGame('mirror-maze', 'labyrinth');
const viewOf = (room: Room, role: Role, now = 2_000_000) => {
const player = room.players.find((p) => p.seat === role)!;
return JSON.parse(JSON.stringify(buildState(room, player, games.get(room.gameId)!, () => true, now)));
};
describe('defuse: Overtime manual', () => {
const game = overtime();
const { fixtures, modules, ruleset } = game;
it.each(fixtures.wires)('wires fixture %#', (f) => {
expect(wireSolution(ruleset, f, f.wires)).toEqual(f.expect);
});
it.each(fixtures.button)('button fixture %#', (f) => {
expect(buttonSolution(ruleset, { serial: '', ...f }, f)).toEqual(f.expect);
});
it.each(fixtures.keypad)('keypad fixture %#', (f) => {
expect(solveKeypad(f.symbols, modules.keypad.columns)).toEqual(f.expect);
});
it.each(fixtures.passwords)('passwords fixture %#', (f) => {
expect(solvePassword(f.columns, modules.passwords!.words)).toBe(f.expect);
});
it.each(fixtures.morse)('morse fixture %#', (f) => {
expect(solveMorse(f.signal, modules.morse!.words)).toEqual(f.expect);
});
it.each(fixtures.simon)('simon fixture %#', (f) => {
expect(solveSimon(f.serial, f.strikes, f.flashes)).toEqual(f.expect);
});
it('never generates a device the solver cannot solve, on any level', () => {
for (let level = 0; level < game.levels.length; level++) {
for (let seed = 1; seed <= 400; seed++) {
const device = generateDevice(game, level, seed);
expect(isSolvable(game, device), `level ${level} seed ${seed}`).toBe(true);
expect(device.indicators!.length).toBeGreaterThanOrEqual(1);
expect(device.indicators!.length).toBeLessThanOrEqual(3);
if (device.passwords) {
expect(device.passwords.columns).toHaveLength(5);
for (const column of device.passwords.columns) expect(new Set(column).size).toBe(6);
}
if (device.simon) {
expect(device.simon.sequence.length).toBeGreaterThanOrEqual(3);
expect(device.simon.sequence.length).toBeLessThanOrEqual(5);
}
}
}
});
/** Solves whatever is on the device, using only the solver. */
function solveAll(room: Room, t: ReturnType<typeof clock>) {
const s = room.engine as DefuseState;
const d = s.device!;
const act = (id: string, action: Parameters<typeof defuseAction>[4], ms = 100) =>
expect(defuseAction(game, t.ctx(ms), 'A', id, action).ok, id).toBe(true);
if (d.wires) act('wires', wireSolution(ruleset, edgework(d), d.wires.colors));
if (d.keypad) for (const symbol of solveKeypad(d.keypad.symbols, modules.keypad.columns)!) act('keypad', { type: 'press', symbol });
if (d.passwords) act('passwords', { type: 'submit', word: solvePassword(d.passwords.columns, modules.passwords!.words)! });
if (d.morse) act('morse', { type: 'transmit', frequency: solveMorse(toSignal(d.morse.word), modules.morse!.words)!.frequency });
if (d.simon) {
for (let stage = 1; stage <= d.simon.sequence.length; stage++) {
for (const color of solveSimon(d.serial, s.strikes, d.simon.sequence.slice(0, stage))) act('simon', { type: 'press', color });
}
}
if (d.button) {
const solution = buttonSolution(ruleset, edgework(d), d.button);
if (solution.type === 'tap') act('button', { type: 'tap' });
else {
act('button', { type: 'hold' });
for (let i = 0; i < 400; i++) {
if (timerText(remainingAt(s, t.now)).includes(String(solution.releaseDigit))) break;
t.ctx(500);
}
act('button', { type: 'release' }, 0);
}
}
}
it('clears all four levels for a key each and finishes the game', () => {
const room = playingRoom(game);
const t = clock(room);
for (let level = 0; level < game.levels.length; level++) {
expect(continueDefuse(game, t.ctx()).ok).toBe(true);
solveAll(room, t);
const s = room.engine as DefuseState;
expect(s.strikes, `level ${level}`).toBe(0);
expect(s.status, `level ${level}`).toBe('cleared');
if (level < game.levels.length - 1) continueDefuse(game, t.ctx());
}
expect(room.keys).toEqual(game.levels.map((l) => l.keyName));
expect(room.result?.success).toBe(true);
});
it('Say It Back: a wrong pad strikes, restarts the stage, and moves the mapping', () => {
const room = playingRoom(game);
const t = clock(room);
const s = room.engine as DefuseState;
s.level = 2;
continueDefuse(game, t.ctx());
const sim = s.device!.simon!;
const expected = (strikes: number) => solveSimon(s.device!.serial, strikes, [sim.sequence[0]])[0];
defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: expected(0) });
expect(sim).toMatchObject({ stage: 2, progress: 0 });
defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: expected(0) });
expect(sim.progress).toBe(1);
const wrong = modules.simon!.colors.find((c) => c !== solveSimon(s.device!.serial, 0, [sim.sequence[1]])[0])!;
defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: wrong });
expect(s.strikes).toBe(1);
expect(sim).toMatchObject({ stage: 2, progress: 0 });
// One strike in, the first flash now maps to a different pad.
expect(expected(1)).not.toBe(expected(0));
defuseAction(game, t.ctx(), 'A', 'simon', { type: 'press', color: expected(1) });
expect(s.strikes).toBe(1);
expect(sim.progress).toBe(1);
});
it('a wrong password or frequency is a strike, and the last level only allows two', () => {
const room = playingRoom(game);
const t = clock(room);
const s = room.engine as DefuseState;
s.level = 3;
continueDefuse(game, t.ctx());
expect(viewOf(room, 'A', t.now).views.A.maxStrikes).toBe(2);
defuseAction(game, t.ctx(), 'A', 'passwords', { type: 'submit', word: 'ZZZZZ' });
expect(s).toMatchObject({ strikes: 1, status: 'active' });
expect(defuseAction(game, t.ctx(), 'A', 'morse', { type: 'transmit', frequency: 'loud' }).ok).toBe(false);
expect(s.strikes).toBe(1);
defuseAction(game, t.ctx(), 'A', 'morse', { type: 'transmit', frequency: '3.501' });
expect(s).toMatchObject({ strikes: 2, status: 'exploded', failReason: 'strikes' });
});
it('the Technician gets the blinking pattern and the letters, never the word or the manual', () => {
const room = playingRoom(game);
const t = clock(room);
const s = room.engine as DefuseState;
s.level = 3;
continueDefuse(game, t.ctx());
const d = s.device!;
const tech = viewOf(room, 'A', t.now).views.A;
const json = JSON.stringify(tech);
expect(json).not.toContain('Defusal Manual');
expect(tech.device.indicators).toEqual(d.indicators);
const byId = Object.fromEntries(tech.device.modules.map((m: { id: string }) => [m.id, m]));
expect(Object.keys(byId).sort()).toEqual([...game.levels[3].modules].sort());
expect(byId.morse.signal).toBe(toSignal(d.morse!.word));
expect(byId.morse).not.toHaveProperty('word');
expect(byId.passwords.columns).toEqual(d.passwords!.columns);
// Only the flashes of the current stage, not the whole sequence.
expect(byId.simon.flashes).toEqual(d.simon!.sequence.slice(0, 1));
expect(json).not.toContain('"sequence"');
const expert = JSON.stringify(viewOf(room, 'B', t.now).views.B);
expect(expert).toContain('Overtime Edition');
expect(expert).not.toContain(d.serial);
for (const key of ['device', 'indicators', 'signal', 'columns', 'flashes']) expect(expert).not.toContain(`"${key}"`);
});
});
describe('sync: Deep Cuts', () => {
const game = deepCuts();
it('telepathy ignores case, spacing, punctuation and a trailing S', () => {
expect(normalizeWord(' Ice-Cream ')).toBe(normalizeWord('ice cream'));
expect(normalizeWord('Apples')).toBe(normalizeWord('apple'));
expect(normalizeWord('kiss')).toBe(normalizeWord('KISS'));
expect(normalizeWord('pear')).not.toBe(normalizeWord('peach'));
});
/** Plays the open round; `matches` of the questions are answered in sync. */
function playRound(room: Room, t: ReturnType<typeof clock>, matches: number, seen: string[]) {
const s = room.engine as SyncState;
const { type } = game.rounds[s.round];
expect(s.questions, `round ${s.round} attempt ${s.attempt}`).toHaveLength(game.rules.questionsPerRound);
for (let i = 0; i < game.rules.questionsPerRound; i++) {
const q = s.questions[s.q];
seen.push(`${s.round}:${q.id}`);
const hit = i < matches;
if (type === 'telepathy') {
expect(answerSync(game, t.ctx(), 'A', q.id, 2).ok).toBe(false); // no options to pick from
expect(answerSync(game, t.ctx(), 'A', q.id, ' ').ok).toBe(false);
answerSync(game, t.ctx(), 'A', q.id, 'Mangos');
answerSync(game, t.ctx(), 'B', q.id, hit ? ' mango' : 'pear');
} else if (type === 'rank') {
expect(answerSync(game, t.ctx(), 'A', q.id, [0, 1, 2]).ok).toBe(false); // every item, once
expect(answerSync(game, t.ctx(), 'A', q.id, [0, 1, 1, 3]).ok).toBe(false);
answerSync(game, t.ctx(), 'A', q.id, [0, 1, 2, 3]);
// Top and bottom agree (the middle doesn't have to), or only the top does.
answerSync(game, t.ctx(), 'B', q.id, hit ? [0, 2, 1, 3] : [0, 1, 3, 2]);
} else {
answerSync(game, t.ctx(), 'A', q.id, 0);
answerSync(game, t.ctx(), 'B', q.id, hit ? 0 : 1);
}
expect(s.status).toBe('reveal');
expect(s.marks[i]).toBe(hit);
continueSync(game, t.ctx());
}
expect(s.status).toBe('summary');
}
it('scores each round type, with its own bar for the key', () => {
const room = playingRoom(game);
const t = clock(room);
const s = room.engine as SyncState;
const seen: string[] = [];
continueSync(game, t.ctx());
playRound(room, t, 3, seen); // telepathy needs 3
expect(room.keys).toEqual(['Telepathy Key']);
continueSync(game, t.ctx());
continueSync(game, t.ctx());
expect(viewOf(room, 'A').views.A).toMatchObject({ pointsForKey: 4, round: { type: 'predict', subject: 'A' }, iAmSubject: true });
playRound(room, t, 3, seen); // spotlight needs 4: not enough
expect(s.earned).toBe(false);
continueSync(game, t.ctx()); // replay
playRound(room, t, 4, seen);
expect(room.keys).toEqual(['Telepathy Key', 'Deep Key A']);
continueSync(game, t.ctx());
continueSync(game, t.ctx());
playRound(room, t, 0, seen);
continueSync(game, t.ctx());
playRound(room, t, 0, seen); // both tries missed
continueSync(game, t.ctx());
expect(s.round).toBe(3);
continueSync(game, t.ctx());
// The spotlight alternates A, B, A, B, A through the ranking round.
expect(viewOf(room, 'B').views.B).toMatchObject({ round: { type: 'rank', subject: 'A' }, iAmSubject: false });
expect(viewOf(room, 'B').views.B.question.options).toHaveLength(4);
playRound(room, t, 3, seen);
expect(room.keys).toEqual(['Telepathy Key', 'Deep Key A', 'Ranking Key']);
continueSync(game, t.ctx());
expect(room.phase).toBe('finished');
// Nothing was asked twice in the same way (a spotlight question may return for the other partner).
const groups = seen.map((x) => x.replace(/^[12]:/, (m) => (m === '1:' ? 'A:' : 'B:')));
expect(new Set(groups).size).toBe(groups.length);
});
it("keeps a typed word private until both are in", () => {
const room = playingRoom(game);
const t = clock(room);
continueSync(game, t.ctx());
const q = (room.engine as SyncState).questions[0];
answerSync(game, t.ctx(), 'A', q.id, 'Banana');
expect(JSON.stringify(viewOf(room, 'B').views.B)).not.toContain('Banana');
expect(viewOf(room, 'B').views.B).toMatchObject({ myAnswer: null, partnerAnswered: true, reveal: null });
answerSync(game, t.ctx(), 'B', q.id, 'bananas');
expect(viewOf(room, 'B').views.B.reveal).toEqual({ A: 'Banana', B: 'bananas', match: true });
});
});
describe('labyrinth: one-way gates (Drowned Catacombs)', () => {
const game = catacombs();
it('lets the Explorer through a gate one way only', () => {
const room = playingRoom(game);
const t = clock(room);
const s = room.engine as LabyrinthState;
const [gate] = game.oneWay; // (5,9) → east
Object.assign(s, { x: gate.x, y: gate.y });
expect(viewOf(room, 'B').views.B).toMatchObject({ compass: 'absolute', gates: [] });
expect(moveExplorer(game, t.ctx(), 'B', gate.dir).ok).toBe(true);
expect(s.message).toBe(game.explorerView.flavour.gate);
const explorer = viewOf(room, 'B').views.B;
expect(explorer.open.w).toBe(false);
expect(explorer.gates).toEqual(['w']);
expect(moveExplorer(game, t.ctx(), 'B', 'w')).toMatchObject({ ok: false, code: 'gate' });
expect(s).toMatchObject({ x: gate.x + 1, y: gate.y });
expect(viewOf(room, 'A').views.A.oneWay).toEqual(game.oneWay);
});
it('rejects a maze where a gate can trap the Explorer for good', () => {
const sealed = structuredClone(game);
// A dead-end pocket behind a gate, with no trap to fall out through.
sealed.grid = ['#####', '#S.E#', '#.###', '#.KK#', '#.#K#', '#####'];
sealed.lanterns = [
{ x: 2, y: 3, name: 'a' },
{ x: 3, y: 3, name: 'b' },
{ x: 3, y: 4, name: 'c' },
];
sealed.glyphs = [];
sealed.oneWay = [{ x: 1, y: 3, dir: 'e' }];
expect(validateGame(sealed).join('\n')).toMatch(/stuck for good/);
sealed.oneWay = [];
expect(validateGame(sealed)).toEqual([]);
});
});
describe('labyrinth: no compass, mirrored map (Mirror Maze)', () => {
const game = mirrorMaze();
it('turns forward/left/right/back into steps from the way the Explorer faces', () => {
expect((['f', 'r', 'b', 'l'] as const).map((rel) => toAbsolute('e', rel))).toEqual(['e', 's', 'w', 'n']);
const room = playingRoom(game);
const t = clock(room);
const s = room.engine as LabyrinthState;
expect(s).toMatchObject({ x: 1, y: 1, facing: 'e' });
expect(moveExplorer(game, t.ctx(), 'B', 'e')).toMatchObject({ ok: false, code: 'bad_dir' }); // no compass
expect(moveExplorer(game, t.ctx(), 'B', 'l')).toMatchObject({ ok: false, code: 'wall' });
moveExplorer(game, t.ctx(), 'B', 'f');
moveExplorer(game, t.ctx(), 'B', 'f'); // (3,1), still facing east
expect(s).toMatchObject({ x: 3, y: 1, facing: 'e' });
moveExplorer(game, t.ctx(), 'B', 'r'); // right of east is south
expect(s).toMatchObject({ x: 3, y: 2, facing: 's' });
const explorer = viewOf(room, 'B').views.B;
expect(explorer.compass).toBe('relative');
expect(explorer.open).toEqual({ f: true, r: false, b: true, l: false });
expect(JSON.stringify(explorer)).not.toMatch(/"(facing|n|e|s|w|x|y|grid)"/);
});
it('serves the Navigator the map flipped left to right', () => {
const room = playingRoom(game);
const navigator = viewOf(room, 'A').views.A;
const width = game.grid[0].length;
expect(navigator.banner).toMatch(/traced from a mirror/);
expect(navigator.grid).toEqual(game.grid.map((row) => [...row].reverse().join('')));
expect(navigator.grid).not.toEqual(game.grid);
game.lanterns.forEach((l, i) => {
expect(navigator.lanterns[i]).toMatchObject({ x: width - 1 - l.x, y: l.y });
expect(navigator.grid[l.y][navigator.lanterns[i].x]).toBe('K');
});
game.glyphs.forEach((g, i) => expect(navigator.glyphs[i].x).toBe(width - 1 - g.x));
});
});
describe.each([catacombs(), mirrorMaze()].map((g) => [g.id, g] as const))('labyrinth: %s', (_id, game) => {
it('can be completed without a single trap', () => {
const room = playingRoom(game);
const t = clock(room);
const s = room.engine as LabyrinthState;
const state = (p: { x: number; y: number }, held: boolean[]) => `${p.x},${p.y},${held.map(Number).join('')}`;
// Breadth-first over (cell, lanterns held): gates make the order matter.
type Node = { x: number; y: number; held: boolean[]; path: Dir[] };
const queue: Node[] = [{ x: s.x, y: s.y, held: game.lanterns.map(() => false), path: [] }];
const seen = new Set([state(queue[0], queue[0].held)]);
let route: Dir[] | null = null;
while (queue.length && !route) {
const node = queue.shift()!;
for (const dir of Object.keys(DELTAS) as Dir[]) {
const next = { x: node.x + DELTAS[dir].x, y: node.y + DELTAS[dir].y };
const c = cellAt(game.grid, next.x, next.y);
if (c === '#' || c === 'T' || gateBlocks(game.oneWay, node.x, node.y, dir)) continue;
const held = node.held.map((h, i) => h || (game.lanterns[i].x === next.x && game.lanterns[i].y === next.y));
if (seen.has(state(next, held))) continue;
seen.add(state(next, held));
const path = [...node.path, dir];
if (c === 'E' && held.every(Boolean)) route = path;
queue.push({ ...next, held, path });
}
}
expect(route).not.toBeNull();
const relative = game.explorer.compass === 'relative';
for (const dir of route!) {
const move: MoveDir = relative ? (['f', 'r', 'b', 'l'] as const).find((rel) => toAbsolute(s.facing!, rel) === dir)! : dir;
expect(moveExplorer(game, t.ctx(), 'B', move).ok).toBe(true);
}
expect(room.phase).toBe('finished');
expect(s.falls).toBe(0);
expect(room.keys).toHaveLength(game.lanternsRequired);
expect(findCells(game.grid, 'K')).toHaveLength(game.lanternsRequired);
});
});
describe('story: per-game hint penalties and time limits', () => {
it('charges the hint penalty the game sets', () => {
const game = getGame('kestrel-heist', 'story');
const room = playingRoom(game);
const t = clock(room);
const ctx = t.ctx();
requestHint(game, ctx, game.chapters[0].lock.id);
expect(room.penaltySeconds).toBe(240);
expect(ctx.toasts[0].message).toBe('Hint 1 revealed (+4:00)');
expect(viewOf(room, 'A').views.A.chapters[0].lock.hintPenaltySeconds).toBe(240);
const classic = getGame('vineyard-hollow', 'story');
const other = playingRoom(classic);
requestHint(classic, clock(other).ctx(), classic.chapters[0].lock.id);
expect(other.penaltySeconds).toBe(120);
});
it('ends the game when the clock (elapsed + penalties) passes the limit', () => {
const game = getGame('deep-lab-nereid', 'story');
const limit = game.rules.timeLimitMinutes! * 60;
const room = playingRoom(game);
const t = clock(room);
expect(viewOf(room, 'A').timer).toMatchObject({ limitSeconds: limit, limitLabel: 'Water level' });
expect(tickStory(game, t.ctx((limit - 100) * 1000))).toBe(false);
requestHint(game, t.ctx(), game.chapters[0].lock.id); // +3:00 tips it over
expect(tickStory(game, t.ctx())).toBe(true);
expect(room.phase).toBe('finished');
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: null, outOfTime: true });
expect(tickStory(game, t.ctx())).toBe(false);
});
it('shows the reveal when an accusation game times out, and leaves untimed games alone', () => {
const game = getGame('kalahari-expedition', 'story');
if (game.finale.type !== 'accusation') throw new Error('expected accusation');
const room = playingRoom(game);
const t = clock(room);
expect(tickStory(game, t.ctx(game.rules.timeLimitMinutes! * 60_000))).toBe(true);
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: game.finale.reveal, outOfTime: true });
const untimed = getGame('vineyard-hollow', 'story');
const calm = playingRoom(untimed);
expect(tickStory(untimed, clock(calm).ctx(24 * 3600_000))).toBe(false);
expect(viewOf(calm, 'A').timer).toMatchObject({ limitSeconds: null, limitLabel: null });
});
it('plays a timed escape room through to the end inside the limit', () => {
const game = getGame('deep-lab-nereid', 'story');
if (game.finale.type !== 'escape') throw new Error('expected escape');
const room = playingRoom(game);
const t = clock(room);
for (const ch of game.chapters) {
expect(submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0])).toMatchObject({ correct: true });
continueChapter(game, t.ctx());
}
expect((room.engine as StoryState).chapter).toBe(game.chapters.length);
expect(tickStory(game, t.ctx())).toBe(false);
submitLock(game, t.ctx(), game.finale.lock.id, 'surface');
expect(room.result).toEqual({ success: true, ending: game.finale.successEnding, reveal: null, outOfTime: false });
});
});
+288
View File
@@ -0,0 +1,288 @@
// GAMES-NEW.md requirements for games 9–18 that the other suites don't already cover:
// sanitized payloads for every new game, Evidence Locker pins, "Out of time" and
// Nightmare Survivors results, one-shot accusations, and the extra content checks.
import { describe, expect, it } from 'vitest';
import type { Role } from '@two-keys/shared';
import type { Game } from '../src/content/schemas';
import { validateGame } from '../src/content/validate';
import { continueDefuse, type DefuseState } from '../src/engines/defuse';
import type { LabyrinthState } from '../src/engines/labyrinth';
import {
agreeAccusation,
continueChapter,
proposeAccusation,
submitAccusation,
submitLock,
tickStory,
togglePin,
} from '../src/engines/story';
import { TELEPATHY_MAX_LENGTH, answerSync, continueSync, type SyncState } from '../src/engines/sync';
import { buildState } from '../src/rooms/state';
import type { Room } from '../src/rooms/store';
import { clock, games, getGame, playingRoom } from './helpers';
const NEW_GAMES = [
'dead-air',
'masquerade-montague',
'kalahari-expedition',
'alchemists-vault',
'deep-lab-nereid',
'kestrel-heist',
'tick-tock-overtime',
'in-sync-deep-cuts',
'drowned-catacombs',
'mirror-maze',
];
const FORBIDDEN_KEYS = ['answers', 'answer', 'solution', 'fixtures', 'expect', 'seed', 'sequence', 'facing', 'word'];
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 json = (room: Room, role: Role, now?: number) => JSON.stringify(stateOf(room, role, now));
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 each new 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') {
(room.engine as DefuseState).level = game.levels.length - 1; // every module at once
continueDefuse(game, t.ctx());
}
if (game.engine === 'sync') {
continueSync(game, t.ctx());
const q = (room.engine as SyncState).questions[0];
answerSync(game, t.ctx(), 'A', q.id, 'Lighthouse');
}
return { room, t };
}
describe.each(NEW_GAMES.map((id) => [id, games.get(id)!] as const))('sanitized payloads: %s', (_id, game) => {
it('never carry answers, fixtures or solver state for either role', () => {
expect(game).toBeDefined();
const { room, t } = started(game);
for (const role of ['A', 'B'] as const) {
const keys = keysOf(JSON.parse(json(room, role, t.now)));
for (const key of FORBIDDEN_KEYS) expect(keys, `${role} got "${key}"`).not.toContain(key);
}
});
if (game.engine === 'defuse') {
it('keeps the device from the Expert and the manual from the Technician', () => {
const { room, t } = started(game);
const tech = json(room, 'A', t.now);
const expert = json(room, 'B', t.now);
expect(keysOf(JSON.parse(tech))).not.toContain('manual');
for (const key of ['device', 'indicators', 'serial', 'modules']) expect(keysOf(JSON.parse(expert))).not.toContain(key);
// Fixture answers don't ride along in any form.
for (const f of game.fixtures.passwords) expect(expert + tech).not.toContain(JSON.stringify(f.columns));
});
}
if (game.engine === 'sync') {
it('keeps a typed word and a ranking private until both are in', () => {
const { room } = started(game);
expect(json(room, 'B')).not.toContain('Lighthouse');
expect(stateOf(room, 'B').views.B).toMatchObject({ partnerAnswered: true, reveal: null });
});
}
if (game.engine === 'labyrinth') {
it("never sends the Explorer the map or where they are, or the Navigator the Explorer's position", () => {
const { room } = started(game);
const s = room.engine as LabyrinthState;
const explorer = JSON.parse(json(room, 'B')).views.B;
for (const key of ['grid', 'glyphs', 'lanterns', 'oneWay', 'x', 'y', 'facing']) expect(keysOf(explorer)).not.toContain(key);
// With no compass, not even a compass letter.
if (game.explorer.compass === 'relative') expect(JSON.stringify(explorer)).not.toMatch(/"[nesw]"/);
const navigator = JSON.parse(json(room, 'A')).views.A;
const { glyphs: _g, lanterns: _l, oneWay: _o, grid, ...rest } = navigator;
expect(keysOf(rest)).not.toContain('x');
if (game.navigatorView.mapTransform === 'mirror-x') {
// Only the mirrored map is ever served.
expect(grid).toEqual(game.grid.map((row) => [...row].reverse().join('')));
expect(grid).not.toEqual(game.grid);
} else {
expect(grid).toEqual(game.grid);
}
expect(s.x).toBeGreaterThan(0);
});
}
});
describe('story: Evidence Locker pins', () => {
const game = getGame('kalahari-expedition', 'story');
it('stars your own clues; your partner only sees how many', () => {
const room = playingRoom(game);
const t = clock(room);
const [mine] = game.chapters[0].clues.A;
const [theirs] = game.chapters[0].clues.B;
expect(togglePin(game, t.ctx(), 'A', theirs.id).ok).toBe(false);
expect(togglePin(game, t.ctx(), 'A', game.chapters[1].clues.A[0].id).ok).toBe(false); // not dealt yet
expect(togglePin(game, t.ctx(), 'A', mine.id).ok).toBe(true);
expect(stateOf(room, 'A').views.A).toMatchObject({ pins: [mine.id], partnerPins: 0 });
const b = stateOf(room, 'B').views.B;
expect(b).toMatchObject({ pins: [], partnerPins: 1 });
expect(JSON.stringify(b)).not.toContain(mine.id);
togglePin(game, t.ctx(), 'A', mine.id);
expect(stateOf(room, 'B').views.B).toMatchObject({ partnerPins: 0 });
});
});
describe('story: one-shot, four-question accusations', () => {
const game = getGame('masquerade-montague', 'story');
if (game.finale.type !== 'accusation') throw new Error('expected accusation');
const finale = game.finale;
function atFinale() {
const room = playingRoom(game);
const t = clock(room);
for (const ch of game.chapters) {
submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0]);
continueChapter(game, t.ctx());
}
return { room, t };
}
it('has four questions and a single attempt', () => {
expect(finale.questions).toHaveLength(4);
expect(finale.maxAttempts).toBe(1);
});
it('a wrong accusation goes straight to the fail ending and the reveal', () => {
const { room, t } = atFinale();
const wrong = Object.fromEntries(finale.questions.map((q) => [q.id, q.options.find((o) => o.id !== q.answer)!.id]));
proposeAccusation(game, t.ctx(), 'A', wrong);
agreeAccusation(game, t.ctx(), 'B');
expect(submitAccusation(game, t.ctx())).toMatchObject({ correct: false });
expect(room.phase).toBe('finished');
expect(room.result).toMatchObject({ success: false, ending: finale.failEnding, reveal: finale.reveal });
});
it('the right one wins', () => {
const { room, t } = atFinale();
proposeAccusation(game, t.ctx(), 'B', Object.fromEntries(finale.questions.map((q) => [q.id, q.answer])));
agreeAccusation(game, t.ctx(), 'A');
expect(submitAccusation(game, t.ctx())).toMatchObject({ correct: true });
expect(room.result?.success).toBe(true);
});
});
describe('results', () => {
it('a time limit running out shows "Out of time", not a rank', () => {
const game = getGame('kestrel-heist', 'story');
const room = playingRoom(game);
const t = clock(room);
tickStory(game, t.ctx(game.rules.timeLimitMinutes! * 60_000));
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);
const room = playingRoom(game);
const t = clock(room);
for (const ch of game.chapters) {
submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0]);
continueChapter(game, t.ctx());
}
if (game.finale.type !== 'escape') throw new Error('expected escape');
submitLock(game, t.ctx(), game.finale.lock.id, game.finale.lock.answers[0]);
expect(stateOf(room, 'A').result).toMatchObject({ success: true, outOfTime: false, nightmareSurvivors: true });
// Not for an easier game.
const easy = getGame('alchemists-vault', 'story');
const other = playingRoom(easy);
const t2 = clock(other);
for (const ch of easy.chapters) {
submitLock(easy, t2.ctx(), ch.lock.id, ch.lock.answers[0]);
continueChapter(easy, t2.ctx());
}
if (easy.finale.type !== 'escape') throw new Error('expected escape');
submitLock(easy, t2.ctx(), easy.finale.lock.id, easy.finale.lock.answers[0]);
expect(stateOf(other, 'A').result).toMatchObject({ success: true, nightmareSurvivors: false });
});
});
describe('sync: telepathy input', () => {
it(`rejects empty answers and anything over ${TELEPATHY_MAX_LENGTH} characters`, () => {
const game = getGame('in-sync-deep-cuts', 'sync');
const room = playingRoom(game);
const t = clock(room);
continueSync(game, t.ctx());
const q = (room.engine as SyncState).questions[0];
expect(answerSync(game, t.ctx(), 'A', q.id, '').ok).toBe(false);
expect(answerSync(game, t.ctx(), 'A', q.id, 'x'.repeat(TELEPATHY_MAX_LENGTH + 1)).ok).toBe(false);
expect(answerSync(game, t.ctx(), 'A', q.id, 'x'.repeat(TELEPATHY_MAX_LENGTH)).ok).toBe(true);
});
});
describe('content validation additions', () => {
const clone = <T>(game: T): T => structuredClone(game);
it('rejects a rank question without exactly 4 items', () => {
const game = clone(getGame('in-sync-deep-cuts', 'sync'));
game.rank[0].items = game.rank[0].items.slice(0, 3);
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';
game.modules.morse!.words.HUG = '3.506';
const errors = validateGame(game).join('\n');
expect(errors).toMatch(/password word list/);
expect(errors).toMatch(/morse word "HUG" should be on 3.505/);
});
it('rejects a password fixture column without 6 letters', () => {
const game = clone(getGame('tick-tock-overtime', 'defuse'));
game.fixtures.passwords[0].columns[0] = 'XAPUD';
expect(validateGame(game).join('\n')).toMatch(/exactly 6 letters/);
});
it('rejects a glyph on a trap', () => {
const game = clone(getGame('mirror-maze', 'labyrinth'));
const [trap] = game.grid.flatMap((row, y) => [...row].flatMap((c, x) => (c === 'T' ? [{ x, y }] : [])));
game.glyphs[0] = { ...game.glyphs[0], ...trap };
expect(validateGame(game).join('\n')).toMatch(/is on a trap/);
});
});
+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
@@ -122,6 +122,6 @@ describe('story rules', () => {
}
}
expect(room.phase).toBe('finished');
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: game.finale.reveal });
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: game.finale.reveal, outOfTime: false });
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@two-keys/shared",
"version": "1.0.1",
"version": "1.3.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
+4 -1
View File
@@ -1,4 +1,4 @@
export const RANKS = ['Soulmate Sleuths', 'Dream Team', 'Getting There', 'Love Is Patient 😅'] as const;
export const RANKS = ['Soulmate Sleuths', 'Dream Team', 'Getting There', 'Love Is Patient 😅', 'Out of time'] as const;
export type Rank = (typeof RANKS)[number];
export interface RankInput {
@@ -8,11 +8,14 @@ export interface RankInput {
estimatedMinutes: number;
keysEarned: number;
totalKeys: number;
/** A time limit ran out: no rank badge, just "Out of time". */
outOfTime?: boolean;
}
export function rankFor(r: RankInput): Rank {
const estimate = r.estimatedMinutes * 60;
const allKeys = r.keysEarned >= r.totalKeys;
if (r.outOfTime) return 'Out of time';
if (!r.success) return 'Love Is Patient 😅';
if (allKeys && r.hintsUsed === 0 && r.finalSeconds <= estimate) return 'Soulmate Sleuths';
if (allKeys && r.hintsUsed <= 3 && r.finalSeconds <= estimate * 1.5) return 'Dream Team';
+207 -9
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 {
@@ -35,6 +35,8 @@ export interface GameSummary {
theme: Theme;
roles: Record<Role, RoleInfo>;
totalKeys: number;
/** Hard time limit, for the games that have one. */
timeLimitMinutes: number | null;
}
export interface GameMeta extends GameSummary {
@@ -83,6 +85,10 @@ export interface LockView {
/** Revealed hint tiers only. */
hints: string[];
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;
@@ -130,6 +136,10 @@ export interface StoryView {
totalChapters: number;
awaitingContinue: boolean;
finale: AccusationView | EscapeFinaleView | null;
/** Clue ids you starred. */
pins: string[];
/** How many clues your partner starred (never which). */
partnerPins: number;
}
// ---------- Defuse engine ----------
@@ -146,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[];
}
@@ -162,7 +176,42 @@ export interface KeypadModuleView {
solved: boolean;
symbols: { id: string; label: string; pressed: boolean }[];
}
export type DefuseModuleView = WiresModuleView | ButtonModuleView | KeypadModuleView;
export interface PasswordsModuleView {
id: 'passwords';
solved: boolean;
/** The letters each column cycles through, top to bottom. */
columns: string[];
}
export interface MorseModuleView {
id: 'morse';
solved: boolean;
/** Same format as a signal clue. Never print it as text. */
signal: string;
/** Dial range in kHz (3500 = 3.500 MHz). */
minKhz: number;
maxKhz: number;
}
export interface SimonModuleView {
id: 'simon';
solved: boolean;
colors: string[];
/** The flashes of the current stage. */
flashes: string[];
stage: number;
stages: number;
/** Presses accepted so far in this stage. */
entered: number;
}
export type DefuseModuleView =
| WiresModuleView
| ButtonModuleView
| KeypadModuleView
| PasswordsModuleView
| MorseModuleView
| SimonModuleView;
export const DEFUSE_MODULES = ['wires', 'button', 'keypad', 'passwords', 'morse', 'simon'] as const;
export type DefuseModuleId = (typeof DEFUSE_MODULES)[number];
interface DefuseCommon {
engine: 'defuse';
@@ -172,10 +221,21 @@ 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';
device: { serial: string; batteries: number; modules: DefuseModuleView[] } | null;
device: {
serial: string;
batteries: number;
indicators: { label: string; lit: boolean }[];
modules: DefuseModuleView[];
} | null;
}
export interface ExpertView extends DefuseCommon {
seat: 'expert';
@@ -188,10 +248,18 @@ export type DefuseAction =
| { type: 'tap' }
| { type: 'hold' }
| { type: 'release' }
| { type: 'press'; symbol: string };
| { type: 'press'; symbol?: string; color?: string }
| { type: 'submit'; word: string }
| { type: 'transmit'; frequency: string };
// ---------- Sync engine ----------
export const SYNC_ROUND_TYPES = ['match', 'predict', 'telepathy', 'rank'] as const;
export type SyncRoundType = (typeof SYNC_ROUND_TYPES)[number];
/** An option index (match/predict), a typed word (telepathy), or item indexes, best first (rank). */
export type SyncAnswer = number | string | number[];
export interface SyncView {
engine: 'sync';
status: 'intro' | 'question' | 'reveal' | 'summary';
@@ -199,8 +267,9 @@ export interface SyncView {
index: number;
total: number;
title: string;
type: 'match' | 'predict';
type: SyncRoundType;
blurb: string;
/** Who the current question is about (predict and rank rounds). */
subject: Role | null;
};
isReplay: boolean;
@@ -208,12 +277,13 @@ export interface SyncView {
questionsPerRound: number;
pointsForKey: number;
points: number;
/** `options` holds the items to order in a rank round, and is empty for telepathy. */
question: { id: string; text: string; options: string[] } | null;
iAmSubject: boolean;
myAnswer: number | null;
myAnswer: SyncAnswer | null;
partnerAnswered: boolean;
/** Both answers, only once both are in. */
reveal: { A: number; B: number; match: boolean } | null;
reveal: { A: SyncAnswer; B: SyncAnswer; match: boolean } | null;
/** Match / miss for each question revealed so far this round. */
marks: boolean[];
summary: { earned: boolean; canReplay: boolean; keyName: string } | null;
@@ -222,18 +292,27 @@ export interface SyncView {
// ---------- Labyrinth engine ----------
export type Dir = 'n' | 'e' | 's' | 'w';
/** Forward / left / right / back, for mazes where the Explorer has no compass. */
export type RelDir = 'f' | 'l' | 'r' | 'b';
export type MoveDir = Dir | RelDir;
export interface ExplorerView {
engine: 'labyrinth';
seat: 'explorer';
glyphLabel: string | null;
open: Record<Dir, boolean>;
compass: 'absolute' | 'relative';
/** Keyed by n/e/s/w, or by f/l/r/b when the compass is relative. */
open: Partial<Record<MoveDir, boolean>>;
/** Directions blocked by a one-way gate rather than a wall. */
gates: MoveDir[];
keysCarried: number;
totalKeys: number;
atExit: boolean;
message: string;
moves: number;
falls: number;
/** Items carried (picked up on entry, kept after traps). */
inventory: string[];
}
export interface NavigatorView {
@@ -242,6 +321,11 @@ export interface NavigatorView {
grid: string[];
glyphs: { x: number; y: number; label: string }[];
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;
moves: number | null;
@@ -249,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 ----------
@@ -263,6 +456,8 @@ export interface GameResult {
keysEarned: number;
totalKeys: number;
rank: string;
outOfTime: boolean;
nightmareSurvivors: boolean;
ending: string | null;
reveal: string | null;
}
@@ -281,6 +476,9 @@ export interface RoomState {
finishedAt: number | null;
penaltySeconds: number;
serverNow: number;
/** Hard limit on elapsed + penalty time, if the game has one. */
limitSeconds: number | null;
limitLabel: string | null;
};
stats: { hintsUsed: number; wrongGuesses: number };
/** One view per role this socket may see: own role, or both in couch mode. */