Two Keys: co-op mystery games for couples. Eight games on four engines (story, defuse, sync, labyrinth), two-device and couch play, mobile-first UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 22:36:12 +02:00
co-authored by Claude Opus 5.5
commit 0de6617c82
85 changed files with 15692 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
server/data/
*.log
.DS_Store
.idea/
temp/
+250
View File
@@ -0,0 +1,250 @@
# Two Keys: Co-op Mysteries for Couples
> Build spec for Claude Code. Read this whole file before writing any code.
> Game content lives in `content/games/*.yaml`. **Never hard-code puzzle answers in client code.**
## 1. What we're building
A website where **two players (a couple)** play co-op mystery games together. Each player sees **different information**, so neither can solve anything alone. They talk it out, pool clues, and enter answers into shared locks. Every lock solved earns a **key**. Enough keys unravel the mystery.
**Core rule of every game: Player A and Player B never see the same clue set.** Every lock needs info from both.
### Game library (8 games, 4 engines)
| # | Game | Category | Engine | File |
|---|------|----------|--------|------|
| 1 | Death at Vineyard Hollow | Murder Mystery | `story` | `vineyard-hollow.yaml` |
| 2 | The Last Set at the Blue Lantern | Murder Mystery | `story` | `blue-lantern.yaml` |
| 3 | Murder on the Southern Star | Murder Mystery | `story` | `southern-star.yaml` |
| 4 | The Clockmaker's Attic | Escape Room | `story` | `clockmakers-attic.yaml` |
| 5 | Signal Lost: Station Echo | Escape Room | `story` | `station-echo.yaml` |
| 6 | Tick Tock Together | Co-op Defuse | `defuse` | `tick-tock-together.yaml` |
| 7 | In Sync | Couples Connection | `sync` | `in-sync.yaml` |
| 8 | Labyrinth of Lanterns | Co-op Navigation | `labyrinth` | `labyrinth-of-lanterns.yaml` |
## 2. Tech stack
- **Monorepo** with npm workspaces: `client/`, `server/`, `shared/`, `content/`
- **Client:** Vite + React 18 + TypeScript + Tailwind CSS + React Router + `socket.io-client` + Framer Motion (subtle transitions only)
- **Server:** Node 20 + TypeScript + Express + Socket.io + `js-yaml` + `zod`
- **Shared:** TypeScript types + the answer normalizer (used by tests; the server is the only thing that checks answers)
- **Tests:** Vitest (server + shared), Playwright smoke test (optional, phase 6)
- **Storage:** In-memory room store, snapshotted to `server/data/rooms.json` every 10s and on shutdown so a restart doesn't kill a game in progress. Rooms expire after 24h idle.
- **Deploy target:** single Node process serves the built client + websocket (Render / Railway / Fly.io). No serverless, as we need persistent websockets.
### Folder structure
```
two-keys/
CLAUDE.md
GAMES.md # spoiler guide (designer reference, never served)
package.json # workspaces
content/games/*.yaml # all game content (source of truth)
shared/src/
types.ts # Game, Chapter, Clue, Lock, Room, etc.
normalize.ts # answer normalizer
server/src/
index.ts # express + socket.io bootstrap
content/loader.ts # loads + zod-validates YAML at boot
content/sanitize.ts # strips answers, filters clues by role
rooms/store.ts # in-memory store + snapshot
rooms/codes.ts # 5-char room codes (no 0/O/1/I)
engines/story.ts
engines/defuse.ts # generator + solver
engines/sync.ts
engines/labyrinth.ts
socket/handlers.ts
scripts/validate-content.ts
client/src/
pages/Home.tsx Library.tsx Lobby.tsx Play.tsx Results.tsx HowToPlay.tsx
engines/StoryGame/ DefuseGame/ SyncGame/ LabyrinthGame/
components/ClueCard.tsx LockPanel.tsx KeyRing.tsx HintButton.tsx
Notepad.tsx PartnerStatus.tsx Timer.tsx PassDeviceScreen.tsx
lib/socket.ts session.ts theme.ts
```
## 3. Rooms, roles, and play modes
### Two-device mode (default)
1. Player creates a game from the library → server creates a room with a 5-char code (e.g. `K7MRX`) and a share link `/join/K7MRX`.
2. Creator picks role **A** or **B** (each game names its roles, e.g. "Inspector" / "Estate Insider"). Partner joins and gets the other role.
3. Game starts when both are in and both tap **Ready**.
4. Each browser stores `{roomCode, playerToken}` in `localStorage` (wrapped in try/catch) for **reconnect**. Refreshing the page rejoins the same seat.
### Couch mode (one device)
- One phone/laptop, both players. Role views alternate behind a **"Pass to [Role B name] 👀 (no peeking)"** cover screen with a tap-to-reveal.
- Locks and the key ring are visible in both views.
### Partner presence
- Show partner online/offline dot, which chapter they're viewing, and a **"Ping"** button (sends a little 👋 / 🔍 / 💡 / ❤️ reaction that floats on the partner's screen).
- **Shared notepad** synced live (debounced 300ms, last-write-wins is fine).
- No in-app chat. Couples are on the couch or on a call. Add a line on the lobby: "Best played on a call or side by side."
## 4. Content model (YAML → zod)
All game files share a header:
```yaml
id: string # matches filename
engine: story | defuse | sync | labyrinth
category: murder-mystery | escape-room | co-op
title, tagline: string
estimatedMinutes: number
difficulty: 1-3
players: { min: 2, max: 2 }
theme: { primary, accent, background, headingFont }
roles: { A: {name, blurb}, B: {name, blurb} }
intro: markdown
```
### `story` engine (games 1–5)
```yaml
suspects: # optional (murder mysteries)
- { id, name, role, description }
chapters:
- id, title, narrative (markdown, both players see it)
clues:
A: [Clue] # only Player A ever receives these
B: [Clue] # only Player B
shared: [Clue] # both
lock:
id, prompt
inputType: code | text | choice
format: string # shown under the input, e.g. "4 digits", "HH:MM"
options: [...] # only for choice
answers: [string] # SERVER ONLY, any match = correct
hints: [string, string, string] # tier 1 nudge → tier 3 near-solution
successText: markdown # revealed to both on unlock
keyName: string # name of the key earned, e.g. "Cellar Key"
finale:
type: accusation | escape
prompt
# accusation only:
questions: [{ id, label, options: [{id, label}], answer }] # answer SERVER ONLY
maxAttempts: number
failEnding, reveal: markdown
# escape only:
clues: { A: [Clue], B: [Clue] } # finale gets its own split clues
lock: Lock # same shape as a chapter lock (no keyName needed)
# both:
successEnding: markdown
```
Use a zod discriminated union on `finale.type`.
**Clue object:** `{ id, title, type, body, signal? }`
- `type` ∈ `note | letter | document | report | log | table | photo | map | transcript | signal | diagram`
- `body` is markdown (tables allowed). Render each type with its own card style:
- `note`: handwritten-ish (Caveat font), slight rotation
- `letter`: paper texture, serif
- `report` / `document` / `log`: typewriter / monospace header strip
- `photo`: polaroid frame; body text is the photo description in italics (no image assets needed)
- `map` / `diagram`: monospace block, preserve whitespace, grid tables
- `signal`: animated blinking light driven by `signal` field (see §6.3)
- Clues from **previous** chapters stay accessible in an "Evidence Locker" drawer.
### Other engines
See each YAML for its schema. Summaries in §6.
## 5. Security & answer checking (important)
- The server loads YAML and keeps the full version in memory.
- `sanitize.ts` builds the **per-role payload**: removes every `answers`, `answer`, `solution`, finale `answer` field, and removes the *other* role's clues. Write a unit test asserting no sanitized payload contains any answer string.
- Also strip per engine: **defuse** → Technician never gets `manual`, Expert never gets the device state; nobody gets `fixtures` or computed solutions. **labyrinth** → Explorer never gets `grid`, `glyphs`, `lanterns` positions; Navigator never gets the Explorer's coordinates. **sync** → never send the partner's answer until both have answered. **signal clues** → send the `signal` pattern only for clues of type `signal`, and the client must never print it as text.
- Hints are only sent once revealed (never ship unrevealed hint tiers to the client).
- **Only the server checks answers.** Client sends `lock:submit {lockId, value}`.
- **Normalizer** (`shared/normalize.ts`): uppercase → strip accents → remove everything except `A-Z0-9`. So `"20:40"`, `"20 40"` → `2040`; `"Fire Swart, Friday!"` → `FIRESWARTFRIDAY`. Compare normalized input to each normalized entry in `answers`.
- Rate-limit submissions: max 1 per 2s per room per lock; after 5 wrong answers on one lock, show "Need a hint?" nudge.
## 6. Engines
### 6.1 Story engine (games 1–5)
- State: `currentChapter`, `keys[]`, `hintsUsed{lockId: tier}`, `wrongAttempts{}`, `startedAt`, `penaltySeconds`, `finaleAttempts`.
- Chapter view: narrative on top → my clues → lock panel → key ring.
- Correct answer → both players see the lock spring open (animation), `successText` reveals, the key flies onto the key ring, **Continue** button (either player can press) advances both.
- **Hints:** either player can request; tiers unlock in order; both players see revealed hints. Each hint adds **+2:00** penalty. 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`.
- For `escape` finales, it's just the last lock; on success show `successEnding`.
### 6.2 Defuse engine (game 6). See `tick-tock-together.yaml`
- Player A (**Technician**) sees the device. Player B (**Expert**) sees only the manual.
- Server **generates** each device from a seeded RNG and computes the solution with a **solver implementing the manual rules exactly**. Never generate a device the solver can't solve.
- Implement the fixtures in the YAML as unit tests. They must pass.
- Strikes: 3 per level. Timer per level. Clock speeds up 25% after each strike.
- Each level cleared = 1 key. Fail a level → retry with a new seed (no key lost).
### 6.3 Signal clues (game 5)
`signal: "./-.-./..../---"`: `.` = 250ms on, `-` = 750ms on, 250ms gap between symbols, `/` = 1000ms gap between letters, 3000ms pause, then loop. Render as a glowing circle. Add a "Replay" button. Never render the dot/dash text to that player (they must read the light).
### 6.4 Sync engine (game 7). See `in-sync.yaml`
- Rounds of 5 questions. Both answer privately; answers reveal simultaneously with a flip animation.
- `match` rounds: both pick from the same options. Match = point.
- `predict` rounds: the `subject` role answers about themselves, the other guesses. Match = point.
- 3+ points in a round = key. Failing a round lets them replay it once with fresh questions.
- Draw questions randomly from the bank, never repeating within a session.
### 6.5 Labyrinth engine (game 8). See `labyrinth-of-lanterns.yaml`
- Player B (**Explorer**) sees only their current cell: four direction buttons (disabled where wall), glyph on the floor if any, key if present.
- Player A (**Navigator**) sees the full map with glyphs, keys, traps, and exit, but **NOT** the Explorer's position. The Navigator has to work out where they are from what the Explorer describes.
- Traps are invisible to the Explorer. Stepping on one sends them back to Start (keys kept) and adds 1:00 penalty.
- Each lantern key collected = key. Exit only opens with all 3.
- Server validates every move.
## 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`,
`finale:propose {answers}`, `finale:agree`, `finale:submit`,
`defuse:action {moduleId, action}`, `sync:answer {questionId, optionId}`,
`maze:move {dir}`, `notepad:update {text}`, `ping {emoji}`
Server → client:
`room:state` (full sanitized per-role state, sent on every change), `lock:result {lockId, correct}`,
`toast {message}`, `ping {from, emoji}`, `error {code, message}`
Keep it simple: after any mutation, broadcast a fresh per-role `room:state` to each socket.
## 8. Scoring & results
- `finalTime = elapsed + penaltySeconds`
- Rank badges: **Soulmate Sleuths** (no hints, under estimate) / **Dream Team** / **Getting There** / **Love Is Patient** 😅
- 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 🔑🔑🔑).
- **Mobile-first.** Most couples will play on two phones. Clue cards stack vertically; lock panel is sticky at the bottom; evidence locker is a bottom sheet.
- Accessibility: all locks keyboard-operable, `prefers-reduced-motion` respected, colorblind-safe (defuse wires also show their color name as text label).
- Role color-coding: A = warm (amber), B = cool (teal), shown as a badge on every clue card ("Only you can see this").
## 10. Content validation script
`npm run validate-content` must check:
- Every YAML parses and passes its engine's zod schema
- Story: every chapter has ≥1 clue for A **and** ≥1 for B; every lock has ≥1 answer and exactly 3 hints; finale answers exist in options
- 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
Run it in CI and as a pre-start step.
## 11. Build phases (do in order, commit after each)
1. **Scaffold** monorepo, shared types, normalizer + tests, YAML loader + zod schemas + validate script.
2. **Rooms**: create/join/role/ready/reconnect, lobby UI, home + library pages.
3. **Story engine** end-to-end with `vineyard-hollow`. Then confirm games 2–5 load and play with no code changes.
4. **Hints, timer, penalties, finale agreement flow, results page.**
5. **Defuse, Sync, Labyrinth** engines.
6. Couch mode, notepad, pings, polish, theming, mobile QA, Playwright smoke test (two browser contexts complete Clockmaker's Attic).
## 12. Acceptance criteria
- [ ] Two phones can play any of the 8 games start to finish
- [ ] Player A cannot find B's clues or any answer via DevTools / network tab
- [ ] Refreshing mid-game rejoins the same role and progress
- [ ] All answers listed in the YAML are accepted; normalizer handles spaces/case/punctuation
- [ ] `npm run validate-content` passes; all unit tests pass
- [ ] Works at 375px wide with no horizontal scroll
+98
View File
@@ -0,0 +1,98 @@
# GAMES.md: Spoiler Guide (designer reference, NEVER served to clients)
Every lock below needs info from **both** players. "A has / B has" shows the split.
---
## 1. Death at Vineyard Hollow (murder mystery · 60 min · 🔑🔑)
Roles: **Inspector** (A) / **Estate Insider** (B)
| Ch | Lock | A has | B has | Answer |
|----|------|-------|-------|--------|
| 1 | Cellar keypad | Butler: "year it began, read backwards" | Gate plaque: Est. 1874 | `4781` |
| 2 | Time of death | ME temp 34.5 °C at 23:10, 1 °C/hr | Keycard log | `20:40` |
| 3 | Poisoned bottle | Lab: red, decanted, cork ends in 7 | Cellar book | `1998` Cabernet Reserve |
| 4 | Diary cipher | Ciphertext "ORAN BFJAC OARMJH", "rack-shift" | Rack 9 = his heart | `FIRE SWART FRIDAY` (shift −9) |
| 5 | Poison source | Tox: fresh *Digitalis* leaves; Vos's tablets sealed | Foxglove bed behind Swart's workshop; card in his apron | `FOXGLOVE` |
**Killer:** Pieter Swart · foxglove extract in the 1998 Reserve · to avoid being fired and exposed for label fraud.
**Red herrings:** Dr Vos (digoxin, near the time window), Marcus (heir, in cellar too late), Isabelle (wanted the estate).
---
## 2. The Last Set at the Blue Lantern (murder mystery · 50 min · 🔑🔑)
Roles: **Detective** (A) / **Regular** (B)
| Ch | Lock | A has | B has | Answer |
|----|------|-------|-------|--------|
| 1 | Drum solo time | Setlist with song lengths | Set started 23:00; solo = last 2 min of Caravan | `23:20` |
| 2 | No alibi | Band walks off during drum solos; Ruby sits out instrumentals | Bar/coat-check alibis (Ruby at coat-check 23:21) | Sammy |
| 3 | Safe | Sheet music notes C·A·F·E | Piano sticker numbers C1…B7 | `1643` |
| 4 | Gun location | Search log: piano not searched | Max: dead "thunk" on low keys | `PIANO` |
**Killer:** Sammy Keys · Vincent's .38 under the drum solo, hidden in the piano · Vincent stole his songs and was about to sell them.
---
## 3. Murder on the Southern Star (murder mystery · 70 min · 🔑🔑🔑)
Roles: **Passenger Detective** (A) / **Railway Inspector** (B)
| Ch | Lock | A has | B has | Answer |
|----|------|-------|-------|--------|
| 1 | Master key box | Rule: carriage × compartment, 4 digits | Plan: Gold Suite = carriage 6, comp 23 | `0138` |
| 2 | Real time of death | Watch 01:15 Greenwich; local is +2h; needle wound | Timetable: Matjiesfontein 03:10–03:20; thump at a platform | `03:15` |
| 3 | Telegram sender | Telegram text | "Name hidden where every line begins" | `HUGO` (acrostic) |
| 4 | Who lied | Corridor watched, nobody passed; only Vera owns a fur coat | Porter: fur-coat lady off car 6, back on car 4 | Vera |
| 5 | Motive word | "V. = 3·7·8·4·2·8", "ask the line" | Numbered stopping list | `FORGER` |
**Killer:** Countess Vera Orlova · hatpin to the base of the skull during the Matjiesfontein stop · Ashby had discovered she was a jewel forger.
---
## 4. The Clockmaker's Attic (escape room · 40 min · 🔑) (best "first game")
Roles: **Tinkerer** (A) / **Collector** (B)
| Lock | A has | B has | Answer |
|------|-------|-------|--------|
| Trunk | Robin 3, Owl 7, Fox 9 | Sampler: dawn bird, hunter, night bird | `397` |
| Music box | "Sky-bow top to bottom, skip what the pendulum stole" | Painting: red, gold, green, blue; pendulum is green | `RED GOLD BLUE` |
| Gear drawer | Gears 48 → 16 → 24 teeth | Turn crank 5 times, count Gear C | `10` |
| Door (finale) | "Order treasures by year made" | Inventory with years | `FOREVER` |
---
## 5. Signal Lost: Station Echo (escape room · 45 min · 🔑🔑)
Roles: **Comms Officer** (A) / **Engineer** (B)
| Lock | A has | B has | Answer |
|------|-------|-------|--------|
| Warning light | Morse chart | Blinking lamp `...-/./-./-` | `VENT` |
| Breach | Current pressures | Normal pressures + ">30% drop" rule | `GRN-3` (Greenhouse, ~33% drop) |
| Generator | Crew roster (5 aboard) | Call sign EC-127 + "× crew aboard" | `635` |
| Beacon (finale, roles swap) | Blinking lamp `..../---/--/.` | Morse chart | `HOME` |
---
## 6. Tick Tock Together (co-op defuse · 25 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 · 🔑)
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 · 🔑🔑)
Navigator (A) has the map, Explorer (B) sees only their own cell. 3 lanterns, 3 hidden traps.
```
########### S start (1,1) K lanterns (7,3) (9,5) (1,7)
#S..#.....# E exit (9,9) T traps (4,5) (6,7) (3,9)
#.#.#.###.#
#.#...#K..# Verified by BFS: every lantern and the exit are
#.#####.#.# reachable from S without stepping on a trap.
#...T...#K#
###.###.#.#
#K..#.T.#.#
#.###.#.#.#
#..T....#E#
###########
```
+26
View File
@@ -0,0 +1,26 @@
# Two Keys 🔑🔑
Co-op mystery games for couples. Two players, different clues, every lock needs both of you.
## Hand this to Claude Code
1. Unzip this folder and open it in your terminal (or WebStorm's terminal).
2. Run `claude`.
3. Paste this as your first prompt:
> Read CLAUDE.md and GAMES.md fully, then every file in content/games/. Build phase 1 from §11 and stop so I can review. Don't change any YAML puzzle content without asking me.
4. After each phase, review, run `npm test` and `npm run validate-content`, then say **"next phase"**.
## What's in here
| File | What it is |
|------|------------|
| `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 |
| `package.json` | Root workspace config |
## Adding a new game later
Copy a YAML in `content/games/`, change the `id` to match the filename, write your chapters,
and run `npm run validate-content`. No code changes needed for new story games.
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#120c0e" />
<meta name="description" content="Co-op mystery games for couples. Two players, different clues, every lock needs both of you." />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<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"
rel="stylesheet"
/>
<title>Two Keys · Co-op mysteries for couples</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@two-keys/client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit -p tsconfig.json && vite build",
"preview": "vite preview"
},
"dependencies": {
"@two-keys/shared": "*",
"framer-motion": "^11.11.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.27.0",
"remark-gfm": "^4.0.0",
"socket.io-client": "^4.8.0"
},
"devDependencies": {
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"vite": "^5.4.10"
}
}
+3
View File
@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
};
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#160c10"/><text x="32" y="44" font-size="34" text-anchor="middle">🔑</text></svg>

After

Width:  |  Height:  |  Size: 187 B

+24
View File
@@ -0,0 +1,24 @@
import { MotionConfig } from 'framer-motion';
import { Route, Routes } from 'react-router-dom';
import { Toasts } from './components/ui';
import { Home } from './pages/Home';
import { HowToPlay } from './pages/HowToPlay';
import { Library } from './pages/Library';
import { JoinRedirect, Room } from './pages/Room';
export function App() {
return (
// Animations switch off for anyone who asks their device for reduced motion.
<MotionConfig reducedMotion="user">
<Toasts />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/games" element={<Library />} />
<Route path="/how-to-play" element={<HowToPlay />} />
<Route path="/join/:code" element={<JoinRedirect />} />
<Route path="/room/:code" element={<Room />} />
<Route path="*" element={<Home />} />
</Routes>
</MotionConfig>
);
}
+173
View File
@@ -0,0 +1,173 @@
import { useEffect, useState } from 'react';
import type { ClueType, ClueView } from '@two-keys/shared';
import { Markdown } from './ui';
const TYPE_LABEL: Record<ClueType, string> = {
note: 'Note',
letter: 'Letter',
document: 'Document',
report: 'Report',
log: 'Log',
table: 'Table',
photo: 'Photo',
map: 'Map',
transcript: 'Transcript',
signal: 'Signal',
diagram: 'Diagram',
};
/** Who can see this clue: tinted in the viewer's role colour when it's theirs alone. */
function Audience({ clue, light }: { clue: ClueView; light: boolean }) {
const isPrivate = clue.audience === 'private';
return (
<span
className={`shrink-0 rounded-full px-2 py-0.5 font-sans text-[11px] font-semibold not-italic ${
isPrivate
? light
? 'bg-stone-900 text-[color:var(--role)]'
: 'bg-black/40 text-[color:var(--role)] ring-1 ring-[color:var(--role)]'
: light
? 'bg-stone-900/10 text-stone-700'
: 'bg-white/10 text-stone-300'
}`}
>
{isPrivate ? '👁 Only you can see this' : 'You both have this'}
</span>
);
}
function Header({ clue, light, mono }: { clue: ClueView; light: boolean; mono?: boolean }) {
return (
<div className="mb-3 flex flex-wrap items-start justify-between gap-x-3 gap-y-1.5">
<div className="min-w-0">
<div className={`text-[10px] font-bold uppercase tracking-[0.18em] opacity-60 ${mono ? 'font-mono' : 'font-sans'}`}>
{TYPE_LABEL[clue.type]}
</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>
);
}
// `.` = 250ms on, `-` = 750ms on, 250ms between symbols, `/` = 1000ms between letters,
// then a 3000ms pause and it loops.
function signalTimeline(pattern: string): { on: boolean; ms: number }[] {
const steps: { on: boolean; ms: number }[] = [];
const symbols = [...pattern];
symbols.forEach((symbol, i) => {
if (symbol === '/') return;
steps.push({ on: true, ms: symbol === '-' ? 750 : 250 });
const next = symbols[i + 1];
if (next === undefined) steps.push({ on: false, ms: 3000 });
else steps.push({ on: false, ms: next === '/' ? 1000 : 250 });
});
return steps;
}
/** A blinking light. The pattern is only ever shown as light, never as text. */
export function SignalLamp({ pattern }: { pattern: string }) {
const [on, setOn] = useState(false);
const [run, setRun] = useState(0);
useEffect(() => {
const steps = signalTimeline(pattern);
if (steps.length === 0) return;
let timer: ReturnType<typeof setTimeout>;
let i = 0;
setOn(false);
const tick = () => {
const step = steps[i % steps.length];
setOn(step.on);
i++;
timer = setTimeout(tick, step.ms);
};
timer = setTimeout(tick, 600); // a beat of darkness so the start is unmistakable
return () => clearTimeout(timer);
}, [pattern, run]);
return (
<div className="flex flex-col items-center gap-4 py-3">
<div className="rounded-full border-4 border-stone-700 bg-black p-3">
<div role="img" aria-label="A blinking signal lamp" className={`lamp h-20 w-20 rounded-full ${on ? 'on' : ''}`} />
</div>
<button className="btn text-sm" onClick={() => setRun((n) => n + 1)}>
↻ Replay from the start
</button>
</div>
);
}
export function ClueCard({ clue }: { clue: ClueView }) {
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 />
<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 />
<Markdown className="paper font-serif text-[1.05rem] leading-7">{clue.body}</Markdown>
</article>
);
case 'report':
case 'document':
case 'log':
return (
<article className="overflow-hidden rounded-md bg-[#ecebe4] text-stone-900 shadow-lg shadow-black/40">
<div className="bg-stone-800 px-4 py-1.5 font-mono text-[10px] font-bold uppercase tracking-[0.25em] text-stone-200">
{clue.type === 'log' ? '▮ Access log' : clue.type === 'report' ? '▮ Official report' : '▮ On file'}
</div>
<div className="p-4">
<Header clue={clue} light mono />
<Markdown className={`paper text-[15px] leading-6 ${clue.type === 'log' ? 'font-mono text-[13px]' : ''}`}>{clue.body}</Markdown>
</div>
</article>
);
case 'photo':
return (
<article className="rotate-[0.5deg] rounded-sm bg-stone-100 p-3 pb-4 text-stone-900 shadow-lg shadow-black/50">
<div className="flex min-h-[7rem] items-center rounded-sm bg-gradient-to-br from-stone-700 to-stone-900 p-4 text-stone-100">
<Markdown className="italic leading-6">{clue.body}</Markdown>
</div>
<div className="mt-3">
<Header clue={clue} light />
</div>
</article>
);
case 'map':
case 'diagram':
return (
<article className="rounded-md border border-accent/30 bg-black/50 p-4 text-accent">
<Header 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} />
<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 />
{clue.signal && <SignalLamp pattern={clue.signal} />}
<Markdown className="text-sm leading-6 text-stone-300">{clue.body}</Markdown>
</article>
);
case 'table':
return (
<article className="rounded-md border border-white/15 bg-white/[0.05] p-4">
<Header clue={clue} light={false} mono />
<Markdown className="font-mono text-[13px] leading-6">{clue.body}</Markdown>
</article>
);
}
}
+85
View File
@@ -0,0 +1,85 @@
import { useState } from 'react';
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 { pushToast } from '../lib/toasts';
import { RoleBadge, Sheet } from './ui';
const ENGINE_BLURB: Record<GameSummary['engine'], string> = {
story: 'Split clues, shared locks',
defuse: 'One sees it, one has the manual',
sync: 'How well do you know each other?',
labyrinth: 'One map, one explorer',
};
/** A library card, themed with its game's own colours. Opens the "how do you want to play" sheet. */
export function GameCard({ game, className = '' }: { game: GameSummary; className?: string }) {
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
const navigate = useNavigate();
const best = getBest(game.id);
const start = async (mode: PlayMode) => {
setCreating(true);
const res = await request('room:create', { gameId: game.id, mode });
setCreating(false);
if (!res.ok) return pushToast(res.message, 'error');
saveSession(res.code, res.playerToken);
navigate(`/room/${res.code}`);
};
return (
<div style={themeVars(game.theme)} className={className}>
<button
onClick={() => setOpen(true)}
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="text-xs text-stone-300">⏱ {game.estimatedMinutes} min</span>
<span className="text-xs" aria-label={`Difficulty ${game.difficulty} of 3`}>
{difficultyKeys(game.difficulty)}
<span className="opacity-25 grayscale">{difficultyKeys(3 - game.difficulty)}</span>
</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>
<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>
</div>
</button>
<Sheet open={open} onClose={() => setOpen(false)} title={game.title}>
<p className="text-stone-300">{game.tagline}</p>
<div className="mt-4 grid gap-2 sm:grid-cols-2">
{(['A', 'B'] as const).map((role) => (
<div key={role} className="card !p-3">
<RoleBadge role={role} name={game.roles[role].name} />
<p className="mt-2 text-sm text-stone-300">{game.roles[role].blurb}</p>
</div>
))}
</div>
<h4 className="mt-6 text-xs font-bold uppercase tracking-widest text-accent">How are you playing?</h4>
<div className="mt-2 grid gap-2">
<button className="btn-primary justify-start py-3 text-left" disabled={creating} onClick={() => start('duo')}>
<span className="text-2xl" aria-hidden>📱📱</span>
<span>
Two devices
<span className="block text-xs font-normal opacity-80">One phone each. You'll get a code to share.</span>
</span>
</button>
<button className="btn justify-start py-3 text-left" disabled={creating} onClick={() => start('couch')}>
<span className="text-2xl" aria-hidden>🛋️</span>
<span>
Couch mode
<span className="block text-xs font-normal opacity-80">One device, passed back and forth. No peeking.</span>
</span>
</button>
</div>
</Sheet>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { useState } from 'react';
import 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. */
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;
const reveal = () => {
setConfirming(false);
send('hint:request', { lockId: lock.id });
};
return (
<>
<button
type="button"
className={`btn shrink-0 px-3 ${lock.nudge ? 'border-accent text-accent motion-safe:animate-pulse' : ''}`}
onClick={() => setOpen(true)}
aria-label={`Hints, ${used} of ${lock.hintsTotal} revealed`}
>
💡<span className="text-xs tabular-nums">{used}/{lock.hintsTotal}</span>
</button>
<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.
</p>
<ol className="space-y-2">
{lock.hints.map((hint, i) => (
<li key={i} className="card">
<div className="text-xs font-bold uppercase tracking-wider text-accent">Hint {i + 1}</div>
<p className="mt-1">{hint}</p>
</li>
))}
</ol>
{left === 0 ? (
<p className="mt-4 text-sm text-stone-400">That's every hint for this lock.</p>
) : confirming ? (
<div className="mt-4 rounded-2xl border border-accent/50 bg-accent/10 p-4">
<p className="font-semibold">This basically solves it. Sure?</p>
<div className="mt-3 flex gap-2">
<button className="btn-primary" onClick={reveal}>Yes, reveal it</button>
<button className="btn" onClick={() => setConfirming(false)}>Keep thinking</button>
</div>
</div>
) : (
<button className="btn-primary mt-4 w-full" onClick={() => (nextIsLast ? setConfirming(true) : reveal())}>
Reveal hint {used + 1} of {lock.hintsTotal} (+2:00)
</button>
)}
</Sheet>
</>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { Sheet } from './ui';
/** Keys earned so far. A new key drops onto the ring with a little flourish. */
export function KeyRing({ keys, total }: { keys: string[]; total: number }) {
const [open, setOpen] = useState(false);
return (
<>
<button
className="flex min-h-[36px] items-center gap-1 rounded-full border border-accent/40 bg-black/30 px-2.5 py-1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
onClick={() => setOpen(true)}
aria-label={`Key ring: ${keys.length} of ${total} keys`}
>
{/* A long ring collapses to one key and the count on narrow phones, leaving room for the partner's name. */}
{total > 3 && <span className="text-sm min-[440px]:hidden" aria-hidden>🔑</span>}
{Array.from({ length: total }, (_, i) => (
<span key={i} className={`relative h-5 w-4 text-sm leading-5 ${total > 3 ? 'hidden min-[440px]:inline-block' : 'inline-block'}`}>
<span className="opacity-20 grayscale" aria-hidden>🔑</span>
<AnimatePresence>
{i < keys.length && (
<motion.span
aria-hidden
className="absolute inset-0"
initial={{ y: -40, scale: 2.4, rotate: -90, opacity: 0 }}
animate={{ y: 0, scale: 1, rotate: 0, opacity: 1 }}
transition={{ type: 'spring', stiffness: 260, damping: 16 }}
>
🔑
</motion.span>
)}
</AnimatePresence>
</span>
))}
<span className="ml-1 text-xs font-semibold tabular-nums text-accent">
{keys.length}/{total}
</span>
</button>
<Sheet open={open} onClose={() => setOpen(false)} title="Key ring">
{keys.length === 0 ? (
<p className="text-stone-400">No keys yet. Every lock you open together earns one.</p>
) : (
<ul className="space-y-2">
{keys.map((name, i) => (
<li key={i} className="card flex items-center gap-3">
<span className="text-2xl" aria-hidden>🔑</span>
<span className="font-semibold">{name}</span>
</li>
))}
</ul>
)}
<p className="mt-4 text-sm text-stone-400">
{keys.length} of {total} keys collected.
</p>
</Sheet>
</>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useState, type FormEvent } from 'react';
import type { LockView } from '@two-keys/shared';
import { socket, type Send } from '../lib/socket';
import { HintButton } from './HintButton';
/** The shared lock: both players see it, either can submit. Only the server knows the answer. */
export function LockPanel({ lock, send }: { lock: LockView; send: Send }) {
const [value, setValue] = useState('');
const [busy, setBusy] = useState(false);
const [wrong, setWrong] = useState(false);
const [shake, setShake] = useState(false);
useEffect(() => {
const onResult = ({ lockId, correct }: { lockId: string; correct: boolean }) => {
if (lockId !== lock.id) return;
setBusy(false);
setWrong(!correct);
if (!correct) setShake(true);
};
const onError = () => setBusy(false);
socket.on('lock:result', onResult);
socket.on('error', onError);
return () => {
socket.off('lock:result', onResult);
socket.off('error', onError);
};
}, [lock.id]);
const submit = (e: FormEvent) => {
e.preventDefault();
if (!value.trim() || busy) return;
setBusy(true);
setWrong(false);
send('lock:submit', { lockId: lock.id, value });
};
const numeric = lock.inputType === 'code' && /digit|number/i.test(lock.format ?? '');
return (
<section
aria-label="Lock"
className={`${lock.inputType === 'choice' ? '' : 'sticky bottom-0 z-10'} -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`}
>
<form onSubmit={submit} onAnimationEnd={() => setShake(false)} className={`mx-auto max-w-2xl ${shake ? 'shake' : ''}`}>
<label htmlFor={`lock-${lock.id}`} className="flex items-start gap-2 font-semibold leading-snug">
<span aria-hidden>🔒</span>
<span>{lock.prompt}</span>
</label>
{lock.inputType === 'choice' && lock.options ? (
<div role="radiogroup" aria-label={lock.prompt} className="mt-2 flex flex-wrap gap-2">
{lock.options.map((o) => (
<label
key={o.id}
className={`cursor-pointer rounded-xl border px-3 py-2 text-sm font-medium transition focus-within:ring-2 focus-within:ring-accent ${
value === o.id ? 'border-accent bg-accent/20' : 'border-white/20 bg-black/30 hover:bg-white/10'
}`}
>
<input
type="radio"
name={`lock-${lock.id}`}
className="sr-only"
checked={value === o.id}
onChange={() => setValue(o.id)}
/>
{o.label}
</label>
))}
</div>
) : null}
<div className="mt-2 flex gap-2">
{lock.inputType !== 'choice' && (
<input
id={`lock-${lock.id}`}
className={`field ${lock.inputType === 'code' ? 'font-mono tracking-[0.2em]' : ''}`}
value={value}
onChange={(e) => setValue(e.target.value)}
inputMode={numeric ? 'numeric' : 'text'}
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
maxLength={80}
placeholder={lock.format ?? 'Your answer'}
aria-describedby={`lock-${lock.id}-help`}
/>
)}
<button type="submit" className="btn-primary shrink-0" disabled={!value.trim() || busy}>
{busy ? '…' : 'Unlock'}
</button>
<HintButton lock={lock} send={send} />
</div>
<p id={`lock-${lock.id}-help`} className="mt-1.5 min-h-[1.25rem] text-xs text-stone-400" aria-live="polite">
{wrong ? (
<span className="font-semibold text-red-300">
Not quite. {lock.nudge ? 'Need a hint? 💡' : 'Compare notes and try again.'}
</span>
) : lock.nudge ? (
<span className="font-semibold text-accent">Stuck? Need a hint? 💡</span>
) : lock.format && lock.inputType !== 'choice' ? (
<>Format: {lock.format}</>
) : (
<>Either of you can answer. You both need to agree first!</>
)}
</p>
</form>
</section>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useRef, useState } from 'react';
import { socket } from '../lib/socket';
import { Sheet } from './ui';
const DEBOUNCE_MS = 300;
/** A notepad both players share, synced live. Last write wins. */
export function Notepad({ text }: { text: string }) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(text);
const timer = useRef<ReturnType<typeof setTimeout>>();
const dirty = useRef(false);
// Take the partner's edits unless we have unsent typing of our own.
useEffect(() => {
if (!dirty.current) setDraft(text);
}, [text]);
const flush = (value: string) => {
clearTimeout(timer.current);
if (!dirty.current) return;
dirty.current = false;
socket.emit('notepad:update', { text: value });
};
const onChange = (value: string) => {
setDraft(value);
dirty.current = true;
clearTimeout(timer.current);
timer.current = setTimeout(() => flush(value), DEBOUNCE_MS);
};
return (
<>
<button
className="flex min-h-[36px] items-center gap-1 rounded-full border border-white/15 bg-black/30 px-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
onClick={() => setOpen(true)}
aria-label="Open the shared notepad"
>
📝<span className="hidden sm:inline">Notes</span>
{text.trim() !== '' && <span className="h-1.5 w-1.5 rounded-full bg-accent" aria-hidden />}
</button>
<Sheet
open={open}
onClose={() => {
flush(draft);
setOpen(false);
}}
title="Shared notepad"
>
<p className="mb-2 text-sm text-stone-400">You both see this, live. Jot down times, names, half-baked theories.</p>
<textarea
className="field min-h-[40dvh] font-hand text-xl leading-7"
value={draft}
onChange={(e) => onChange(e.target.value)}
onBlur={() => flush(draft)}
maxLength={5000}
placeholder="Our notes…"
aria-label="Shared notes"
/>
</Sheet>
</>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { PING_EMOJIS, type Role, type RoomState } from '@two-keys/shared';
import { socket } from '../lib/socket';
import { ROLE_TEXT } from '../lib/theme';
/** Partner's online dot, what they're looking at, and the ping buttons. */
export function PartnerStatus({ state }: { state: RoomState }) {
const [open, setOpen] = useState(false);
const partner = state.partner;
const name = partner?.seat ? state.game.roles[partner.seat].name : 'Partner';
return (
<div className="flex min-w-0 items-center gap-1.5">
<button
className="flex min-h-[36px] min-w-0 items-center gap-1.5 rounded-full border border-white/15 bg-black/30 px-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-label={`${name} is ${partner?.online ? 'online' : 'offline'}. Send a ping.`}
>
<span
aria-hidden
className={`h-2 w-2 shrink-0 rounded-full ${partner?.online ? 'bg-emerald-400 shadow-[0_0_6px_#34d399]' : 'bg-stone-500'}`}
/>
<span className={`truncate font-medium ${partner?.seat ? ROLE_TEXT[partner.seat] : ''}`}>{name}</span>
<span aria-hidden>👋</span>
</button>
{open && (
// Anchored to the header bar (not the button) so it always fits a narrow screen.
<div className="absolute inset-x-4 top-full z-30 mt-2 max-w-xs rounded-2xl border border-white/15 bg-bg p-3 shadow-2xl">
<p className="text-xs text-stone-400">
{partner?.online ? 'Online' : 'Offline. They can rejoin with the same link.'}
{partner?.online && partner.viewing ? ` · looking at ${partner.viewing}` : ''}
</p>
<div className="mt-2 flex gap-2">
{PING_EMOJIS.map((emoji) => (
<button
key={emoji}
className="btn flex-1 px-0 text-xl"
disabled={!partner?.online}
aria-label={`Send ${emoji}`}
onClick={() => {
socket.emit('ping', { emoji });
setOpen(false);
}}
>
{emoji}
</button>
))}
</div>
</div>
)}
</div>
);
}
interface Ping {
id: number;
emoji: string;
from: Role;
x: number;
}
/** Reactions from the partner float up the screen. */
export function PingOverlay({ roles }: { roles: RoomState['game']['roles'] }) {
const [pings, setPings] = useState<Ping[]>([]);
useEffect(() => {
let nextId = 1;
const onPing = ({ from, emoji }: { from: Role; emoji: string }) => {
const id = nextId++;
setPings((p) => [...p, { id, emoji, from, x: 15 + Math.random() * 70 }]);
setTimeout(() => setPings((p) => p.filter((x) => x.id !== id)), 2600);
};
socket.on('ping', onPing);
return () => {
socket.off('ping', onPing);
};
}, []);
return (
<div className="pointer-events-none fixed inset-0 z-50 overflow-hidden" aria-live="polite">
<AnimatePresence>
{pings.map((p) => (
<motion.div
key={p.id}
className="absolute bottom-24 flex flex-col items-center"
style={{ left: `${p.x}%` }}
initial={{ y: 0, opacity: 0, scale: 0.5 }}
animate={{ y: -260, opacity: 1, scale: 1.4 }}
exit={{ opacity: 0 }}
transition={{ duration: 2.2, ease: 'easeOut' }}
>
<span className="text-5xl">{p.emoji}</span>
<span className={`mt-1 rounded-full bg-black/70 px-2 text-[10px] font-semibold ${ROLE_TEXT[p.from]}`}>
{roles[p.from].name}
</span>
</motion.div>
))}
</AnimatePresence>
</div>
);
}
@@ -0,0 +1,28 @@
import type { Role } from '@two-keys/shared';
import { ROLE_TEXT } from '../lib/theme';
/** Couch mode: covers the screen while the device changes hands. */
export function PassDeviceScreen({
role,
roleName,
onReveal,
}: {
role: Role;
roleName: string;
onReveal: () => void;
}) {
return (
<div className="fixed inset-0 z-40 flex flex-col items-center justify-center gap-6 bg-bg px-6 text-center">
<div className="text-6xl" aria-hidden>
🙈
</div>
<h2 className="font-display text-3xl font-bold leading-tight">
Pass to <span className={ROLE_TEXT[role]}>{roleName}</span> 👀
</h2>
<p className="text-stone-400">(no peeking)</p>
<button className="btn-primary px-8 py-3 text-lg" onClick={onReveal} autoFocus>
I'm {roleName}. Tap to reveal
</button>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from 'react';
import { Link, NavLink } from 'react-router-dom';
const navClass = ({ isActive }: { isActive: boolean }) =>
`flex min-h-[44px] items-center rounded-lg px-3 text-sm font-medium transition hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
isActive ? 'text-accent' : 'text-stone-300'
}`;
export function SiteShell({ children }: { children: ReactNode }) {
return (
<div className="mx-auto flex min-h-dvh w-full max-w-5xl flex-col px-4">
<header className="flex items-center justify-between gap-2 py-2">
<Link to="/" className="flex min-h-[44px] items-center gap-1.5 font-display text-xl font-bold tracking-tight focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent">
Two Keys <span aria-hidden>🔑🔑</span>
</Link>
<nav className="flex items-center" aria-label="Main">
<NavLink to="/games" className={navClass}>Games</NavLink>
<NavLink to="/how-to-play" className={navClass}>How to play</NavLink>
</nav>
</header>
<main className="flex-1 pb-16">{children}</main>
<footer className="border-t border-white/10 py-6 text-center text-xs text-stone-500">
Two players. Different clues. Every lock needs both of you.
</footer>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { formatClock, type RoomState } from '@two-keys/shared';
import { useNow } from '../lib/hooks';
/** Elapsed time plus penalties. 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;
return (
<div
className="flex items-baseline gap-1.5 font-mono tabular-nums"
role="timer"
aria-label={`Time ${formatClock(elapsed + timer.penaltySeconds)}`}
>
<span className="text-base font-bold">{formatClock(elapsed + timer.penaltySeconds)}</span>
{timer.penaltySeconds > 0 && (
<span className="text-[11px] text-red-300" title="Penalty time from hints and traps">
+{formatClock(timer.penaltySeconds)}
</span>
)}
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { Role } from '@two-keys/shared';
import { useToasts } from '../lib/toasts';
import { ROLE_BG, ROLE_BORDER, ROLE_TEXT } from '../lib/theme';
export function Markdown({ children, className = '' }: { children: string; className?: string }) {
return (
<div className={`md ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Wide tables scroll inside their card instead of stretching the page.
table: ({ node: _node, ...props }) => (
<div className="scroll-x">
<table {...props} />
</div>
),
}}
>
{children}
</ReactMarkdown>
</div>
);
}
export function RoleBadge({ role, name, className = '' }: { role: Role; name: string; className?: string }) {
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-semibold ${ROLE_BORDER[role]} ${ROLE_BG[role]} ${ROLE_TEXT[role]} ${className}`}
>
<span aria-hidden className="h-1.5 w-1.5 rounded-full bg-current" />
{name}
</span>
);
}
function useEscape(onClose: () => void, active: boolean) {
useEffect(() => {
if (!active) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [active, onClose]);
}
/** A panel that slides up from the bottom (a centred dialog on wide screens). */
export function Sheet({
open,
onClose,
title,
children,
}: {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}) {
const panel = useRef<HTMLDivElement>(null);
useEscape(onClose, open);
useEffect(() => {
if (open) panel.current?.focus();
}, [open]);
// Rendered on <body>: the sticky bars use backdrop-filter, which would otherwise trap a fixed overlay inside them.
return createPortal(
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-40 flex items-end justify-center sm:items-center">
<motion.div
className="absolute inset-0 bg-black/70"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
ref={panel}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
className="relative flex max-h-[85dvh] w-full max-w-2xl flex-col rounded-t-3xl border border-white/15 bg-bg shadow-2xl outline-none sm:rounded-3xl"
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'tween', duration: 0.25 }}
>
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-3">
<h2 className="font-display text-xl font-bold">{title}</h2>
<button className="btn !min-h-[36px] px-3 py-1 text-sm" onClick={onClose}>
Close
</button>
</div>
<div className="overflow-y-auto p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">{children}</div>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body,
);
}
export function Toasts() {
const toasts = useToasts();
return (
<div
aria-live="polite"
className="pointer-events-none fixed inset-x-0 top-3 z-50 flex flex-col items-center gap-2 px-4"
>
<AnimatePresence>
{toasts.map((t) => (
<motion.div
key={t.id}
initial={{ opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className={`max-w-sm rounded-xl border px-4 py-2 text-sm font-medium shadow-xl backdrop-blur ${
t.kind === 'error'
? 'border-red-400/40 bg-red-950/90 text-red-100'
: 'border-accent/40 bg-black/85 text-stone-100'
}`}
>
{t.message}
</motion.div>
))}
</AnimatePresence>
</div>
);
}
export function Spinner({ label }: { label: string }) {
return (
<div className="flex min-h-[60dvh] flex-col items-center justify-center gap-4 px-6 text-center text-stone-300">
<div className="text-4xl motion-safe:animate-pulse" aria-hidden>
🔑🔑
</div>
<p>{label}</p>
</div>
);
}
+292
View File
@@ -0,0 +1,292 @@
import type {
ButtonModuleView,
DefuseAction,
DefuseView,
KeypadModuleView,
RoomState,
TechnicianView,
WiresModuleView,
} from '@two-keys/shared';
import { Markdown } from '../components/ui';
import { useNow } from '../lib/hooks';
import type { Send } from '../lib/socket';
interface Props {
view: DefuseView;
state: RoomState;
receivedAt: number;
send: Send;
}
// Colours always come with their name as text, so nothing depends on telling hues apart.
const SWATCH: Record<string, string> = {
red: '#e5484d',
blue: '#3b82f6',
yellow: '#facc15',
green: '#22c55e',
white: '#f5f5f4',
};
const darkText = (color: string) => color === 'yellow' || color === 'white';
function timerText(remaining: number): string {
const t = Math.max(0, Math.ceil(remaining));
return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`;
}
function useRemaining(view: DefuseView, receivedAt: number): number {
const now = useNow(200);
if (!view.clock) return view.level.timeSeconds;
if (!view.clock.running) return view.clock.remaining;
return Math.max(0, view.clock.remaining - ((now - receivedAt) / 1000) * view.clock.speed);
}
function StatusBar({ view, remaining }: { view: DefuseView; remaining: number }) {
const urgent = view.status === 'active' && remaining <= 30;
return (
<div className="sticky top-0 z-10 -mx-4 flex items-center justify-between gap-3 border-b border-white/10 bg-bg/95 px-4 py-2 backdrop-blur">
<div className="min-w-0">
<div className="text-[10px] font-bold uppercase tracking-widest text-accent">
Level {view.level.index + 1} of {view.level.total}
</div>
<div className="truncate font-display font-bold">{view.level.title}</div>
</div>
<div
className={`rounded-lg border-2 bg-black px-3 py-1 font-mono text-3xl font-bold tabular-nums ${
urgent ? 'border-red-500 text-red-400' : 'border-stone-700 text-red-500'
}`}
role="timer"
aria-label={`Bomb timer ${timerText(remaining)}`}
>
{timerText(remaining)}
</div>
<div className="text-right" aria-label={`${view.strikes} of ${view.maxStrikes} strikes`}>
<div className="text-[10px] font-bold uppercase tracking-widest text-stone-400">Strikes</div>
<div className="font-mono text-lg tracking-widest" aria-hidden>
{Array.from({ length: view.maxStrikes }, (_, i) => (
<span key={i} className={i < view.strikes ? 'text-red-500' : 'text-stone-600'}>
✖
</span>
))}
</div>
{view.clock && view.clock.speed > 1 && (
<div className="text-[10px] text-red-300">clock ×{view.clock.speed.toFixed(2)}</div>
)}
</div>
</div>
);
}
function ModuleFrame({ title, solved, children }: { title: string; solved: boolean; children: React.ReactNode }) {
return (
<section className={`rounded-2xl border-2 p-4 ${solved ? 'border-emerald-500/60 bg-emerald-500/10' : 'border-stone-600 bg-stone-900/70'}`}>
<div className="mb-3 flex items-center justify-between">
<h3 className="font-mono text-sm font-bold uppercase tracking-widest">{title}</h3>
<span className={`flex items-center gap-1.5 text-xs font-bold ${solved ? 'text-emerald-400' : 'text-stone-500'}`}>
<span className={`h-2.5 w-2.5 rounded-full ${solved ? 'bg-emerald-400' : 'bg-stone-600'}`} aria-hidden />
{solved ? 'DISARMED' : 'ARMED'}
</span>
</div>
{children}
</section>
);
}
function Wires({ module, act }: { module: WiresModuleView; act: (a: DefuseAction) => void }) {
return (
<ModuleFrame title="Wires" 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="w-16 font-mono text-sm font-bold uppercase">{color}</span>
<button
className="btn !min-h-[40px] px-3 text-sm"
disabled={module.solved}
onClick={() => act({ type: 'cut', index: i + 1 })}
aria-label={`Cut wire ${i + 1}, ${color}`}
>
✂ Cut
</button>
</li>
))}
</ol>
</ModuleFrame>
);
}
function BigButton({ module, act }: { module: ButtonModuleView; act: (a: DefuseAction) => void }) {
const bg = SWATCH[module.color] ?? module.color;
return (
<ModuleFrame title="The Button" solved={module.solved}>
<div className="flex items-center gap-4">
<div
className={`flex h-28 w-28 shrink-0 flex-col items-center justify-center rounded-full border-4 border-black/40 font-mono shadow-xl ${
module.holding ? 'scale-95 shadow-inner' : ''
} ${darkText(module.color) ? 'text-stone-900' : 'text-white'}`}
style={{ backgroundColor: bg }}
aria-label={`A ${module.color} button labelled ${module.label}`}
role="img"
>
<span className="text-xl font-bold">{module.label}</span>
<span className="text-[10px] font-bold uppercase opacity-80">{module.color} button</span>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
{module.holding ? (
<>
<div
className={`rounded-lg px-3 py-2 text-center font-mono text-sm font-bold uppercase ${darkText(module.strip ?? '') ? 'text-stone-900' : 'text-white'}`}
style={{ backgroundColor: SWATCH[module.strip ?? ''] ?? '#444' }}
aria-live="assertive"
>
Strip: {module.strip}
</div>
<button className="btn-primary" onClick={() => act({ type: 'release' })}>
Release now
</button>
</>
) : (
<>
<button className="btn" disabled={module.solved} onClick={() => act({ type: 'tap' })}>
Tap it
</button>
<button className="btn" disabled={module.solved} onClick={() => act({ type: 'hold' })}>
Press &amp; hold
</button>
</>
)}
</div>
</div>
</ModuleFrame>
);
}
function Keypad({ module, act }: { module: KeypadModuleView; act: (a: DefuseAction) => void }) {
return (
<ModuleFrame title="Hearts & Symbols" solved={module.solved}>
<div className="grid grid-cols-2 gap-2">
{module.symbols.map((s) => {
const [glyph, ...name] = s.label.split(' ');
return (
<button
key={s.id}
className={`flex flex-col items-center rounded-xl border-2 px-2 py-3 transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent ${
s.pressed ? 'border-emerald-500 bg-emerald-500/20' : 'border-stone-500 bg-stone-800 hover:bg-stone-700'
}`}
disabled={s.pressed || module.solved}
onClick={() => act({ type: 'press', symbol: s.id })}
aria-label={`${name.join(' ')}${s.pressed ? ', pressed' : ''}`}
>
<span className="text-3xl" aria-hidden>{glyph}</span>
<span className="mt-1 text-xs font-semibold">{name.join(' ')}{s.pressed ? ' ✓' : ''}</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;
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">
<div>
<div className="text-[10px] font-bold uppercase tracking-widest text-stone-400">Serial number</div>
<div className="text-2xl font-bold tracking-[0.15em]">{serial}</div>
</div>
<div>
<div className="text-[10px] font-bold uppercase tracking-widest text-stone-400">Batteries</div>
<div className="text-2xl font-bold">
{batteries} <span className="text-base" aria-hidden>{'🔋'.repeat(batteries)}</span>
</div>
</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)} />
),
)}
</div>
);
}
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;
let banner = null;
if (view.status === 'ready') {
banner = (
<div className="card text-center">
<div className="text-5xl" aria-hidden>💣</div>
<h2 className="mt-2 font-display text-2xl font-bold">
Level {view.level.index + 1}: {view.level.title}
</h2>
<p className="mt-1 text-stone-300">
{timerText(view.level.timeSeconds)} on the clock. {view.maxStrikes} 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}: serial number, batteries, every colour and label.`
: `You have the manual below. Ask the ${partner} for the serial number and battery count first.`}
</p>
<button className="btn-primary mt-4 w-full" onClick={() => send('chapter:continue')}>
Start the clock ⏱
</button>
</div>
);
} else if (view.status === 'exploded') {
banner = (
<div className="rounded-2xl border-2 border-red-500/70 bg-red-950/60 p-5 text-center" aria-live="assertive">
<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.
</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')}>
Try again with a new device
</button>
</div>
);
} else if (view.status === 'cleared') {
banner = (
<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>
<h2 className="mt-2 font-display text-2xl font-bold">Disarmed with {timerText(remaining)} to spare!</h2>
<button className="btn-primary mt-4 w-full" onClick={() => send('chapter:continue')}>
Next level →
</button>
</div>
);
}
return (
<div className="space-y-4">
<StatusBar view={view} remaining={remaining} />
{banner}
{view.seat === 'technician' && view.status !== 'ready' && (
<div className={view.status === 'active' ? '' : 'pointer-events-none opacity-50'}>
<Device view={view} send={send} />
</div>
)}
{view.seat === 'technician' && view.status === 'active' && (
<p className="text-center text-sm text-stone-400">You can't see the manual. Describe what you see out loud.</p>
)}
{view.seat === 'expert' && (
<article className="rounded-md bg-[#ecebe4] p-5 text-stone-900 shadow-lg">
<Markdown className="paper leading-7">{view.manual}</Markdown>
</article>
)}
</div>
);
}
+186
View File
@@ -0,0 +1,186 @@
import { useEffect } from 'react';
import type { Dir, ExplorerView, LabyrinthView, NavigatorView, RoomState } from '@two-keys/shared';
import type { Send } from '../lib/socket';
interface Props {
view: LabyrinthView;
state: RoomState;
send: Send;
}
/** "☀ A carved sun (the entrance)" → ["☀", "A carved sun (the entrance)"] */
const splitLabel = (label: string): [string, string] => {
const i = label.indexOf(' ');
return i === -1 ? [label, ''] : [label.slice(0, i), label.slice(i + 1)];
};
function Lanterns({ carried, total }: { carried: number; total: number }) {
return (
<div className="flex items-center gap-1" aria-label={`${carried} of ${total} lanterns carried`}>
{Array.from({ length: total }, (_, i) => (
<span key={i} aria-hidden className={`text-2xl ${i < carried ? '' : 'opacity-25 grayscale'}`}>
🏮
</span>
))}
</div>
);
}
// ---------- Navigator: the whole map, but not where the Explorer is ----------
function NavigatorMap({ view, explorerName }: { view: NavigatorView; explorerName: string }) {
const width = view.grid[0].length;
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]));
return (
<div className="space-y-4">
<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">
{view.moves !== null && <div>{view.moves} moves</div>}
{view.falls > 0 && <div className="text-red-300">{view.falls} trap{view.falls === 1 ? '' : 's'} sprung</div>}
</div>
</div>
<div
className="mx-auto grid w-full max-w-md gap-px overflow-hidden rounded-lg border-2 border-accent/40 bg-black/60 p-1"
style={{ gridTemplateColumns: `repeat(${width}, minmax(0, 1fr))` }}
role="img"
aria-label="Map of the labyrinth. The legend below lists every glyph, lantern, trap and the exit."
>
{view.grid.flatMap((row, y) =>
[...row].map((cell, x) => {
const key = `${x},${y}`;
const glyph = glyphAt.get(key);
const lantern = lanternAt.get(key);
let content = '';
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 (lantern) { content = '🏮'; tone = lantern.collected ? 'bg-stone-800/40 opacity-30 grayscale' : 'bg-amber-950'; }
else if (glyph) content = splitLabel(glyph.label)[0];
return (
<div key={key} className={`flex aspect-square items-center justify-center text-[clamp(11px,4vw,20px)] leading-none ${tone}`}>
{content}
</div>
);
}),
)}
</div>
<p className="text-center text-sm text-stone-400">
You can't see {explorerName}. Ask what's on the floor and which ways are open, then work out where they are.
</p>
<div className="card">
<h3 className="mb-2 text-xs font-bold uppercase tracking-widest text-accent">Map legend</h3>
<ul className="grid gap-x-4 gap-y-1 text-sm sm:grid-cols-2">
{view.glyphs.map((g) => {
const [symbol, text] = splitLabel(g.label);
return (
<li key={`${g.x},${g.y}`} className="flex gap-2">
<span className="w-6 text-center" aria-hidden>{symbol}</span>
{text}
</li>
);
})}
{view.lanterns.map((l) => (
<li key={l.name} className={`flex gap-2 ${l.collected ? 'text-stone-500 line-through' : ''}`}>
<span className="w-6 text-center" aria-hidden>🏮</span>
{l.name}
{l.collected && <span className="no-underline"> ✓</span>}
</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 text-stone-400"><span className="w-6 text-center" aria-hidden>▪</span>Most floor is bare stone</li>
</ul>
</div>
</div>
);
}
// ---------- Explorer: one cell, four directions ----------
const DIRS: { dir: Dir; label: string; arrow: string; area: string; keys: string[] }[] = [
{ 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'] },
];
function ExplorerCell({ view, send, navigatorName }: { view: ExplorerView; send: Send; navigatorName: string }) {
// 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));
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]);
const [symbol, text] = view.glyphLabel ? splitLabel(view.glyphLabel) : ['', ''];
const open = DIRS.filter((d) => view.open[d.dir]).map((d) => d.label.toLowerCase());
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<Lanterns carried={view.keysCarried} total={view.totalKeys} />
<div className="text-sm text-stone-300">{view.moves} moves</div>
</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"
aria-live="polite"
>
<div className="text-xs font-bold uppercase tracking-widest text-stone-400">Under your feet</div>
{view.glyphLabel ? (
<>
<div className="mt-2 text-7xl" aria-hidden>{symbol}</div>
<p className="mt-2 font-display text-2xl font-bold">{text}</p>
</>
) : (
<div className="mt-2 text-6xl opacity-60" aria-hidden>🕯️</div>
)}
{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>
</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>
</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!
</p>
</div>
);
}
export function LabyrinthGame({ view, state, send }: Props) {
return view.seat === 'navigator' ? (
<NavigatorMap view={view} explorerName={state.game.roles.B.name} />
) : (
<ExplorerCell view={view} send={send} navigatorName={state.game.roles.A.name} />
);
}
+262
View File
@@ -0,0 +1,262 @@
import { useEffect, 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';
import { ClueCard } from '../components/ClueCard';
import { LockPanel } from '../components/LockPanel';
import { Markdown, Sheet } from '../components/ui';
import { socket, type Send } from '../lib/socket';
interface Props {
view: StoryView;
state: RoomState;
role: Role;
send: Send;
}
function Clues({ clues }: { clues: ClueView[] }) {
return (
<div className="space-y-5">
{clues.map((clue) => (
<ClueCard key={clue.id} clue={clue} />
))}
</div>
);
}
/** The lock springs open, the key drops onto the ring, either player continues. */
function Unlocked({ lock, onContinue, last }: { lock: LockView; onContinue: () => void; last: boolean }) {
return (
<motion.section
className="rounded-2xl border border-accent/50 bg-accent/10 p-5"
initial={{ opacity: 0, scale: 0.94 }}
animate={{ opacity: 1, scale: 1 }}
aria-live="polite"
>
<div className="flex items-center gap-3">
<motion.span
className="text-4xl"
aria-hidden
initial={{ rotate: -30, scale: 0.4 }}
animate={{ rotate: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 10 }}
>
🔓
</motion.span>
<div>
<div className="text-xs font-bold uppercase tracking-widest text-accent">Unlocked</div>
{lock.keyName && <div className="font-display text-xl font-bold">🔑 {lock.keyName}</div>}
</div>
</div>
{lock.successText && <Markdown className="mt-4 leading-7">{lock.successText}</Markdown>}
<button className="btn-primary mt-5 w-full" onClick={onContinue}>
{last ? 'On to the finale →' : 'Continue →'}
</button>
</motion.section>
);
}
/** Both players must agree: one proposes, the other agrees or changes it. */
function Accusation({ finale, role, state, send }: { finale: AccusationView; role: Role; state: RoomState; send: Send }) {
const proposalKey = JSON.stringify(finale.proposal?.answers ?? null);
const [draft, setDraft] = useState<Record<string, string>>(finale.proposal?.answers ?? {});
// A new proposal (from either side) replaces whatever was being picked.
useEffect(() => {
if (finale.proposal) setDraft(finale.proposal.answers);
}, [proposalKey]); // eslint-disable-line react-hooks/exhaustive-deps
const complete = finale.questions.every((q) => draft[q.id]);
const matchesProposal =
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;
let action;
if (matchesProposal && finale.agreed) {
action = (
<button className="btn-primary w-full" onClick={() => send('finale:submit')}>
You both agree. Make the accusation ⚖️
</button>
);
} else if (matchesProposal && mine) {
action = (
<button className="btn w-full" disabled>
Waiting for {partnerName} to agree…
</button>
);
} else if (matchesProposal) {
action = (
<div className="space-y-2">
<button className="btn-primary w-full" onClick={() => send('finale:agree')}>
Agree with {partnerName} ✓
</button>
<p className="text-center text-xs text-stone-400">Disagree? Change any answer above to propose your own.</p>
</div>
);
} else {
action = (
<button className="btn-primary w-full" disabled={!complete} onClick={() => send('finale:propose', { answers: draft })}>
{finale.proposal ? 'Propose this instead' : 'Propose this accusation'}
</button>
);
}
return (
<section className="space-y-5">
{finale.questions.map((q) => (
<fieldset key={q.id} className="card">
<legend className="float-left mb-3 w-full font-display text-xl font-bold">{q.label}</legend>
<div className="clear-both space-y-2">
{q.options.map((o) => {
const picked = draft[q.id] === o.id;
const proposed = finale.proposal?.answers[q.id] === o.id;
return (
<label
key={o.id}
className={`flex cursor-pointer items-center gap-3 rounded-xl border px-3 py-2.5 transition focus-within:ring-2 focus-within:ring-accent ${
picked ? 'border-accent bg-accent/20' : 'border-white/15 bg-black/20 hover:bg-white/10'
}`}
>
<input
type="radio"
name={q.id}
className="h-4 w-4 accent-[rgb(var(--accent))]"
checked={picked}
onChange={() => setDraft((d) => ({ ...d, [q.id]: o.id }))}
/>
<span className="flex-1">{o.label}</span>
{proposed && !picked && <span className="chip">proposed</span>}
</label>
);
})}
</div>
</fieldset>
))}
<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.proposal && !finale.agreed && ` · ${mine ? 'You' : partnerName} proposed an accusation`}
</p>
{action}
</div>
</section>
);
}
function EvidenceLocker({ view, chapters }: { view: StoryView; chapters: ChapterView[] }) {
return (
<div className="space-y-6">
{view.suspects.length > 0 && (
<section>
<h3 className="mb-2 text-xs font-bold uppercase tracking-widest text-accent">Suspects</h3>
<ul className="space-y-2">
{view.suspects.map((s) => (
<li key={s.id} className="card !p-3">
<div className="font-semibold">
{s.name} <span className="font-normal text-stone-400">· {s.role}</span>
</div>
<p className="mt-0.5 text-sm text-stone-300">{s.description}</p>
</li>
))}
</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>
))}
</div>
);
}
export function StoryGame({ view, state, role, send }: Props) {
const [lockerOpen, setLockerOpen] = useState(false);
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);
// Tell the partner what we're looking at.
useEffect(() => {
if (state.mode === 'duo') socket.emit('presence:view', { label: lockerOpen ? 'the evidence locker' : stageLabel });
}, [lockerOpen, stageLabel, state.mode]);
// Braces matter: newer browsers return a promise from scrollTo, and an effect must not return one.
useEffect(() => {
window.scrollTo({ top: 0 });
}, [view.currentChapter, atFinale]);
const heading = (
<div>
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold uppercase tracking-widest text-accent">
{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)})` : ''}
</button>
</div>
<div className="mt-1">
<h2 className="font-display text-3xl font-bold leading-tight">
{chapter ? chapter.title : view.finale?.type === 'accusation' ? 'The Accusation' : 'The Way Out'}
</h2>
</div>
</div>
);
return (
<div className="space-y-5">
{heading}
{chapter && (
<>
<Markdown className="text-lg leading-8 text-stone-200">{chapter.narrative}</Markdown>
<Clues clues={chapter.clues} />
{chapter.lock.solved && view.awaitingContinue ? (
<Unlocked
lock={chapter.lock}
last={view.currentChapter === view.totalChapters - 1}
onContinue={() => send('chapter:continue')}
/>
) : (
<LockPanel key={chapter.lock.id} lock={chapter.lock} send={send} />
)}
</>
)}
{view.finale?.type === 'accusation' && (
<>
<Markdown className="text-lg leading-8 text-stone-200">{view.finale.prompt}</Markdown>
<Accusation finale={view.finale} role={role} state={state} send={send} />
</>
)}
{view.finale?.type === 'escape' && (
<>
<Markdown className="text-lg leading-8 text-stone-200">{view.finale.prompt}</Markdown>
<Clues clues={view.finale.clues} />
<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} />
</Sheet>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { motion } from 'framer-motion';
import type { Role, RoomState, SyncView } from '@two-keys/shared';
import { otherRole } from '@two-keys/shared';
import { RoleBadge } from '../components/ui';
import type { Send } from '../lib/socket';
interface Props {
view: SyncView;
state: RoomState;
role: Role;
send: Send;
}
function Marks({ view }: { view: SyncView }) {
return (
<div className="flex items-center justify-center gap-2" aria-label={`${view.points} matches so far, ${view.pointsForKey} needed for the key`}>
{Array.from({ length: view.questionsPerRound }, (_, i) => (
<span
key={i}
aria-hidden
className={`flex h-7 w-7 items-center justify-center rounded-full border text-sm ${
view.marks[i] === true
? 'border-accent bg-accent text-stone-950'
: view.marks[i] === false
? 'border-white/20 bg-white/5 text-stone-500'
: 'border-white/15 text-transparent'
}`}
>
{view.marks[i] === true ? '♥' : view.marks[i] === false ? '✕' : '·'}
</span>
))}
</div>
);
}
/** 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 }) {
return (
<div className="flex-1 [perspective:800px]">
<motion.div
className="card flex min-h-[7rem] flex-col items-center justify-center gap-2 text-center"
initial={{ rotateY: 180, opacity: 0.4 }}
animate={{ rotateY: 0, opacity: 1 }}
transition={{ duration: 0.6, delay }}
>
<RoleBadge role={role} name={name} />
<div className="font-display text-lg font-bold leading-snug">{answer}</div>
</motion.div>
</div>
);
}
export function SyncGame({ view, state, role, send }: Props) {
const roles = state.game.roles;
const partnerName = roles[otherRole(role)].name;
const couch = state.mode === 'couch';
const header = (
<div className="text-center">
<div className="text-xs font-bold uppercase tracking-widest text-accent">
Round {view.round.index + 1} of {view.round.total}
{view.isReplay && ' · Replay'}
</div>
<h2 className="font-display text-3xl font-bold">{view.round.title}</h2>
</div>
);
if (view.status === 'intro') {
return (
<div className="space-y-5 text-center">
{header}
<p className="text-lg text-stone-200">{view.round.blurb}</p>
<p className="text-sm text-stone-400">
{view.questionsPerRound} questions. Match on {view.pointsForKey} to earn the key. Answer privately, no peeking.
</p>
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
We're ready 💞
</button>
</div>
);
}
if (view.status === 'summary' && view.summary) {
const { earned, canReplay, keyName } = view.summary;
const last = view.round.index === view.round.total - 1;
return (
<div className="space-y-5 text-center" aria-live="polite">
{header}
<Marks view={view} />
<motion.div initial={{ scale: 0.6, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} className="text-6xl" aria-hidden>
{earned ? '🔑' : '🙃'}
</motion.div>
<p className="font-display text-2xl font-bold">
{view.points} out of {view.questionsPerRound}
</p>
<p className="text-stone-300">
{earned
? `In sync! You earned the ${keyName}.`
: canReplay
? `Not quite ${view.pointsForKey}. You get one replay with fresh questions.`
: 'No key for this round. Something to talk about over dessert.'}
</p>
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
{!earned && canReplay ? 'Replay this round ↻' : last ? 'See how we did →' : 'Next round →'}
</button>
</div>
);
}
const q = view.question;
if (!q) return null;
const predict = view.round.type === 'predict';
const subjectName = view.round.subject ? roles[view.round.subject].name : '';
return (
<div className="space-y-5">
{header}
<Marks view={view} />
<div className="card text-center">
<div className="text-xs font-semibold uppercase tracking-widest text-stone-400">
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>
)}
</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>
<p className="text-center text-sm text-stone-400" aria-live="polite">
{view.myAnswer !== null
? couch
? `Locked in. Pass the device to ${partnerName}.`
: `Locked in. Waiting for ${partnerName}…`
: view.partnerAnswered
? `${partnerName} has answered. Your turn!`
: 'Your answer stays hidden until you have both answered.'}
</p>
</>
)}
{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} />
</div>
<motion.p
className="text-center font-display text-2xl font-bold"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8 }}
>
{view.reveal.match ? '💞 In sync!' : '🙈 Not this time'}
</motion.p>
<button className="btn-primary w-full" onClick={() => send('chapter:continue')}>
{view.questionNumber < view.questionsPerRound ? 'Next question →' : 'See the round result →'}
</button>
</div>
)}
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
/* Defaults; a room overrides these from its game's YAML theme. */
--primary: 123 30 58;
--accent: 232 176 74;
--bg: 18 12 14;
--heading-font: 'Playfair Display';
color-scheme: dark;
}
html,
body,
#root {
min-height: 100%;
}
body {
@apply bg-bg font-sans text-stone-100 antialiased;
overflow-x: hidden;
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, rgb(var(--primary) / 0.35), transparent);
background-attachment: fixed;
transition: background-color 0.4s;
}
@layer components {
.btn {
@apply inline-flex min-h-[44px] items-center justify-center gap-2 rounded-xl border border-white/15 bg-white/5 px-4 py-2 font-semibold text-stone-100 transition hover:bg-white/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-40;
}
.btn-primary {
@apply inline-flex min-h-[44px] items-center justify-center gap-2 rounded-xl bg-accent px-4 py-2 font-semibold text-stone-950 shadow-lg shadow-black/30 transition hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white disabled:cursor-not-allowed disabled:opacity-40;
}
.card {
@apply rounded-2xl border border-white/10 bg-white/[0.04] p-4;
}
.chip {
@apply inline-flex items-center gap-1 rounded-full border border-white/15 bg-black/30 px-2.5 py-0.5 text-xs font-medium uppercase tracking-wide;
}
.field {
@apply min-h-[44px] w-full rounded-xl border border-white/20 bg-black/40 px-3 py-2 text-base text-stone-100 placeholder:text-stone-500 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/40;
}
}
/* ---------- Markdown ---------- */
.md {
--line: rgb(255 255 255 / 0.18);
overflow-wrap: anywhere;
}
.md.paper {
--line: rgb(0 0 0 / 0.22);
}
.md > * + * {
margin-top: 0.7em;
}
.md h1,
.md h2,
.md h3 {
font-family: var(--heading-font), 'Playfair Display', serif;
font-weight: 700;
line-height: 1.2;
}
.md h1 {
font-size: 1.5em;
}
.md h2 {
font-size: 1.3em;
letter-spacing: 0.04em;
}
.md ul {
list-style: disc;
padding-left: 1.2em;
}
.md ol {
list-style: decimal;
padding-left: 1.4em;
}
.md li + li {
margin-top: 0.25em;
}
.md blockquote {
border-left: 3px solid rgb(var(--accent) / 0.7);
padding-left: 0.9em;
opacity: 0.92;
}
.md hr {
border-color: var(--line);
}
.md table {
border-collapse: collapse;
font-size: 0.9em;
white-space: nowrap;
}
.md th,
.md td {
border: 1px solid var(--line);
padding: 0.3em 0.6em;
text-align: left;
}
.md th:empty {
display: none;
}
.md pre {
overflow-x: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 0.9em;
line-height: 1.5;
white-space: pre;
}
.md pre code {
font-size: inherit;
}
.md code {
font-family: 'JetBrains Mono', monospace;
font-size: 0.92em;
}
.md .scroll-x {
overflow-x: auto;
max-width: 100%;
}
/* ---------- Signal lamp ---------- */
.lamp {
transition: background-color 40ms, box-shadow 40ms;
background-color: #2a0e0e;
box-shadow: inset 0 0 12px rgb(0 0 0 / 0.8);
}
.lamp.on {
background-color: #ff4d3d;
box-shadow: 0 0 28px 10px rgb(255 77 61 / 0.65), inset 0 0 10px rgb(255 220 200 / 0.8);
}
@keyframes shake {
20%,
60% {
transform: translateX(-6px);
}
40%,
80% {
transform: translateX(6px);
}
}
.shake {
animation: shake 0.35s;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+89
View File
@@ -0,0 +1,89 @@
import { useEffect, useState } from 'react';
import type { GameSummary, RoomState } from '@two-keys/shared';
import { getToken, saveSession } from './session';
import { request, socket } from './socket';
/** Re-renders every `ms` and returns the current time. */
export function useNow(ms = 1000): number {
const [now, setNow] = useState(Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), ms);
return () => clearInterval(id);
}, [ms]);
return now;
}
let gamesCache: Promise<GameSummary[]> | null = null;
export function useGames(): GameSummary[] | null {
const [games, setGames] = useState<GameSummary[] | null>(null);
useEffect(() => {
gamesCache ??= fetch('/api/games').then((r) => {
if (!r.ok) throw new Error('failed to load games');
return r.json();
});
let alive = true;
gamesCache.then((g) => alive && setGames(g)).catch(() => {
gamesCache = null;
if (alive) setGames([]);
});
return () => {
alive = false;
};
}, []);
return games;
}
export interface RoomSnapshot {
state: RoomState;
/** Local time the state arrived, for running clocks between updates. */
receivedAt: number;
}
// One join per room at a time, shared between effect runs (StrictMode mounts twice).
const joins = new Map<string, ReturnType<typeof request>>();
function join(code: string) {
let pending = joins.get(code);
if (!pending) {
pending = request('room:join', { code, playerToken: getToken(code) }).then((res) => {
joins.delete(code);
if (res.ok) saveSession(res.code, res.playerToken);
return res;
});
joins.set(code, pending);
}
return pending;
}
/** Joins (or rejoins) a room and keeps its sanitized state up to date. */
export function useRoom(code: string) {
const [snapshot, setSnapshot] = useState<RoomSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState(socket.connected);
useEffect(() => {
let alive = true;
const onConnect = () => {
setConnected(true);
join(code).then((res) => alive && setError(res.ok ? null : res.message));
};
const onDisconnect = () => setConnected(false);
const onState = (state: RoomState) => {
if (state.code === code) setSnapshot({ state, receivedAt: Date.now() });
};
socket.on('connect', onConnect);
socket.on('disconnect', onDisconnect);
socket.on('room:state', onState);
if (socket.connected) onConnect();
return () => {
alive = false;
socket.off('connect', onConnect);
socket.off('disconnect', onDisconnect);
socket.off('room:state', onState);
};
}, [code]);
return { snapshot, error, connected };
}
+58
View File
@@ -0,0 +1,58 @@
// localStorage can throw (private mode, blocked storage), so every access is wrapped.
function read(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function write(key: string, value: string | null) {
try {
if (value === null) localStorage.removeItem(key);
else localStorage.setItem(key, value);
} catch {
/* play on without persistence */
}
}
const tokenKey = (code: string) => `twokeys:room:${code}`;
const LAST_ROOM = 'twokeys:last-room';
/** The seat token for a room, so a refresh rejoins the same seat. */
export const getToken = (code: string) => read(tokenKey(code));
export function saveSession(code: string, playerToken: string) {
write(tokenKey(code), playerToken);
write(LAST_ROOM, code);
}
export const getLastRoom = () => read(LAST_ROOM);
export const forgetLastRoom = () => write(LAST_ROOM, null);
export interface BestResult {
finalSeconds: number;
rank: string;
hintsUsed: number;
keysEarned: number;
}
export function getBest(gameId: string): BestResult | null {
try {
return JSON.parse(read(`twokeys:best:${gameId}`) ?? 'null');
} catch {
return null;
}
}
/** Keeps the best result per game: more keys first, then the faster time. */
export function saveBest(gameId: string, result: BestResult): boolean {
const best = getBest(gameId);
const better =
!best ||
result.keysEarned > best.keysEarned ||
(result.keysEarned === best.keysEarned && result.finalSeconds < best.finalSeconds);
if (better) write(`twokeys:best:${gameId}`, JSON.stringify(result));
return better;
}
+19
View File
@@ -0,0 +1,19 @@
import { io } from 'socket.io-client';
import type { JoinAck } from '@two-keys/shared';
import { pushToast } from './toasts';
export const socket = io({ transports: ['websocket', 'polling'] });
socket.on('toast', ({ message }: { message: string }) => pushToast(message));
socket.on('error', ({ message }: { message: string }) => pushToast(message, 'error'));
/** Emit an event that the server acknowledges (room:create, room:join). */
export function request(event: string, payload: object): Promise<JoinAck> {
return new Promise((resolve) => {
socket.timeout(8000).emit(event, payload, (err: Error | null, res: JoinAck) => {
resolve(err ? { ok: false, code: 'timeout', message: "Can't reach the server. Check your connection." } : res);
});
});
}
export type Send = (event: string, payload?: Record<string, unknown>) => void;
+45
View File
@@ -0,0 +1,45 @@
import { useEffect } from 'react';
import type { CSSProperties } from 'react';
import type { Category, 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 {
const n = parseInt(hex.slice(1), 16);
return `${(n >> 16) & 255} ${(n >> 8) & 255} ${n & 255}`;
}
export function themeVars(theme: Theme): CSSProperties {
return {
'--primary': hexToRgb(theme.primary),
'--accent': hexToRgb(theme.accent),
'--bg': hexToRgb(theme.background),
'--heading-font': `'${theme.headingFont}'`,
} as CSSProperties;
}
/** Applies a game's theme to the whole page while a room is open. */
export function useTheme(theme: Theme | undefined) {
const key = theme ? JSON.stringify(theme) : '';
useEffect(() => {
if (!key) return;
const root = document.documentElement;
const vars = themeVars(JSON.parse(key)) as Record<string, string>;
for (const [name, value] of Object.entries(vars)) root.style.setProperty(name, value);
return () => {
for (const name of Object.keys(vars)) root.style.removeProperty(name);
};
}, [key]);
}
export const CATEGORY_LABEL: Record<Category, string> = {
'murder-mystery': 'Murder Mystery',
'escape-room': 'Escape Room',
'co-op': 'Co-op',
};
/** 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);
+32
View File
@@ -0,0 +1,32 @@
import { useSyncExternalStore } from 'react';
export interface Toast {
id: number;
message: string;
kind: 'info' | 'error';
}
let toasts: Toast[] = [];
let nextId = 1;
const listeners = new Set<() => void>();
function set(next: Toast[]) {
toasts = next;
listeners.forEach((l) => l());
}
export function pushToast(message: string, kind: Toast['kind'] = 'info') {
const id = nextId++;
set([...toasts.slice(-2), { id, message, kind }]);
setTimeout(() => set(toasts.filter((t) => t.id !== id)), 4500);
}
export function useToasts(): Toast[] {
return useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
() => toasts,
);
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
+87
View File
@@ -0,0 +1,87 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { GameCard } from '../components/GameCard';
import { SiteShell } from '../components/SiteShell';
import { useGames } from '../lib/hooks';
import { getLastRoom } from '../lib/session';
function JoinForm() {
const [code, setCode] = useState('');
const navigate = useNavigate();
const submit = (e: FormEvent) => {
e.preventDefault();
const clean = code.trim().toUpperCase();
if (clean) navigate(`/join/${clean}`);
};
return (
<form onSubmit={submit} className="flex w-full max-w-xs gap-2">
<input
className="field text-center font-mono uppercase tracking-[0.3em]"
value={code}
onChange={(e) => setCode(e.target.value.replace(/[^a-zA-Z0-9]/g, '').slice(0, 5))}
placeholder="CODE"
aria-label="Room code from your partner"
autoCapitalize="characters"
autoComplete="off"
spellCheck={false}
/>
<button className="btn shrink-0" disabled={code.length < 5}>
Join
</button>
</form>
);
}
export function Home() {
const games = useGames();
const lastRoom = getLastRoom();
return (
<SiteShell>
<section className="flex flex-col items-center py-10 text-center sm:py-16">
<p className="chip text-accent">Date night, solved</p>
<h1 className="mt-5 max-w-3xl font-display text-4xl font-bold leading-[1.1] sm:text-6xl">
Two players. One mystery. <span className="text-accent">Every key needs both of you.</span>
</h1>
<p className="mt-5 max-w-xl text-lg leading-8 text-stone-300">
Co-op mysteries for couples. You each see different clues, so neither of you can crack it alone. Talk it
out, pool what you know, and open the locks together.
</p>
<div className="mt-8 flex w-full flex-col items-center gap-3">
<Link to="/games" className="btn-primary px-8 py-3 text-lg">
Pick a mystery
</Link>
<span className="text-sm text-stone-500">or join your partner's game</span>
<JoinForm />
{lastRoom && (
<Link to={`/room/${lastRoom}`} className="py-3 text-sm text-accent underline underline-offset-4">
Back to your last game ({lastRoom})
</Link>
)}
</div>
</section>
<section aria-labelledby="library">
<div className="mb-4 flex items-end justify-between">
<h2 id="library" className="font-display text-2xl font-bold">
The library
</h2>
<Link to="/how-to-play" className="-my-3 py-3 text-sm text-accent underline underline-offset-4">
How does it work?
</Link>
</div>
{games === null ? (
<p className="text-stone-400">Loading games…</p>
) : games.length === 0 ? (
<p className="text-red-300">Couldn't load the games. Is the server running?</p>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{games.map((game) => (
<GameCard key={game.id} game={game} />
))}
</div>
)}
</section>
</SiteShell>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { Link } from 'react-router-dom';
import { SiteShell } from '../components/SiteShell';
const STEPS = [
{
icon: '🎟️',
title: 'Pick a game, share the code',
body: 'One of you starts a game and gets a five-letter room code and a link. The other joins on their own phone. Only have one device? Choose couch mode and pass it back and forth.',
},
{
icon: '🎭',
title: 'Choose your roles',
body: "Every game has two roles, like Inspector and Estate Insider. Your role decides which clues you get. Your partner gets a completely different set.",
},
{
icon: '🗣️',
title: 'Talk it out',
body: "Describe what you see. Don't just show your screen, that's cheating (and less fun). Every lock needs something from each of you.",
},
{
icon: '🔑',
title: 'Open the locks together',
body: 'Either of you can type the answer. Each lock you open earns a key. Collect them all to reach the finale.',
},
];
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.'],
['📝 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.'],
['🏆 Ranks', 'Finish under the estimated time with no hints to become Soulmate Sleuths.'],
] as const;
export function HowToPlay() {
return (
<SiteShell>
<h1 className="mt-4 font-display text-4xl font-bold">How to play</h1>
<p className="mt-2 max-w-2xl text-lg text-stone-300">
Best played on a call or side by side. There's no in-game chat on purpose: the talking is the game.
</p>
<ol className="mt-8 grid gap-4 sm:grid-cols-2">
{STEPS.map((step, i) => (
<li key={step.title} className="card">
<div className="flex items-center gap-3">
<span className="text-3xl" aria-hidden>{step.icon}</span>
<h2 className="font-display text-xl font-bold">
{i + 1}. {step.title}
</h2>
</div>
<p className="mt-2 leading-7 text-stone-300">{step.body}</p>
</li>
))}
</ol>
<h2 className="mt-10 font-display text-2xl font-bold">Good to know</h2>
<dl className="mt-4 space-y-3">
{TIPS.map(([term, text]) => (
<div key={term} className="card !py-3">
<dt className="font-semibold">{term}</dt>
<dd className="mt-0.5 text-stone-300">{text}</dd>
</div>
))}
</dl>
<div className="mt-10 text-center">
<Link to="/games" className="btn-primary px-8 py-3 text-lg">
Pick a mystery
</Link>
</div>
</SiteShell>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { useState } from 'react';
import type { Category } from '@two-keys/shared';
import { GameCard } from '../components/GameCard';
import { SiteShell } from '../components/SiteShell';
import { useGames } from '../lib/hooks';
import { CATEGORY_LABEL } from '../lib/theme';
const FILTERS: (Category | 'all')[] = ['all', 'murder-mystery', 'escape-room', 'co-op'];
export function Library() {
const games = useGames();
const [filter, setFilter] = useState<Category | 'all'>('all');
const shown = (games ?? []).filter((g) => filter === 'all' || g.category === filter);
return (
<SiteShell>
<h1 className="mt-4 font-display text-4xl font-bold">Game library</h1>
<p className="mt-2 text-stone-300">
New here? Start with <strong>The Clockmaker's Attic</strong>. It's the gentlest first game.
</p>
<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>
))}
</div>
{games === null ? (
<p className="mt-6 text-stone-400">Loading games…</p>
) : games.length === 0 ? (
<p className="mt-6 text-red-300">Couldn't load the games. Is the server running?</p>
) : (
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{shown.map((game) => (
<GameCard key={game.id} game={game} />
))}
</div>
)}
</SiteShell>
);
}
+117
View File
@@ -0,0 +1,117 @@
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';
function ShareBox({ code }: { code: string }) {
const [copied, setCopied] = useState(false);
const link = `${window.location.origin}/join/${code}`;
const copy = async () => {
try {
await navigator.clipboard.writeText(link);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
/* clipboard blocked: the link is on screen to copy by hand */
}
};
return (
<section className="card text-center">
<div className="text-xs font-bold uppercase tracking-widest text-stone-400">Room code</div>
<div className="mt-1 font-mono text-5xl font-bold tracking-[0.25em] text-accent" aria-label={`Room code ${code.split('').join(' ')}`}>
{code}
</div>
<p className="mt-3 break-all font-mono text-xs text-stone-400">{link}</p>
<button className="btn mt-3 w-full" onClick={copy}>
{copied ? 'Copied ✓' : 'Copy invite link'}
</button>
</section>
);
}
export function Lobby({ state }: { state: RoomState }) {
const { game, you, partner } = state;
const couch = state.mode === 'couch';
const mySeat = you.seat === 'A' || you.seat === 'B' ? you.seat : null;
const holder = (role: Role) => (couch ? null : mySeat === role ? 'You' : partner?.seat === role ? 'Your partner' : null);
let status: string;
if (couch) status = 'One device, two detectives. Decide who is who, then start.';
else if (!partner) status = 'Waiting for your partner to join…';
else if (!mySeat) status = 'Pick a role. Your partner gets the other one.';
else if (you.ready && !partner.ready) status = "You're ready. Waiting for your partner…";
else if (!you.ready && partner.ready) status = 'Your partner is ready. Tap Ready when you are!';
else status = "Both here. Tap Ready when you're set.";
return (
<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>⏱ {game.estimatedMinutes} min</span>
<span aria-label={`Difficulty ${game.difficulty} of 3`}>{difficultyKeys(game.difficulty)}</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>
</header>
{!couch && <ShareBox code={state.code} />}
<section aria-labelledby="roles">
<h2 id="roles" className="mb-2 text-xs font-bold uppercase tracking-widest text-accent">
{couch ? 'The two roles' : 'Choose your role'}
</h2>
<div className="grid gap-3 sm:grid-cols-2">
{(['A', 'B'] as const).map((role) => {
const who = holder(role);
const mine = mySeat === role;
return (
<div key={role} className={`card flex flex-col ${mine ? `${ROLE_BORDER[role]} bg-white/[0.08]` : ''}`}>
<div className="flex items-center justify-between gap-2">
<RoleBadge role={role} name={game.roles[role].name} />
{who && <span className="text-xs font-semibold text-stone-300">{who}</span>}
</div>
<p className="mt-2 flex-1 text-sm leading-6 text-stone-300">{game.roles[role].blurb}</p>
{!couch && (
<button
className="btn mt-3"
disabled={mine}
onClick={() => socket.emit('room:pickRole', { role })}
>
{mine ? 'This is you' : who ? 'Swap roles' : `Play as ${game.roles[role].name}`}
</button>
)}
</div>
);
})}
</div>
</section>
<section className="card">
<Markdown className="leading-7 text-stone-200">{game.intro}</Markdown>
</section>
<div className="sticky bottom-0 -mx-4 border-t border-white/10 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-sm text-stone-300" aria-live="polite">
{!couch && (
<span
aria-hidden
className={`mr-2 inline-block h-2 w-2 rounded-full ${partner?.online ? 'bg-emerald-400' : 'bg-stone-500'}`}
/>
)}
{status}
</p>
<button
className={you.ready ? 'btn w-full' : 'btn-primary w-full'}
disabled={!couch && !mySeat}
onClick={() => socket.emit('room:ready')}
>
{couch ? 'Start the game' : you.ready ? 'Ready ✓ (tap to undo)' : "I'm ready"}
</button>
<p className="mt-2 text-center text-xs text-stone-500">Best played on a call or side by side.</p>
</div>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { useCallback, useState, type CSSProperties } from 'react';
import { Link } from 'react-router-dom';
import { otherRole, type Role, type RoomState } from '@two-keys/shared';
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 { RoleBadge } from '../components/ui';
import { DefuseGame } from '../engines/DefuseGame';
import { LabyrinthGame } from '../engines/LabyrinthGame';
import { StoryGame } from '../engines/StoryGame';
import { SyncGame } from '../engines/SyncGame';
import { socket, type Send } from '../lib/socket';
const ROLE_COLOR: Record<Role, string> = { A: '#f5b942', B: '#2dd4bf' };
export function Play({ state, receivedAt }: { state: RoomState; receivedAt: number }) {
const couch = state.you.seat === 'both';
const [activeRole, setActiveRole] = useState<Role>(couch ? 'A' : (state.you.seat as Role));
// Couch mode: the role waiting behind the "pass the device" cover, if it's up.
const [cover, setCover] = useState<Role | null>(couch ? 'A' : null);
const send = useCallback<Send>(
(event, payload = {}) => socket.emit(event, couch ? { ...payload, asRole: activeRole } : payload),
[couch, activeRole],
);
const { game } = state;
const view = state.views[activeRole];
if (cover) {
return (
<PassDeviceScreen
role={cover}
roleName={game.roles[cover].name}
onReveal={() => {
setActiveRole(cover);
setCover(null);
}}
/>
);
}
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`}
>
<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">
🔑🔑
</Link>
<h1 className="min-w-0 flex-1 truncate font-display text-base font-bold">{game.title}</h1>
<Timer timer={state.timer} receivedAt={receivedAt} />
</div>
<div className="mt-2 flex items-center gap-2">
<KeyRing keys={state.keys} total={game.totalKeys} />
{couch ? (
<button
className="flex min-h-[36px] min-w-0 items-center gap-1.5 rounded-full border border-white/15 bg-black/30 px-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
onClick={() => setCover(otherRole(activeRole))}
>
<span className="truncate">Pass to {game.roles[otherRole(activeRole)].name}</span>
<span aria-hidden>👀</span>
</button>
) : (
<PartnerStatus state={state} />
)}
<div className="flex-1" />
<Notepad text={state.notepad} />
</div>
</header>
<main className="pb-5 pt-3">
<div className="mb-4 flex items-center gap-2 text-xs text-stone-400">
<span>You are</span>
<RoleBadge role={activeRole} name={game.roles[activeRole].name} />
</div>
{view?.engine === 'story' && <StoryGame view={view} state={state} role={activeRole} send={send} />}
{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} />}
</main>
{!couch && <PingOverlay roles={game.roles} />}
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { Link } from 'react-router-dom';
import { formatClock, type GameResult, type RoomState } from '@two-keys/shared';
import { GameCard } from '../components/GameCard';
import { Markdown } from '../components/ui';
import { useGames } from '../lib/hooks';
import { forgetLastRoom, saveBest } from '../lib/session';
const RANK_ICON: Record<string, string> = {
'Soulmate Sleuths': '💞',
'Dream Team': '🌟',
'Getting There': '🧩',
};
function Stat({ label, value, note }: { label: string; value: string; note?: string }) {
return (
<div className="card !p-3 text-center">
<div className="font-mono text-2xl font-bold tabular-nums">{value}</div>
<div className="text-xs uppercase tracking-wider text-stone-400">{label}</div>
{note && <div className="mt-0.5 text-[11px] text-red-300">{note}</div>}
</div>
);
}
export function Results({ state, result }: { state: RoomState; result: GameResult }) {
const { game } = state;
const games = useGames();
const [isBest, setIsBest] = useState(false);
// Remember the best result for this game on this device.
useEffect(() => {
forgetLastRoom();
if (!result.success) return;
setIsBest(
saveBest(game.id, {
finalSeconds: result.finalSeconds,
rank: result.rank,
hintsUsed: result.hintsUsed,
keysEarned: result.keysEarned,
}),
);
}, [game.id, result]);
const others = (games ?? []).filter((g) => g.id !== game.id);
return (
<div className="mx-auto w-full max-w-2xl space-y-6 px-4 py-8">
<header className="text-center">
<div className="text-xs font-bold uppercase tracking-widest text-accent">{game.title}</div>
<motion.div
className="mt-3 text-7xl"
aria-hidden
initial={{ scale: 0.3, rotate: -20, opacity: 0 }}
animate={{ scale: 1, rotate: 0, opacity: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 12 }}
>
{result.success ? (RANK_ICON[result.rank] ?? '🔑') : '🕯️'}
</motion.div>
<h1 className="mt-3 font-display text-4xl font-bold">{result.rank}</h1>
{isBest && <p className="mt-1 text-sm font-semibold text-accent">🏆 New personal best</p>}
</header>
{result.ending && (
<section className="card">
<Markdown className="text-lg leading-8">{result.ending}</Markdown>
</section>
)}
{result.reveal && (
<section className="rounded-2xl border border-accent/40 bg-accent/10 p-4">
<h2 className="text-xs font-bold uppercase tracking-widest text-accent">What really happened</h2>
<Markdown className="mt-2 leading-7">{result.reveal}</Markdown>
</section>
)}
<section className="grid grid-cols-2 gap-3" aria-label="Your results">
<Stat
label="Final time"
value={formatClock(result.finalSeconds)}
note={result.penaltySeconds > 0 ? `includes +${formatClock(result.penaltySeconds)} penalty` : undefined}
/>
<Stat label="Keys earned" value={`${result.keysEarned}/${result.totalKeys}`} />
<Stat label="Hints used" value={String(result.hintsUsed)} />
<Stat label="Wrong guesses" value={String(result.wrongGuesses)} />
</section>
<p className="text-center text-xs text-stone-500">
Estimated time for this game: {game.estimatedMinutes} minutes.
</p>
{others.length > 0 && (
<section aria-labelledby="another">
<h2 id="another" className="mb-3 font-display text-2xl font-bold">
Play another
</h2>
<div className="-mx-4 flex snap-x snap-mandatory gap-3 overflow-x-auto px-4 pb-3">
{others.map((g) => (
<GameCard key={g.id} game={g} className="w-[78%] max-w-xs shrink-0 snap-center" />
))}
</div>
</section>
)}
<div className="text-center">
<Link to="/" className="btn">
Back to the library
</Link>
</div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { Link, Navigate, useParams } from 'react-router-dom';
import { Spinner } from '../components/ui';
import { useRoom } from '../lib/hooks';
import { useTheme } from '../lib/theme';
import { Lobby } from './Lobby';
import { Play } from './Play';
import { Results } from './Results';
/** /join/K7MRX is the share link; the room page does the actual joining. */
export function JoinRedirect() {
const { code = '' } = useParams();
return <Navigate to={`/room/${code.toUpperCase()}`} replace />;
}
export function Room() {
const code = (useParams().code ?? '').toUpperCase();
const { snapshot, error, connected } = useRoom(code);
useTheme(snapshot?.state.game.theme);
if (error && !snapshot) {
return (
<div className="flex min-h-dvh flex-col items-center justify-center gap-4 px-6 text-center">
<div className="text-5xl" aria-hidden>🗝️</div>
<h1 className="font-display text-3xl font-bold">This door won't open</h1>
<p className="max-w-sm text-stone-300">{error}</p>
<Link to="/" className="btn-primary">
Back to the library
</Link>
</div>
);
}
if (!snapshot) return <Spinner label={`Opening room ${code}…`} />;
const { state, receivedAt } = snapshot;
return (
<div className="min-h-dvh">
{!connected && (
<div role="status" className="sticky top-0 z-30 bg-red-900 px-4 py-1.5 text-center text-sm font-medium">
Connection lost. Reconnecting…
</div>
)}
{state.phase === 'lobby' && <Lobby state={state} />}
{/* Keyed by seat so a couch/duo rejoin starts from the right role. */}
{state.phase === 'playing' && <Play key={state.you.seat} state={state} receivedAt={receivedAt} />}
{state.phase === 'finished' && state.result && <Results state={state} result={state.result} />}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
// Per-game theme, set as CSS variables from the YAML `theme` block.
primary: 'rgb(var(--primary) / <alpha-value>)',
accent: 'rgb(var(--accent) / <alpha-value>)',
bg: 'rgb(var(--bg) / <alpha-value>)',
// Role colours: A is warm, B is cool.
roleA: '#f5b942',
roleB: '#2dd4bf',
},
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'],
},
},
},
plugins: [],
};
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"]
},
"include": ["src"]
}
+15
View File
@@ -0,0 +1,15 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
const server = 'http://localhost:3001';
export default defineConfig({
plugins: [react()],
server: {
host: true,
proxy: {
'/api': server,
'/socket.io': { target: server, ws: true },
},
},
});
+259
View File
@@ -0,0 +1,259 @@
id: blue-lantern
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
players: { min: 2, max: 2 }
theme: { primary: "#1f3a68", accent: "#e8b04a", background: "#0b0f1c", headingFont: "Playfair Display" }
roles:
A:
name: The Detective
blurb: You have the band's paperwork, the police search log, and the stage manager's notes.
B:
name: The Regular
blurb: You've drunk here every Friday for ten years. You know the staff, the bar, and the gossip.
intro: |
Friday night at **the Blue Lantern**, the hottest jazz club on Harbour Street.
The last set kicks off at 23:00. Smoke, brass, a full house.
When the band takes its bow at 23:25, club owner **Vincent Cole** is found in his
office behind the stage. One bullet. Nobody heard a thing.
One of you is the detective who caught the case. The other is the regular who knows
everybody's business. **Put your heads together.**
suspects:
- id: ruby
name: Ruby Day
role: Singer
description: The voice of the Blue Lantern. Fought with Vincent about her pay last week.
- id: sammy
name: Sammy Keys
role: Pianist
description: Quiet genius. Writes most of the club's songs. Never talks about money.
- id: frankie
name: Frankie Moretti
role: Moneylender
description: Vincent owed him. A lot. Sat at the bar all night watching the door.
- id: lou
name: Lou Banner
role: Bartender
description: Sees everything, says nothing. Vincent's oldest employee.
- id: grace
name: Grace Cole
role: Vincent's wife
description: Hates jazz. Came tonight anyway. Nobody knows why.
chapters:
- id: ch1
title: Under the Drums
narrative: |
The office is right behind the stage. A gunshot in there should have stopped the room cold.
Unless something louder was playing at the time.
clues:
A:
- id: a1-setlist
title: Last set, taped to the piano
type: document
body: |
**THE LAST SET** (no breaks, straight through)
1. Night Train · 4 min
2. Blue Moon · 6 min
3. Take Five · 5 min
4. Caravan · 7 min
5. Goodnight Sweetheart · 3 min
B:
- id: b1-lou
title: What Lou the bartender told you
type: note
body: |
"Band kicked off the last set at eleven on the dot. Max always closes **Caravan**
with a drum solo, the **last two minutes** of the tune. Loud enough to rattle the glasses.
If there was a shot, it was under that solo. Nothing else is that loud in here."
lock:
id: lock-solo
prompt: At what time did the drum solo start?
inputType: code
format: "HH:MM"
answers: ["2320", "1120", "1120PM"]
hints:
- Add up the songs before Caravan, then work out when Caravan ended.
- Caravan runs 23:15 to 23:22.
- The solo is the last two minutes of Caravan, so it starts at 23:20.
successText: |
**23:20.** For two minutes, the whole room was drowned in drums.
That's when Vincent died.
keyName: Drumstick Key
- id: ch2
title: Who Left the Stage?
narrative: |
Everyone has an alibi. But an alibi for "the evening" isn't an alibi for 23:20 to 23:22.
clues:
A:
- id: a2-stagemanager
title: Stage manager's notes
type: report
body: |
- Ruby only sings on vocal numbers. **Caravan is instrumental**, so she's off stage for it.
- **During Max's drum solo, the rest of the band walks off** and comes back for the final bar. House tradition.
- The stage-left door leads straight to the office corridor. Ten steps.
B:
- id: b2-gossip
title: Who you saw, and who saw who
type: note
body: |
- **Frankie** was at the bar the whole set. Lou poured him a whiskey right as the drums kicked in.
- **Lou** never left the bar. You were sitting right in front of him.
- **Grace** was in the coat-check queue from about 23:15 to 23:25, complaining to Dolly the coat girl.
- **Ruby** came to the coat-check at 23:21 to borrow Dolly's lighter. Dolly lit it for her.
lock:
id: lock-alibi
prompt: Who had NO alibi during the drum solo?
inputType: choice
options:
- { id: ruby, label: Ruby Day }
- { id: sammy, label: Sammy Keys }
- { id: frankie, label: Frankie Moretti }
- { id: lou, label: Lou Banner }
- { id: grace, label: Grace Cole }
answers: ["sammy"]
hints:
- The stage manager says something surprising about who's on stage during a drum solo.
- Everyone in the band except Max leaves during the solo. Which of them has a witness?
- Ruby was at the coat-check. Sammy was off stage, alone, ten steps from the office.
successText: |
**Sammy Keys.** Everyone assumed the pianist was at his piano.
But during the solo, the piano bench sat empty.
keyName: Stage Door Key
- id: ch3
title: Vincent's Safe
narrative: |
Vincent's office safe is still locked. Four-digit dial. Lou says Vincent
"kept the combination in his head and his heart."
clues:
A:
- id: a3-sheet
title: Sheet music on Vincent's desk
type: document
body: |
*"Lantern Waltz", Vincent's favourite tune.*
The first four notes are circled in red ink:
## C · A · F · E
Scribbled underneath: *"The safe plays my song."*
B:
- id: b3-piano
title: Sticker strip on the rehearsal piano
type: diagram
body: |
Sammy numbered the white keys for the beginners' class:
```
| C | D | E | F | G | A | B |
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
```
lock:
id: lock-safe
prompt: Enter the safe combination.
inputType: code
format: 4 digits
answers: ["1643"]
hints:
- One of you has the notes. The other has the numbers for the notes.
- Use the beginner sticker numbers, not the alphabet.
- C=1, A=6, F=4, E=3.
successText: |
The safe swings open. Inside: a record contract from Galaxy Records.
**Twelve songs, all written by Sammy Keys. All registered under Vincent Cole's name.**
Signing date: Monday. Advance: more than Sammy would earn in twenty years.
keyName: Safe Key
- id: ch4
title: The Missing Gun
narrative: |
Vincent was shot with his own .38 from his desk drawer. The police have torn the club apart.
No gun.
clues:
A:
- id: a4-search
title: Police search log
type: log
body: |
| Area | Searched | Result |
|------------------|----------|--------|
| Office | Yes | Empty drawer |
| Dressing rooms | Yes | Nothing |
| Bar + storeroom | Yes | Nothing |
| Coat-check | Yes | Nothing |
| Drum kit, bass case | Yes | Nothing |
| Alley bins | Yes | Nothing |
| Grand piano | **No** | Lid locked, owner's prized Steinway |
B:
- id: b4-max
title: Overheard from Max the drummer
type: transcript
body: |
"When we came back for the last chorus, Sammy's first chord sounded *wrong*.
Dead. A thunk, like something heavy was lying on the strings.
He played the rest of the night up on the high keys only. Never seen him do that."
lock:
id: lock-gun
prompt: Where is the gun hidden?
inputType: text
format: One or two words
answers: ["PIANO", "THEPIANO", "GRANDPIANO", "STEINWAY", "INSIDETHEPIANO", "INPIANO"]
hints:
- Something was sitting on some strings.
- Which part of the club did the police not search?
- The grand piano.
successText: |
The police force the lid. Lying across the low strings of the Steinway:
**Vincent's .38**, one round fired.
keyName: Piano Key
finale:
type: accusation
prompt: Four keys. One killer. Make the call together.
maxAttempts: 2
questions:
- id: who
label: Who killed Vincent Cole?
options:
- { id: ruby, label: Ruby Day }
- { id: sammy, label: Sammy Keys }
- { id: frankie, label: Frankie Moretti }
- { id: lou, label: Lou Banner }
- { id: grace, label: Grace Cole }
answer: sammy
- id: how
label: How?
options:
- { 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 }
answer: gunsolo
- id: why
label: Why?
options:
- { id: debt, label: An unpaid gambling debt }
- { id: songs, label: Vincent stole his songs and was about to sell them }
- { id: pay, label: A fight over wages }
- { id: love, label: A love triangle with Grace }
answer: songs
successEnding: |
Sammy sits at the piano one last time and plays four notes. C, A, F, E.
"I wrote that one too," he says. "Every song he called his. I found the contract Tuesday."
He slipped off stage when the band cleared for Max's solo, took Vincent's gun from the
drawer, and was back for the final bar before anyone looked up.
**Case closed.** The band plays on. 🎷🔑
failEnding: |
The wrong person goes away. Sammy keeps playing, a little sadder every year.
reveal: |
**Sammy Keys** shot Vincent with his own **.38** during the **drum solo** and hid the gun
**inside the piano**, because Vincent had **stolen his songs** and was about to sell them.
+194
View File
@@ -0,0 +1,194 @@
id: clockmakers-attic
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
difficulty: 1
players: { min: 2, max: 2 }
theme: { primary: "#8a5a2b", accent: "#f2c66d", background: "#17110b", headingFont: "Playfair Display" }
roles:
A:
name: The Tinkerer
blurb: You're on the workbench side. Clocks, gears, and the old man's letters.
B:
name: The Collector
blurb: You're on the shelves side. Paintings, samplers, and his treasure inventory.
intro: |
You bought an old house on a whim. The attic door was painted shut.
Tonight you finally pry it open, step inside together... and it **slams behind you**.
Dust. Brass. A hundred clocks, all still ticking. A letter on the workbench:
> *"Whoever finds my attic, welcome. I've hidden the way out in my favourite things.
> Work together. Nothing in here opens for one pair of hands. — Old Mr Albrecht"*
A tall beam of shelves splits the room in half. You can hear each other, but you can't see each other's side.
chapters:
- id: ch1
title: The Three Clocks
narrative: |
An old sea trunk sits in the middle of the room. Brass padlock, three dials.
clues:
A:
- id: a1-clocks
title: Three clocks on the workbench
type: diagram
body: |
```
🐦 ROBIN clock ..... hour hand on 3
🦉 OWL clock ....... hour hand on 7
🦊 FOX clock ....... hour hand on 9
```
All three stopped. The trunk lock has three dials, 0–9.
B:
- id: b1-sampler
title: Embroidered sampler on the wall
type: note
body: |
*Stitched in faded thread:*
**First the bird that sings the dawn,
then the hunter on the lawn,
last the bird that rules the night.
Set them so, and set them right.**
lock:
id: lock-trunk
prompt: Set the trunk's three dials.
inputType: code
format: 3 digits
answers: ["397"]
hints:
- The sampler gives you an order. The clocks give you numbers.
- Dawn bird = robin. Hunter = fox. Night bird = owl.
- Robin 3, fox 9, owl 7.
successText: |
The trunk creaks open. Inside, wrapped in velvet: a **music box** with four coloured buttons,
and a small brass key.
keyName: Trunk Key
- id: ch2
title: The Music Box
narrative: |
The music box is silent. Four buttons on its lid: **red, blue, green, gold**.
Press them in the right order and it should play.
clues:
A:
- id: a2-musicbox
title: Engraving under the lid
type: note
body: |
*"Play the colours of the sky-bow, top to bottom.
All but the one my pendulum stole."*
B:
- id: b2-painting
title: Small oil painting on the shelf
type: photo
body: |
*A painted rainbow over a village. Only four bands, top to bottom:
**red, gold, green, blue**.*
- id: b2-pendulum
title: The grandfather clock on your side
type: photo
body: |
*A tall clock. Its brass pendulum has been painted a deep **green**.
A label on the case: "Borrowed a colour for my favourite clock. — A."*
lock:
id: lock-musicbox
prompt: Which order do you press the buttons?
inputType: text
format: Three colours, in order
answers: ["REDGOLDBLUE"]
hints:
- The "sky-bow" is a rainbow. The Collector can see one.
- The pendulum stole green. Leave it out.
- Red, gold, blue.
successText: |
The music box plays a tinkling waltz. A drawer pops out of its base.
Inside: a crank handle and a sketch of the **gear wall**.
keyName: Music Box Key
- id: ch3
title: The Gear Wall
narrative: |
On the far wall, three brass gears mesh together. A crank socket sits on the biggest one.
A drawer below has a number dial.
clues:
A:
- id: a3-gears
title: The gear wall
type: diagram
body: |
```
(Gear A: 48 teeth) ⟷ (Gear B: 16 teeth) ⟷ (Gear C: 24 teeth)
↑ crank
```
Each gear meshes with the next. Every tooth that passes on one passes on the other.
B:
- id: b3-instructions
title: Note pinned to the shelf
type: note
body: |
*Turn my crank exactly **5 full turns**.
The drawer wants to know how many full turns **Gear C** makes.*
lock:
id: lock-gears
prompt: How many turns does Gear C make?
inputType: code
format: A number
answers: ["10"]
hints:
- Count teeth. Five turns of Gear A moves 5 × 48 teeth past every gear.
- Gear B doesn't change the answer. Only the first and last gear matter.
- 240 teeth ÷ 24 teeth per turn = 10.
successText: |
The drawer slides open. Inside: Mr Albrecht's last letter, and his treasure inventory, complete.
keyName: Gear Key
finale:
type: escape
prompt: The attic door has a seven-letter word lock.
clues:
A:
- id: af-letter
title: Mr Albrecht's last letter
type: letter
body: |
*My dears,*
*Line up my seven treasures **in the order I made them**, oldest first.
Their first letters are the word I'd give the two of you.*
*— A.*
B:
- id: bf-inventory
title: Treasure inventory (dates engraved on each)
type: table
body: |
| Treasure | Year made |
|----------------|-----------|
| Violin | 1922 |
| Fob watch | 1902 |
| Rocking horse | 1915 |
| Egg timer | 1931 |
| Oil lamp | 1909 |
| Rug loom | 1938 |
| Easel | 1920 |
lock:
id: lock-door
prompt: Enter the seven-letter word.
inputType: text
format: 7 letters
answers: ["FOREVER"]
hints:
- Sort the treasures by year, oldest to newest.
- "1902 Fob watch, 1909 Oil lamp, 1915 Rocking horse..."
- F-O-R-E-V-E-R.
successEnding: |
**FOREVER.** The lock clicks. The door swings open onto the stairs.
On the back of the door, carved into the wood: *"Albrecht & Marta, 1901. Forever."*
He built the whole attic as a puzzle for his wife. Now it was one for you.
**You escaped!** 🕰️🔑
+92
View File
@@ -0,0 +1,92 @@
id: in-sync
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
difficulty: 1
players: { min: 2, max: 2 }
theme: { primary: "#c2185b", accent: "#ffb3c7", background: "#14080d", headingFont: "Playfair Display" }
roles:
A:
name: Partner A
blurb: You'll be in the spotlight in Round 2.
B:
name: Partner B
blurb: You'll be in the spotlight in Round 3.
intro: |
No clues. No killer. Just the two of you.
Answer privately. Answers flip over at the same time.
**Match on 3 out of 5 to earn a key.** Miss a round and you get one replay with new questions.
No peeking at each other's screens. We'll know. (We won't. But you will.) 😏
rules:
questionsPerRound: 5
pointsForKey: 3
replaysPerRound: 1
rounds:
- id: r1
title: Same Wavelength
type: match
blurb: Both pick the answer you think your partner will pick. Same answer = point.
keyName: Wavelength Key
- id: r2
title: All About A
type: predict
subject: A
blurb: Partner A answers honestly about themselves. Partner B guesses.
keyName: Spotlight Key A
- id: r3
title: All About B
type: predict
subject: B
blurb: Partner B answers honestly about themselves. Partner A guesses.
keyName: Spotlight Key B
# MATCH bank: both see the same question and options.
match:
- { id: m01, q: "Perfect Friday night?", options: [Movie on the couch, Dinner out, Friends over, Early night] }
- { id: m02, q: "Our dream holiday?", options: [Beach, Mountains, Big city, Road trip] }
- { id: m03, q: "Best pizza topping?", options: [Pepperoni, Mushroom, Pineapple, Just cheese] }
- { id: m04, q: "If we got a pet tomorrow?", options: [Dog, Cat, Fish, Absolutely not] }
- { id: m05, q: "Who'd survive longer in a zombie movie?", options: [Me, My partner, Neither, We'd stick together] }
- { id: m06, q: "Best season?", options: [Summer, Autumn, Winter, Spring] }
- { id: m07, q: "Our go-to takeaway?", options: [Burgers, Pizza, Sushi, Curry] }
- { id: m08, q: "Best way to make up after a fight?", options: [Talk it out, Hug first, Snacks, Give it an hour] }
- { id: m09, q: "Morning person or night owl? (Us as a couple)", options: [Morning, Night, Depends on the coffee, Neither] }
- { id: m10, q: "Which superpower would we share?", options: [Teleporting, Mind reading, Time travel, Flying] }
- { id: m11, q: "Rainy Sunday plan?", options: [Board games, Series binge, Baking, Sleep in] }
- { id: m12, q: "Who says 'I love you' more?", options: [Me, My partner, Even, We say it with snacks] }
- { id: m13, q: "Best date we could do this month?", options: [Picnic, Concert, Cooking class, Stargazing] }
- { id: m14, q: "Coffee or tea?", options: [Coffee, Tea, Both, Neither] }
- { id: m15, q: "Our couple theme song genre?", options: [Hip-hop, Worship, Pop, Old-school love songs] }
- { id: m16, q: "Ideal house?", options: [Farmhouse, City apartment, Beach cottage, Cabin in the woods] }
# PREDICT bank: subject answers about themselves; partner guesses. Text is shown with the
# subject's name swapped in: "{subject}" → their role name, "you/your" for the subject's own view.
predict:
- { id: p01, q: "{subject}'s comfort food?", options: [Mac and cheese, Chocolate, Chips, Something spicy] }
- { id: p02, q: "{subject}'s biggest pet peeve?", options: [Loud chewing, Being late, Messy kitchen, Slow walkers] }
- { id: p03, q: "{subject}'s ideal birthday?", options: [Big party, Quiet dinner, Surprise trip, Day off doing nothing] }
- { id: p04, q: "What would {subject} grab first in a fire (people and pets safe)?", options: [Phone, Photos, Laptop, A sentimental item] }
- { id: p05, q: "{subject}'s love language?", options: [Words, Time together, Gifts, Acts of service, Touch] }
- { id: p06, q: "{subject}'s secret talent?", options: [Singing, Cooking, Impressions, Dancing] }
- { id: p07, q: "{subject} would rather...", options: [Never use social media again, Never eat dessert again] }
- { id: p08, q: "{subject}'s scariest thing?", options: [Heights, Spiders, Deep water, Public speaking] }
- { id: p09, q: "{subject}'s dream job (if money didn't matter)?", options: [Artist, Chef, Traveller, Teacher] }
- { id: p10, q: "{subject}'s go-to karaoke move?", options: [Power ballad, Rap verse, Duet, Refuse entirely] }
- { id: p11, q: "How does {subject} recharge?", options: [Alone time, Friends, Outdoors, Sleep] }
- { id: p12, q: "{subject}'s favourite way to receive a compliment?", options: [In private, In front of people, In writing, With a hug] }
- { id: p13, q: "{subject}'s worst habit?", options: [Phone scrolling, Procrastinating, Snacking late, Overthinking] }
- { id: p14, q: "What does {subject} miss most about childhood?", options: [No responsibilities, School friends, Holidays, Mom's cooking] }
- { id: p15, q: "{subject}'s perfect lazy breakfast?", options: [Pancakes, Toast and eggs, Cereal, Just coffee] }
- { id: p16, q: "If {subject} won the lotto, first buy?", options: [House, Car, Trip, Pay off family's debts] }
- { id: p17, q: "{subject} in a group project is...", options: [The leader, The ideas person, The quiet worker, The one who brings snacks] }
- { id: p18, q: "{subject}'s favourite memory of us?", options: [Our first date, A trip we took, A random ordinary day, Something only we know] }
- { id: p19, q: "When {subject} is stressed, they need...", options: [Space, A hug, A plan, Food] }
- { id: p20, q: "{subject}'s favourite movie genre?", options: [Comedy, Action, Romance, Thriller] }
- { id: p21, q: "What would {subject} name a pet goldfish?", options: [Something funny, A human name, A food name, A famous person's name] }
- { id: p22, q: "{subject}'s most-used emoji?", options: ["😂", "❤️", "🙏", "🔥"] }
+87
View File
@@ -0,0 +1,87 @@
id: labyrinth-of-lanterns
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
players: { min: 2, max: 2 }
theme: { primary: "#3d2c6b", accent: "#ffb347", background: "#09070f", headingFont: "Playfair Display" }
roles:
A:
name: Navigator
blurb: You hold the ancient map. You can see every wall, glyph and trap. You can't see your partner.
B:
name: Explorer
blurb: You're inside the labyrinth with one candle. You only see the floor right under your feet.
intro: |
Beneath an old cathedral lies a labyrinth built for lovers. Legend says only a couple who
truly listen to each other can carry the **three lanterns** out.
**Explorer:** describe every glyph you step on and which ways you can go.
**Navigator:** work out where they are, guide them to the lanterns, and steer them **around the traps**.
The Explorer can't see the traps. One wrong step and they wake up back at the entrance.
# Grid legend:
# # wall . floor S start
# E exit (opens only when all 3 lanterns are carried)
# K lantern key (collected on entry)
# T trap (invisible to Explorer; on entry → back to S, keys kept, +60s penalty)
# Coordinates are (x, y) with (0,0) top-left.
grid:
- "###########"
- "#S..#.....#"
- "#.#.#.###.#"
- "#.#...#K..#"
- "#.#####.#.#"
- "#...T...#K#"
- "###.###.#.#"
- "#K..#.T.#.#"
- "#.###.#.#.#"
- "#..T....#E#"
- "###########"
# Floor glyphs: Explorer sees the glyph of their current cell; Navigator sees all of them on the map.
# Many floor cells are deliberately blank so the Navigator has to track moves, not just glyphs.
glyphs:
- { x: 1, y: 1, glyph: sun, label: "☀ A carved sun (the entrance)" }
- { x: 3, y: 1, glyph: spiral, label: "🌀 A spiral" }
- { x: 5, y: 3, glyph: crescent, label: "☾ A crescent moon" }
- { x: 9, y: 1, glyph: eye, label: "👁 An open eye" }
- { x: 1, y: 5, glyph: feather, label: "🪶 A feather" }
- { x: 3, y: 7, glyph: wave, label: "🌊 Three wavy lines" }
- { x: 7, y: 5, glyph: crown, label: "👑 A crown" }
- { x: 7, y: 9, glyph: star, label: "★ A star" }
- { x: 9, y: 7, glyph: rose, label: "🌹 A rose" }
- { x: 5, y: 9, glyph: heart, label: "♥ Two hearts, intertwined" }
lanterns:
- { x: 7, y: 3, name: Lantern of Patience }
- { x: 9, y: 5, name: Lantern of Trust }
- { x: 1, y: 7, name: Lantern of Listening }
explorerView:
# What the Explorer gets each move (server-built, never the grid):
# { glyphLabel | null, open: { n, e, s, w }, lanternHere: bool, keysCarried: n, atExit: bool }
flavour:
blank: "Bare stone. Your candle flickers."
trap: "The floor gives way! Darkness... you wake up at the carved sun."
lantern: "A lantern glows to life in your hands! ({name})"
exitLocked: "A heavy door with three empty lantern hooks. It won't budge."
exitOpen: "You hang the three lanterns. The door swings open onto starlight."
navigatorView:
# Navigator sees the full grid, glyphs, lanterns (dimmed once collected), traps, exit.
# Navigator NEVER receives the Explorer's coordinates. They do get: keysCarried, and a
# toast "Your Explorer fell into a trap and is back at the entrance." when it happens.
showMoveCount: true
scoring:
trapPenaltySeconds: 60
parMoves: 60
successEnding: |
The Explorer climbs out into the cathedral garden carrying three lanterns.
The Navigator is waiting with the map rolled up and a grin.
**Patience. Trust. Listening.** Turns out that's the whole map. 🏮🔑
+323
View File
@@ -0,0 +1,323 @@
id: southern-star
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
players: { min: 2, max: 2 }
theme: { primary: "#2e4a3a", accent: "#c9a227", background: "#0d1411", headingFont: "Playfair Display" }
roles:
A:
name: The Passenger Detective
blurb: You're travelling in carriage 5. You have the crew's notebooks and the passengers' papers.
B:
name: The Railway Inspector
blurb: You work for the line. You have the train plans, the timetable, and the platform reports.
intro: |
1936. The **Southern Star** sleeper leaves Cape Town for Johannesburg at 18:00.
Leather, brass, and a dining car that serves oysters in the middle of the desert.
At 06:30, the steward knocks on the Gold Suite with **Lord Henry Ashby's** morning tea.
No answer. The door is locked.
Ashby made his fortune in diamonds and his enemies in everything else.
The train won't stop until Kimberley. **You have until then.**
suspects:
- id: thomas
name: Thomas Webb
role: Ashby's valet
description: Served him for fifteen years. Knows every habit, every grudge.
- id: vera
name: Countess Vera Orlova
role: Travelling aristocrat
description: Glamorous, charming, dripping in diamonds. Dined with Ashby last night.
- id: hugo
name: Dr Hugo Brandt
role: Physician
description: Says he's travelling to a medical conference. Asks a lot of questions for a doctor.
- id: penny
name: Miss Penny Lowe
role: Governess
description: Travelling alone. Was seen crying in the dining car.
- id: abel
name: Abel Mokoena
role: Conductor
description: Twenty years on the line. Holds the master key box.
chapters:
- id: ch1
title: The Gold Suite
narrative: |
The Gold Suite is locked from the inside. The conductor's master key box has a
four-digit lock, and the conductor swears he "always works it out from the plans."
clues:
A:
- id: a1-conductor
title: Conductor's notebook (inside cover)
type: note
body: |
*Master box, my little rule so I never write it down:*
**carriage number × compartment number. Always four digits, pad with zeros at the front.**
B:
- id: b1-plan
title: Southern Star, first-class plan
type: map
body: |
```
CARRIAGE 4 | Comp 10 Orlova | Comp 11 Lowe | Comp 12 (empty)
CARRIAGE 5 | Comp 17 Brandt | Comp 18 (you) | Comp 19 Webb
CARRIAGE 6 | Comp 23 ★ GOLD SUITE ★ (Lord Ashby) | Comp 24 Linen
```
lock:
id: lock-master
prompt: Open the master key box.
inputType: code
format: 4 digits
answers: ["0138"]
hints:
- You need two numbers from the train plan and one rule from the conductor.
- Gold Suite is carriage 6, compartment 23.
- 6 × 23 = 138, padded to four digits.
successText: |
The box opens. The conductor's master key turns the lock.
Lord Ashby lies on the floor of the Gold Suite. His gold pocket watch, shattered, lies beside him.
keyName: Master Key
- id: ch2
title: The Stopped Watch
narrative: |
The pocket watch smashed when he fell. Its hands are frozen.
An obvious clue. Maybe too obvious.
clues:
A:
- id: a2-watch
title: Ashby's broken pocket watch
type: photo
body: |
*A gold hunter watch, glass cracked. The hands are stopped at exactly **01:15**.
Engraved inside the lid: "Greenwich, always."*
- id: a2-valet
title: Statement of Thomas Webb, valet
type: transcript
body: |
"His Lordship never changed that watch from **Greenwich time**. Drove me mad.
He said a gentleman doesn't chase the sun. Out here it's always **two hours behind** the station clocks."
- id: a2-doctor
title: Dr Brandt's examination
type: report
body: |
Single small puncture wound at the base of the skull. Very thin, very deep.
Something like a long needle. Death almost instant.
B:
- id: b2-timetable
title: Southern Star timetable (local time)
type: table
body: |
| Station | Arrive | Depart |
|------------------|--------|--------|
| Cape Town | · | 18:00 |
| Worcester | 21:05 | 21:15 |
| Touws River | 23:40 | 23:50 |
| **Matjiesfontein** | **03:10** | **03:20** |
| Laingsburg | 04:05 | 04:10 |
| Kimberley | 13:30 | · |
- id: b2-steward
title: Night steward's report
type: report
body: |
Heard a heavy thump from carriage 6 while we stood at a platform.
Didn't think anything of it. Luggage falls all the time.
lock:
id: lock-tod
prompt: What was the real local time of the murder?
inputType: code
format: "HH:MM"
answers: ["0315", "315", "315AM"]
hints:
- The watch shows a time, but not local time.
- Greenwich is two hours behind local time here. Add two hours.
- 01:15 + 2 hours = 03:15, while the train was stopped at Matjiesfontein.
successText: |
**03:15 local time.** The train was standing at **Matjiesfontein** platform from 03:10 to 03:20.
That's when the thump was heard. Someone used those ten minutes.
keyName: Watch Key
- id: ch3
title: The Telegram
narrative: |
Tucked into Ashby's Bible: a telegram, collected at Worcester. No sender's name.
clues:
A:
- id: a3-telegram
title: Telegram, received Worcester 21:10
type: letter
body: |
```
HAVE THE DOCUMENTS READY STOP
USE THE SLEEPER NOT THE DAY COACH STOP
GIVE NOTHING TO THE COUNTESS STOP
ONLY CASH NO CHEQUES STOP
DESTROY THIS MESSAGE STOP
```
B:
- id: b3-ledger
title: Note in Ashby's ledger
type: note
body: |
*My private man never signs a telegram. He hides his name where every line begins.
Clever chap. Worth every penny.*
lock:
id: lock-telegram
prompt: Who sent the telegram?
inputType: text
format: A first name
answers: ["HUGO", "DRHUGO", "HUGOBRANDT", "DRHUGOBRANDT", "BRANDT"]
hints:
- The ledger tells you exactly where to look.
- Read the first letter of each line, top to bottom.
- H · U · G · O.
successText: |
**HUGO.** "Dr" Hugo Brandt isn't going to a conference. He's Ashby's **private investigator**.
And the telegram warned Ashby about one person by title: **the Countess**.
keyName: Telegram Key
- id: ch4
title: The Ten Minutes at Matjiesfontein
narrative: |
Everyone says they were asleep at 03:15. One of them is lying.
clues:
A:
- id: a4-corridor
title: Night steward's corridor watch
type: log
body: |
I sat at the **connecting door between carriages 4, 5 and 6** from 02:00 to 04:00.
**Nobody passed through the corridor.** I'd swear it on my mother.
- id: a4-luggage
title: Passenger luggage declarations
type: table
body: |
| Passenger | Notable items |
|----------------|---------------------------------------|
| Countess Orlova| Sable **fur coat**, fur hat, jewel case |
| Miss Lowe | Wool cardigan, two suitcases |
| Dr Brandt | Doctor's bag, camera |
| Mr Webb | Valet kit, uniform |
B:
- id: b4-porter
title: Matjiesfontein platform porter's report
type: report
body: |
03:12 · A lady in a **long fur coat** stepped down from **carriage 6** onto the platform.
03:18 · Same lady walked quickly along the platform and climbed back aboard at **carriage 4**.
She was holding her hat on with one hand. Her hatpin must have come loose.
Nobody else left the train.
lock:
id: lock-liar
prompt: Who lied about being asleep?
inputType: choice
options:
- { id: thomas, label: Thomas Webb }
- { id: vera, label: Countess Vera Orlova }
- { id: hugo, label: Dr Hugo Brandt }
- { id: penny, label: Miss Penny Lowe }
- { id: abel, label: Abel Mokoena }
answers: ["vera"]
hints:
- If nobody used the corridor, how did someone get from carriage 6 back to carriage 4?
- The porter saw a lady in a fur coat. Who packed a fur coat?
- Countess Orlova.
successText: |
**Countess Vera Orlova.** She walked the platform to dodge the steward in the corridor.
Carriage 4 is hers. Carriage 6 is the Gold Suite. And her "loose" hatpin is now missing from her hat.
keyName: Platform Key
- id: ch5
title: Ashby's Last Note
narrative: |
In the Gold Suite's writing desk: Ashby's notebook. One line of numbers, underlined twice.
clues:
A:
- id: a5-notebook
title: Ashby's notebook, last entry
type: note
body: |
*The "Countess". Her stones are paste. Ask the line, it knows:*
## V. = 3 · 7 · 8 · 4 · 2 · 8
*Confirm with Hugo at Kimberley. Then the police.*
B:
- id: b5-stops
title: Karoo branch stopping list (numbered)
type: document
body: |
1. Ashton
2. Elandsdrift
3. Fraserburg Road
4. Grootvlei
5. Klaarstroom
6. Matjiesfontein
7. Ottersrust
8. Rietbron
9. Touws River
lock:
id: lock-motive
prompt: What did Ashby discover Vera was?
inputType: text
format: One word
answers: ["FORGER", "AFORGER", "JEWELFORGER"]
hints:
- '"Ask the line": the numbers point at the railway stops.'
- Take the first letter of each numbered stop.
- 3=F, 7=O, 8=R, 4=G, 2=E, 8=R.
successText: |
**FORGER.** Ashby, the diamond king, saw it over dinner: the Countess's diamonds were fakes.
And so was the Countess. Vera Orlova had sold forged stones to half of Johannesburg society.
keyName: Diamond Key
finale:
type: accusation
prompt: Kimberley is in sight. Name the killer before the train stops.
maxAttempts: 2
questions:
- id: who
label: Who killed Lord Ashby?
options:
- { id: thomas, label: Thomas Webb }
- { id: vera, label: Countess Vera Orlova }
- { id: hugo, label: Dr Hugo Brandt }
- { id: penny, label: Miss Penny Lowe }
- { id: abel, label: Abel Mokoena }
answer: vera
- id: how
label: How?
options:
- { id: hatpin, label: Stabbed at the base of the skull with her hatpin }
- { id: poison, label: Poison in his nightcap }
- { id: strangled, label: Strangled with a silk scarf }
- { id: watch, label: Struck with his own pocket watch }
answer: hatpin
- id: why
label: Why?
options:
- { id: will, label: She was named in his will }
- { id: expose, label: He had found out she was a jewel forger and was going to the police }
- { id: love, label: He rejected her advances }
- { id: debt, label: She owed him money }
answer: expose
successEnding: |
The Countess laughs, then stops. "He looked at my necklace over the soup and *smiled*.
I knew that smile. I've seen it on jewellers."
At Matjiesfontein she stepped onto the platform, slipped into the Gold Suite from outside,
and drove her hatpin home while the train stood still. Ten minutes. Back in her bed by 03:20.
**Case closed.** The Southern Star rolls into Kimberley on time. 🚂🔑
failEnding: |
The Countess steps off at Kimberley, smiles at the police, and vanishes into the crowd.
reveal: |
**Countess Vera Orlova** stabbed Ashby with her **hatpin** during the Matjiesfontein stop,
because he'd discovered she was a **jewel forger** and was going to the police.
+196
View File
@@ -0,0 +1,196 @@
id: station-echo
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
difficulty: 2
players: { min: 2, max: 2 }
theme: { primary: "#2b6f8a", accent: "#7fe3ff", background: "#060d12", headingFont: "Playfair Display" }
roles:
A:
name: Comms Officer
blurb: You're locked in the radio room. Manuals, crew records, the commander's logs.
B:
name: Engineer
blurb: You're in the generator bay. Panels, plaques, sensors, and a blinking warning light.
intro: |
**Station Echo**, Antarctica. Winter. Six months of night.
A storm knocked out the main power at 02:14. The rest of the crew evacuated on the last shuttle.
You two missed it. Now you're sealed in separate modules, talking over a crackly intercom.
Emergency air: **45 minutes.** Restore the generator, find the leak, and call home.
chapters:
- id: ch1
title: The Warning Light
narrative: |
In the generator bay, a red warning lamp is blinking a pattern. Over and over.
The station computer is down. That light is trying to tell you something.
clues:
A:
- id: a1-morse
title: Radio room laminated card
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 `--..` | | | | |
*Short flash = dot. Long flash = dash. Long dark gap = new letter.*
B:
- id: b1-lamp
title: Generator bay warning lamp
type: signal
signal: "...-/./-./-"
body: |
A red lamp, pulsing in short and long flashes, then going dark and starting again.
Describe what you see to the Comms Officer.
lock:
id: lock-lamp
prompt: What is the warning light saying?
inputType: text
format: One word
answers: ["VENT"]
hints:
- The Engineer should call out "short" or "long" for each flash, and say when there's a gap.
- There are four letters. The first is three short flashes and a long one.
- V · E · N · T.
successText: |
**VENT.** The air vents are open to a breached module. Somewhere, your air is leaking out.
A panel lights up: *SEAL BREACHED MODULE TO CONTINUE.*
keyName: Signal Key
- id: ch2
title: Find the Breach
narrative: |
Five modules. One of them is bleeding air. Seal the wrong one, and you waste the time you don't have.
clues:
A:
- id: a2-readings
title: Live pressure readings (radio room screen)
type: table
body: |
| Module | Code | Pressure now (kPa) |
|-----------------|-------|--------------------|
| Habitat | HAB-1 | 98.1 |
| Laboratory | LAB-2 | 85.0 |
| Greenhouse | GRN-3 | 64.0 |
| Comms | COM-4 | 70.2 |
| Docking bay | DCK-5 | 110.0 |
B:
- id: b2-baseline
title: Engineering plaque, generator bay
type: document
body: |
**NORMAL OPERATING PRESSURE**
HAB-1: 101.3 · LAB-2: 101.3 · GRN-3: 95.0 · COM-4: 90.0 · DCK-5: 120.0 (kPa)
⚠️ **A module is BREACHED when its pressure has dropped by MORE THAN 30% of normal.**
lock:
id: lock-breach
prompt: Which module is breached? (module code)
inputType: text
format: e.g. ABC-1
answers: ["GRN3", "GREENHOUSE", "GRN"]
hints:
- Work out each module's drop as a percentage of its own normal pressure.
- Comms dropped about 22%. Lab dropped about 16%. One drops by over 30%.
- "Greenhouse: 95 → 64 is a drop of about 33%."
successText: |
Blast doors thunder shut on the **Greenhouse**. The hissing stops.
The air gauge steadies. Now get the power back on.
keyName: Seal Key
- id: ch3
title: Generator Restart
narrative: |
The generator console wants a restart code. A sticky note is taped to the console.
clues:
A:
- id: a3-roster
title: Crew roster (radio room)
type: table
body: |
| Crew member | Status at 02:30 |
|---------------|-----------------|
| Cmdr Okafor | Evacuated ✈️ |
| Dr Lindqvist | Evacuated ✈️ |
| Dr Petersen | Aboard |
| Sgt Moyo | Aboard |
| Dr Chen | Evacuated ✈️ |
| Tech Ruiz | Evacuated ✈️ |
| **You (Comms)** | Aboard |
| **You (Engineer)** | Aboard |
| Cook Dlamini | Aboard |
B:
- id: b3-sticky
title: Sticky note on the console
type: note
body: |
*Restart code = our call-sign number × crew still aboard. Don't forget to count yourselves! — Ruiz*
- id: b3-callsign
title: Station plaque
type: photo
body: |
*A brass plaque by the door: **STATION ECHO · CALL SIGN EC-127**.*
lock:
id: lock-generator
prompt: Enter the generator restart code.
inputType: code
format: Up to 4 digits
answers: ["635"]
hints:
- You need the call-sign number and the number of people still aboard.
- Five people are still aboard, including the two of you.
- 127 × 5 = 635.
successText: |
The generator coughs, then roars. Lights flood every module.
The radio room's long-range transmitter warms up. One more message and you're going home.
keyName: Power Key
finale:
type: escape
prompt: The transmitter is receiving a coded beacon from the rescue plane. Reply with the same word to confirm.
clues:
A:
- id: af-beacon
title: Radio room beacon lamp
type: signal
signal: "..../---/--/."
body: |
The incoming-signal lamp is flashing a pattern from the rescue plane.
Describe it to the Engineer.
B:
- id: bf-chart
title: Morse chart taped inside the generator panel
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 `--..` | | | | |
lock:
id: lock-beacon
prompt: Transmit the beacon word.
inputType: text
format: One word
answers: ["HOME"]
hints:
- Roles have swapped. This time the Comms Officer reads the light.
- Four letters. The first is four short flashes.
- H · O · M · E.
successEnding: |
**HOME.** The radio crackles: *"Echo, we read you. Wheels down in ten. Put the kettle on."*
Through the frost on the window, landing lights cut through the polar night.
**You escaped!** 📡🔑
+151
View File
@@ -0,0 +1,151 @@
id: tick-tock-together
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
players: { min: 2, max: 2 }
theme: { primary: "#b3261e", accent: "#ffcc00", background: "#0e0e0e", headingFont: "JetBrains Mono" }
roles:
A:
name: Technician
blurb: You see the device. You can't see the manual. Describe everything.
B:
name: Expert
blurb: You have the manual. You can't see the device. Ask the right questions.
intro: |
A cartoonishly suspicious box has been left at your favourite restaurant, ticking.
It's a training device (probably). Still, let's not find out.
**Technician:** describe what you see. **Expert:** find the rule and tell them what to do.
Three strikes and it's over. Every strike makes the clock run faster.
# ---------- Device definition ----------
device:
serial:
length: 6
alphabet: "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789" # last char is ALWAYS a digit
batteries: { min: 0, max: 4 }
levels:
- id: lvl1
title: Wire Warm-up
timeSeconds: 180
modules: [wires]
keyName: Red Wire Key
- id: lvl2
title: Push It
timeSeconds: 240
modules: [wires, button]
keyName: Big Button Key
- id: lvl3
title: Hearts & Symbols
timeSeconds: 300
modules: [wires, button, keypad]
keyName: Heart Key
strikes: { max: 3, speedUpPerStrike: 0.25 }
# ---------- Module generation ----------
modules:
wires:
count: { min: 3, max: 5 }
colors: [red, blue, yellow, green, white]
# Technician view: wires listed top to bottom, each with colour swatch + colour NAME label.
# Action: { type: "cut", index: 1-based }
button:
labels: [HOLD, PRESS, ABORT, LOVE]
colors: [red, blue, white, yellow]
stripColors: [blue, white, yellow, red]
# Strip colour is generated up front but only shown to the Technician WHILE holding.
# Actions: { type: "tap" } or { type: "hold" } then { type: "release", timerText }
# Server checks the release against the timer value it computes itself.
keypad:
symbols:
heart: "♥ Heart"
star: "★ Star"
moon: "☾ Moon"
sun: "☀ Sun"
club: "♣ Club"
flower: "✿ Flower"
umbrella: "☂ Umbrella"
plane: "✈ Plane"
music: "♫ Music note"
anchor: "⚓ Anchor"
diamond: "♦ Diamond"
spade: "♠ Spade"
columns:
- [heart, star, moon, sun, club, flower]
- [umbrella, heart, plane, music, star, anchor]
- [diamond, sun, spade, umbrella, flower, music]
- [anchor, spade, club, plane, moon, diamond]
# Generator: pick a column at random, pick 4 symbols from it, shuffle.
# Columns share at most 2 symbols, so every 4-symbol set matches exactly one column.
# Action: { type: "press", symbol } one at a time.
# ---------- The manual (shown ONLY to the Expert) ----------
manual: |
# Bomb Defusal Manual (Couples' Edition)
**Ask your Technician for the serial number and battery count first. You'll need both.**
---
## Module: Wires
Wires are numbered **top to bottom**, starting at 1. Cut exactly one.
Check these rules **in order**. Use the **first** one that applies.
1. If there are **exactly two red** wires → cut the **second red** wire.
2. If the serial number's **last digit is even** and there is **any yellow** wire → cut the **first yellow** wire.
3. If there are **no blue** wires → cut the **last** wire.
4. If there are **exactly five** wires → cut the **fourth** wire.
5. Otherwise → cut the **first** wire.
---
## Module: The Button
Check these rules **in order**. Use the **first** one that applies.
1. If the button says **LOVE** → **tap** it. (Obviously.)
2. If the button is **red** and says **HOLD** → **tap** it.
3. If there are **2 or more batteries** and the button says **PRESS** → **tap** it.
4. Otherwise → **hold** the button. A coloured strip lights up beside it. Release when the timer shows:
- **Blue** strip → a **4** anywhere on the timer
- **Yellow** strip → a **5** anywhere on the timer
- **Any other colour** → a **1** anywhere on the timer
---
## Module: Hearts & Symbols
The keypad shows four symbols. Exactly **one** column below contains all four.
Press the four symbols in the order they appear **from top to bottom** in that column.
| Column 1 | Column 2 | Column 3 | Column 4 |
|----------|----------|----------|----------|
| ♥ Heart | ☂ Umbrella | ♦ Diamond | ⚓ Anchor |
| ★ Star | ♥ Heart | ☀ Sun | ♠ Spade |
| ☾ Moon | ✈ Plane | ♠ Spade | ♣ Club |
| ☀ Sun | ♫ Music note | ☂ Umbrella | ✈ Plane |
| ♣ Club | ★ Star | ✿ Flower | ☾ Moon |
| ✿ Flower | ⚓ Anchor | ♫ Music note | ♦ Diamond |
# ---------- Solver fixtures (MUST pass as unit tests) ----------
fixtures:
wires:
- { serial: "TK4L82", batteries: 1, wires: [red, blue, red, white], expect: { type: cut, index: 3 } }
- { serial: "QX7M35", batteries: 0, wires: [yellow, blue, white], expect: { type: cut, index: 1 } }
- { serial: "ZP2R44", batteries: 2, wires: [green, yellow, white, yellow, red], expect: { type: cut, index: 2 } }
- { serial: "AB9C11", batteries: 3, wires: [red, green, white, yellow], expect: { type: cut, index: 4 } }
- { serial: "LM3N07", batteries: 1, wires: [blue, green, white, red, yellow], expect: { type: cut, index: 4 } }
button:
- { batteries: 0, label: LOVE, color: red, strip: blue, expect: { type: tap } }
- { batteries: 0, label: HOLD, color: red, strip: white, expect: { type: tap } }
- { batteries: 3, label: PRESS, color: blue, strip: yellow, expect: { type: tap } }
- { batteries: 1, label: PRESS, color: white, strip: blue, expect: { type: hold, releaseDigit: 4 } }
- { batteries: 2, label: ABORT, color: yellow, strip: yellow, expect: { type: hold, releaseDigit: 5 } }
- { batteries: 4, label: HOLD, color: blue, strip: red, expect: { type: hold, releaseDigit: 1 } }
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] }
+363
View File
@@ -0,0 +1,363 @@
id: vineyard-hollow
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
difficulty: 2
players: { min: 2, max: 2 }
theme: { primary: "#7b1e3a", accent: "#d4a373", background: "#160c10", headingFont: "Playfair Display" }
roles:
A:
name: The Inspector
blurb: You hold the police file. Statements, the medical examiner, the lab.
B:
name: The Estate Insider
blurb: You know the estate. Logs, maps, the family's private papers.
intro: |
Saturday night. The Hale family's annual harvest gala at **Vineyard Hollow**.
Candles, jazz on the lawn, two hundred guests in their best.
At 23:05, Clara Hale goes down to the barrel cellar to find her husband, **Edmund Hale**,
who promised to open "something special" for the toast. She finds him slumped against
Rack 9, a wine glass shattered beside him.
The police have locked down the estate. Nobody leaves until the truth comes out.
One of you has the official file. The other knows every corner of this estate.
**Neither of you can crack this alone.**
suspects:
- id: clara
name: Clara Hale
role: Wife of the victim
description: Hostess of the gala. Married to Edmund for 37 years. Composed. Too composed?
- id: marcus
name: Marcus Hale
role: Son and heir
description: Charming, broke, and very interested in his inheritance.
- id: nina
name: Dr Nina Vos
role: Family doctor
description: Treats Edmund's heart condition. Carries her medical bag everywhere.
- id: pieter
name: Pieter Swart
role: Head winemaker
description: Twenty years at Vineyard Hollow. Made the wines that made the Hale name.
- id: isabelle
name: Isabelle Rourke
role: Wine buyer
description: Represents a group that has been trying to buy the estate for two years.
chapters:
- id: ch1
title: The Cellar Door
narrative: |
The security system re-locked the cellar after the police went down.
The keypad blinks red, waiting for a four-digit code. The officer who knew it went home.
clues:
A:
- id: a1-butler
title: Statement of Hendricks, the butler
type: note
body: |
"Mr Hale changed the cellar code last month. He was very pleased with himself.
He said it was *the year it all began*, read the way wine is tasted in his family:
**from the finish back to the start.** I'm afraid I never worked it out."
B:
- id: b1-plaque
title: Photo of the main gate
type: photo
body: |
*A brass plaque on a stone gatepost, polished to a shine:*
**VINEYARD HOLLOW · EST. 1874 · Stellenbosch Valley**
- id: b1-wedding
title: Framed photo in the hallway
type: photo
body: |
*Edmund and Clara on their wedding day under an oak tree.
Engraved on the frame: "E & C · 1989".*
shared:
- id: s1-suspects
title: Who was at the gala
type: document
body: |
Five people had reason to be near the cellar tonight. See the suspect list.
Officers note everyone was in good spirits until around 20:00.
lock:
id: lock-cellar
prompt: Enter the 4-digit cellar code.
inputType: code
format: 4 digits
answers: ["4781"]
hints:
- The butler's note tells you HOW to build the code. The estate tells you WHAT number to use.
- "\"The year it all began\" is the year the estate was founded."
- 1874 read backwards is 4781.
successText: |
The keypad clicks green. Cold air rolls up the stone stairs.
The barrel cellar smells of oak, and of something bitter underneath.
keyName: Cellar Key
- id: ch2
title: The Hour of Death
narrative: |
Edmund lies where he fell. The medical examiner has taken her readings.
The cellar has a keycard reader that logs every entry and exit.
When did Edmund die? And who was down here with him?
clues:
A:
- id: a2-me
title: Medical examiner's preliminary note
type: report
body: |
**Subject:** Edmund Hale, 64
**Core body temperature at 23:10:** 34.5 °C
**Normal living temperature:** 37.0 °C
The cellar is held at a constant 14 °C. Under these conditions the body
will have cooled at approximately **1.0 °C per hour**.
Estimated margin: plus or minus 15 minutes.
- id: a2-statements
title: Suspect statements (summary)
type: document
body: |
- **Clara:** "I was hosting on the lawn all night. I went down at five past eleven and found him."
- **Marcus:** "I went down around half nine to talk to Dad. Lights were off. I figured he'd gone up."
- **Dr Vos:** "Edmund asked me to check his heart in the cellar. I left him at half past eight, alive and grumbling about the band."
- **Pieter Swart:** "I lost my keycard around eight. Someone must have taken it. I was in the tasting room all evening."
- **Isabelle Rourke:** "I never went near the cellar. Check your little log."
B:
- id: b2-log
title: Cellar keycard log, Saturday
type: log
body: |
| Time | Card holder | Event |
|-------|------------------|-------|
| 20:10 | Edmund Hale | IN |
| 20:18 | Dr Nina Vos | IN |
| 20:31 | Dr Nina Vos | OUT |
| 20:35 | Pieter Swart | IN |
| 20:52 | Pieter Swart | OUT |
| 21:30 | Marcus Hale | IN |
| 21:33 | Marcus Hale | OUT |
| 23:05 | Clara Hale | IN |
| 23:10 | POLICE OVERRIDE | IN |
*Isabelle Rourke was not issued a cellar card.*
lock:
id: lock-tod
prompt: What was Edmund's time of death? (24-hour time)
inputType: code
format: "HH:MM"
answers: ["2040", "840PM", "840"]
hints:
- Work out how many degrees Edmund's body lost after death.
- He lost 2.5 °C. At 1 °C per hour, that's 2 hours 30 minutes.
- 23:10 minus 2h30 = 20:40.
successText: |
**Time of death: about 20:40 (± 15 minutes).**
Check the log. In that window, two cards were in the cellar:
**Dr Nina Vos** (until 20:31) and **Pieter Swart** (20:35 to 20:52).
Marcus came much later. By 21:30, Edmund had been dead almost an hour.
keyName: Pocket Watch Key
- id: ch3
title: The Poisoned Bottle
narrative: |
No wound. No struggle. The lab rushed the glass by Edmund's hand.
Six bottles were opened in the cellar tonight. Only one killed him.
clues:
A:
- id: a3-lab
title: Forensic lab, rush result
type: report
body: |
Residue in the victim's glass: **a cardiac glycoside** (a heart toxin).
The wine in the glass came from a source bottle that was:
- a **red** wine
- **decanted** before serving
- sealed with a cork whose lot number **ends in 7**
B:
- id: b3-cellarbook
title: Sommelier's cellar book, bottles opened tonight
type: table
body: |
| Wine | Colour | Cork lot | Decanted? |
|-----------------------|--------|----------|-----------|
| 2015 Chenin Blanc | White | VH-1157 | Yes |
| 2004 Pinotage | Red | VH-3302 | Yes |
| 2010 Merlot | Red | VH-4417 | No |
| 1998 Cabernet Reserve | Red | VH-2207 | Yes |
| 2019 Rosé | Pink | VH-5527 | No |
| 2012 Shiraz | Red | VH-6608 | Yes |
*Margin note: "1998 Reserve brought up from Rack 9 at Mr Hale's request."*
lock:
id: lock-bottle
prompt: Which bottle was poisoned? (vintage year or name)
inputType: text
format: e.g. a year or wine name
answers: ["1998", "1998CABERNETRESERVE", "CABERNETRESERVE", "1998RESERVE", "RESERVE", "VH2207", "2207"]
hints:
- Filter the cellar book using all three lab conditions at once.
- Red + decanted leaves Pinotage, Cabernet Reserve and Shiraz. Now check the cork lots.
- Only VH-2207 ends in 7, so it's the 1998 Cabernet Reserve.
successText: |
**The 1998 Cabernet Reserve.** Edmund's most treasured wine, the vintage that won
Vineyard Hollow its first gold medal. He kept it in **Rack 9**, his private rack,
and only opened it for the people closest to him.
Whoever poisoned it knew exactly what he'd pour tonight.
keyName: Silver Corkscrew Key
- id: ch4
title: Edmund's Diary
narrative: |
In Edmund's jacket: a leather pocket diary. The last entry is scrambled.
Edmund loved puzzles. His family says he "hid his secrets where he hid his heart."
clues:
A:
- id: a4-diary
title: Diary, final entry
type: note
body: |
*Clara mustn't see this yet. Rack-shift, as always.*
## ORAN BFJAC OARMJH
*(Below, in ordinary ink:) Tell Rourke the sale is on, once the label audit is done.*
- id: a4-alphabet
title: Alphabet strip (from the evidence kit)
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
1 2 3 4 5 6 7 8 9 ... 26
```
B:
- id: b4-map
title: Barrel cellar map
type: map
body: |
```
[Rack 1] Chenin Blanc [Rack 6] Pinotage
[Rack 2] Sauvignon Blanc [Rack 7] Shiraz
[Rack 3] Rosé [Rack 8] Export stock
[Rack 4] Merlot [Rack 9] 1998 Cabernet Reserve
[Rack 5] Blends ♥ E.H. PRIVATE ♥
```
- id: b4-clara
title: Clara's comment to the police
type: note
body: |
"Edmund used to say Rack 9 was where he kept his heart. Sentimental old fool."
lock:
id: lock-diary
prompt: Decode the diary entry.
inputType: text
format: The decoded message (3 words)
answers: ["FIRESWARTFRIDAY"]
hints:
- Every letter is shifted along the alphabet by the same number. "Rack-shift" tells you which number.
- His heart is in Rack 9. Shift every letter BACK by 9.
- O→F, R→I, A→R, N→E… The first word is FIRE.
successText: |
**"FIRE SWART FRIDAY."**
Edmund was about to fire his head winemaker of twenty years.
And the line underneath, "once the label audit is done," starts to look a lot less innocent.
An auditor's letter in the study confirms it: years of cheap blends sold as Reserve.
keyName: Diary Key
- id: ch5
title: The Source of the Poison
narrative: |
Dr Vos carries heart medication in her bag. The obvious answer. Too obvious?
The full toxicology report has just come in.
clues:
A:
- id: a5-tox
title: Full toxicology addendum
type: report
body: |
The glycoside is **not** pharmaceutical digoxin. No tablet binders, coatings, or fillers.
Profile matches a **crude extract made from fresh leaves** of *Digitalis purpurea*,
a common garden plant with tall purple bell-shaped flowers.
- id: a5-bag
title: Dr Vos, medical bag inventory
type: table
body: |
| Item | Count |
|--------------------------|-------|
| Digoxin 0.25mg tablets | 30 / 30 (seal unbroken) |
| Stethoscope | 1 |
| BP cuff | 1 |
B:
- id: b5-groundskeeper
title: Groundskeeper's notebook
type: note
body: |
**Thu:** Someone's been cutting leaves in the foxglove bed behind the winery workshop.
Only reachable through the workshop. Only key is Mr Swart's.
**Sat 22:00:** Tidying the workshop. Found a cellar keycard in Mr Swart's apron pocket.
Put it on his bench. Thought he said he'd lost it?
lock:
id: lock-plant
prompt: Name the plant the poison came from.
inputType: text
format: One word
answers: ["FOXGLOVE", "DIGITALIS", "DIGITALISPURPUREA", "FOXGLOVES"]
hints:
- The lab gives the scientific name; the estate gives the common one.
- Digitalis purpurea = the plant growing behind the workshop.
- Foxglove.
successText: |
**Foxglove.** Dr Vos's tablets are untouched.
The poison came from a bed only reachable through Pieter Swart's workshop.
And the keycard he swore he'd lost was in his own apron, an hour after the murder.
keyName: Workshop Key
finale:
type: accusation
prompt: You have all five keys. Make your accusation together.
maxAttempts: 2
questions:
- id: who
label: Who killed Edmund Hale?
options:
- { id: clara, label: Clara Hale }
- { id: marcus, label: Marcus Hale }
- { id: nina, label: Dr Nina Vos }
- { id: pieter, label: Pieter Swart }
- { id: isabelle, label: Isabelle Rourke }
answer: pieter
- id: how
label: How?
options:
- { id: tablets, label: Crushed digoxin tablets from Dr Vos's bag }
- { id: foxglove, label: Foxglove leaf extract in the 1998 Reserve }
- { id: chenin, label: Poison in the Chenin Blanc }
- { id: blow, label: A blow to the head in the dark }
answer: foxglove
- id: why
label: Why?
options:
- { id: debts, label: To inherit and clear gambling debts }
- { id: affair, label: Jealousy over an affair }
- { id: fraud, label: To stop being fired and exposed for label fraud }
- { id: sale, label: To sabotage the sale and buy the estate cheap }
answer: fraud
successEnding: |
Pieter Swart sets down his glass and says nothing for a long time.
"Twenty years," he finally says. "Every medal on that wall is mine. He was going to
throw me out and tell the world I was a fraud."
He brewed the foxglove in his workshop, slipped into the cellar at 20:35 while Edmund
decanted the Reserve for the toast, and claimed his card was "lost" to cover the log.
**Case closed.** Well played, detectives. 🍷🔑
failEnding: |
The police make an arrest on your theory. Three weeks later the case collapses
and the real killer is long gone.
reveal: |
**Pieter Swart**, with **foxglove extract in the 1998 Reserve**, to stop Edmund
**firing him and exposing years of label fraud**.
+1
View File
@@ -0,0 +1 @@
console.log('Happy developing ✨')
+7341
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "two-keys",
"private": true,
"version": "1.0.0",
"description": "Co-op mystery games for couples",
"workspaces": ["shared", "server", "client"],
"engines": { "node": ">=20" },
"scripts": {
"dev": "npm run validate-content && concurrently -n server,client \"npm -w server run dev\" \"npm -w client run dev\"",
"build": "npm -w shared run build && npm -w client run build && npm -w server run build",
"start": "npm run validate-content && node server/dist/index.js",
"test": "npm -w shared test && npm -w server test",
"validate-content": "tsx server/src/scripts/validate-content.ts"
},
"devDependencies": {
"concurrently": "^9.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0"
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@two-keys/server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc --noEmit -p tsconfig.json && esbuild src/index.ts --bundle --platform=node --format=esm --target=node20 --packages=external --alias:@two-keys/shared=../shared/src/index.ts --outfile=dist/index.js",
"start": "node dist/index.js",
"test": "vitest run"
},
"dependencies": {
"@two-keys/shared": "*",
"express": "^4.21.0",
"js-yaml": "^4.1.0",
"socket.io": "^4.8.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.16.0",
"esbuild": "^0.24.0",
"socket.io-client": "^4.8.0",
"vitest": "^2.1.0"
}
}
+66
View File
@@ -0,0 +1,66 @@
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import express from 'express';
import { Server } from 'socket.io';
import { findRoot, gamesDir, loadGames } from './content/loader';
import { gameSummary } from './content/sanitize';
import { RoomStore } from './rooms/store';
import { registerHandlers } from './socket/handlers';
export interface AppOptions {
root?: string;
/** Where rooms are snapshotted; null keeps them in memory only. */
dataFile?: string | null;
}
export function createApp(opts: AppOptions = {}) {
const root = opts.root ?? findRoot();
const games = loadGames(gamesDir(root));
const dataFile = opts.dataFile === undefined ? path.join(root, 'server', 'data', 'rooms.json') : opts.dataFile;
const store = new RoomStore(dataFile);
store.load((gameId) => games.has(gameId));
const app = express();
app.disable('x-powered-by');
const summaries = [...games.values()].map(gameSummary);
app.get('/api/health', (_req, res) => res.json({ ok: true, games: games.size, rooms: store.rooms.size }));
app.get('/api/games', (_req, res) => res.json(summaries));
// In production the same process serves the built client.
const clientDist = path.join(root, 'client', 'dist');
if (fs.existsSync(path.join(clientDist, 'index.html'))) {
app.use(express.static(clientDist));
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/socket.io')) return next();
res.sendFile(path.join(clientDist, 'index.html'));
});
}
const server = http.createServer(app);
const io = new Server(server, { maxHttpBufferSize: 64 * 1024 });
const stopTicker = registerHandlers(io, games, store);
return {
app,
server,
io,
games,
store,
listen(port: number): Promise<number> {
return new Promise((resolve) => {
server.listen(port, () => {
store.start();
const address = server.address();
resolve(typeof address === 'object' && address ? address.port : port);
});
});
},
async close(): Promise<void> {
stopTicker();
store.stop();
await io.close();
},
};
}
+66
View File
@@ -0,0 +1,66 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';
import { gameSchema, type Game } from './schemas';
import { validateGame } from './validate';
/** The repo root: the nearest ancestor of this file that contains content/games. */
export function findRoot(): string {
if (process.env.TWO_KEYS_ROOT) return path.resolve(process.env.TWO_KEYS_ROOT);
let dir = path.dirname(fileURLToPath(import.meta.url));
for (;;) {
if (fs.existsSync(path.join(dir, 'content', 'games'))) return dir;
const parent = path.dirname(dir);
if (parent === dir) throw new Error('Could not find content/games above ' + import.meta.url);
dir = parent;
}
}
export const gamesDir = (root = findRoot()) => path.join(root, 'content', 'games');
export interface LoadedFile {
file: string;
game: Game | null;
errors: string[];
}
export function loadGameFile(file: string): LoadedFile {
const name = path.basename(file);
let raw: unknown;
try {
raw = yaml.load(fs.readFileSync(file, 'utf8'));
} catch (err) {
return { file: name, game: null, errors: [`YAML parse error: ${(err as Error).message}`] };
}
const parsed = gameSchema.safeParse(raw);
if (!parsed.success) {
const errors = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`);
return { file: name, game: null, errors };
}
const game = parsed.data;
const errors = validateGame(game);
const expectedId = name.replace(/\.ya?ml$/, '');
if (game.id !== expectedId) errors.push(`id "${game.id}" does not match filename "${name}"`);
return { file: name, game, errors };
}
export function loadAllFiles(dir = gamesDir()): LoadedFile[] {
return fs
.readdirSync(dir)
.filter((f) => /\.ya?ml$/.test(f))
.sort()
.map((f) => loadGameFile(path.join(dir, f)));
}
/** Loads every game at boot; refuses to start on invalid content. */
export function loadGames(dir = gamesDir()): Map<string, Game> {
const games = new Map<string, Game>();
const problems: string[] = [];
for (const { file, game, errors } of loadAllFiles(dir)) {
if (errors.length > 0 || !game) problems.push(...errors.map((e) => `${file}: ${e}`));
else games.set(game.id, game);
}
if (problems.length > 0) throw new Error(`Invalid game content:\n ${problems.join('\n ')}`);
return games;
}
+273
View File
@@ -0,0 +1,273 @@
// 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 {
ChapterView,
ClueView,
DefuseModuleView,
DefuseView,
Dir,
EngineView,
GameMeta,
GameSummary,
LabyrinthView,
LockView,
Role,
StoryView,
SyncView,
} 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 type { Room } from '../rooms/store';
import type { Clue, DefuseGame, Game, LabyrinthGame, Lock, StoryGame, SyncGame } from './schemas';
export function gameSummary(game: Game): GameSummary {
return {
id: game.id,
engine: game.engine,
category: game.category,
title: game.title,
tagline: game.tagline,
estimatedMinutes: game.estimatedMinutes,
difficulty: game.difficulty,
theme: { ...game.theme },
roles: { A: { ...game.roles.A }, B: { ...game.roles.B } },
totalKeys: totalKeys(game),
};
}
export function gameMeta(game: Game): GameMeta {
return { ...gameSummary(game), intro: game.intro };
}
// ---------- story ----------
function clueView(clue: Clue, audience: ClueView['audience']): ClueView {
const view: ClueView = { id: clue.id, title: clue.title, type: clue.type, body: clue.body, audience };
if (clue.type === 'signal' && clue.signal) view.signal = clue.signal;
return view;
}
function lockView(lock: Lock, s: StoryState): LockView {
const solved = s.solved.includes(lock.id);
const wrongAttempts = s.wrong[lock.id] ?? 0;
return {
id: lock.id,
prompt: lock.prompt,
inputType: lock.inputType,
format: lock.format ?? null,
options: lock.options?.map((o) => ({ id: o.id, label: o.label })) ?? null,
solved,
hints: lock.hints.slice(0, s.hints[lock.id] ?? 0),
hintsTotal: lock.hints.length,
wrongAttempts,
nudge: !solved && wrongAttempts >= NUDGE_AFTER_WRONG,
successText: solved ? (lock.successText ?? null) : null,
keyName: solved ? (lock.keyName ?? null) : null,
};
}
export function storyView(game: StoryGame, s: StoryState, role: Role): StoryView {
const total = game.chapters.length;
const atFinale = s.chapter >= total;
const chapters: ChapterView[] = game.chapters.slice(0, Math.min(s.chapter, total - 1) + 1).map((ch) => ({
id: ch.id,
title: ch.title,
narrative: ch.narrative,
clues: [
...ch.clues[role].map((c) => clueView(c, 'private')),
...ch.clues.shared.map((c) => clueView(c, 'shared')),
],
lock: lockView(ch.lock, s),
}));
let finale: StoryView['finale'] = null;
if (atFinale && game.finale.type === 'accusation') {
const f = game.finale;
finale = {
type: 'accusation',
prompt: f.prompt,
questions: f.questions.map((q) => ({
id: q.id,
label: q.label,
options: q.options.map((o) => ({ id: o.id, label: o.label })),
})),
proposal: s.finale.proposal
? { answers: { ...s.finale.proposal.answers }, by: s.finale.proposal.by }
: null,
agreed: s.finale.agreed,
attemptsLeft: Math.max(0, f.maxAttempts - s.finale.attempts),
maxAttempts: f.maxAttempts,
};
} else if (atFinale && game.finale.type === 'escape') {
const f = game.finale;
finale = {
type: 'escape',
prompt: f.prompt,
clues: f.clues[role].map((c) => clueView(c, 'private')),
lock: lockView(f.lock, s),
};
}
return {
engine: 'story',
suspects: (game.suspects ?? []).map((x) => ({ ...x })),
chapters,
currentChapter: Math.min(s.chapter, total),
totalChapters: total,
awaitingContinue: s.awaitingContinue,
finale,
};
}
// ---------- defuse ----------
export function defuseView(game: DefuseGame, s: DefuseState, role: Role, now: number): DefuseView {
const level = game.levels[s.level];
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,
clock: s.clock
? { remaining: Math.max(0, remainingAt(s, now)), speed: s.clock.speed, running: s.status === 'active' }
: null,
};
// The Expert gets the manual and nothing about the device.
if (role === 'B') 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] });
if (d.button) {
modules.push({
id: 'button',
solved: d.button.solved,
label: d.button.label,
color: d.button.color,
holding: d.button.holding,
// The strip only lights up while the button is held.
strip: d.button.holding ? d.button.strip : null,
});
}
if (d.keypad) {
const { pressed } = d.keypad;
modules.push({
id: 'keypad',
solved: d.keypad.solved,
symbols: d.keypad.symbols.map((id) => ({
id,
label: game.modules.keypad.symbols[id] ?? id,
pressed: pressed.includes(id),
})),
});
}
return { ...common, seat: 'technician', device: { serial: d.serial, batteries: d.batteries, 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 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;
return {
engine: 'sync',
status: s.status,
round: {
index: s.round,
total: game.rounds.length,
title: round.title,
type: round.type,
blurb: round.blurb,
subject,
},
isReplay: s.attempt > 0,
questionNumber: s.q + 1,
questionsPerRound: s.questions.length,
pointsForKey: game.rules.pointsForKey,
points: s.marks.filter(Boolean).length,
question: q
? {
id: q.id,
text: q.q.replaceAll('{subject}', subject ? game.roles[subject].name : ''),
options: [...q.options],
}
: 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,
marks: [...s.marks],
summary:
s.status === 'summary'
? { earned: s.earned, canReplay: canReplay(game, s), keyName: round.keyName }
: null,
};
}
// ---------- labyrinth ----------
export function labyrinthView(game: LabyrinthGame, s: LabyrinthState, role: Role): LabyrinthView {
const keysCarried = s.collected.filter(Boolean).length;
const total = game.lanterns.length;
// Navigator: the whole map, but never the Explorer's coordinates.
if (role === 'A') {
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] })),
keysCarried,
totalKeys: total,
moves: game.navigatorView.showMoveCount ? s.moves : null,
falls: s.falls,
};
}
// 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);
}
const glyph = game.glyphs.find((g) => g.x === s.x && g.y === s.y);
return {
engine: 'labyrinth',
seat: 'explorer',
glyphLabel: glyph?.label ?? null,
open,
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,
};
}
export function engineView(game: Game, room: Room, role: Role, now: number): EngineView {
switch (game.engine) {
case 'story':
return storyView(game, room.engine as StoryState, role);
case 'defuse':
return defuseView(game, room.engine as DefuseState, role, now);
case 'sync':
return syncView(game, room.engine as SyncState, role);
case 'labyrinth':
return labyrinthView(game, room.engine as LabyrinthState, role);
}
}
+229
View File
@@ -0,0 +1,229 @@
import { z } from 'zod';
import { CLUE_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() });
const header = {
id: z.string().regex(/^[a-z0-9-]+$/),
category: z.enum(['murder-mystery', 'escape-room', 'co-op']),
title: z.string().min(1),
tagline: z.string().min(1),
estimatedMinutes: z.number().positive(),
difficulty: z.number().int().min(1).max(3),
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 }),
intro: z.string().min(1),
};
// ---------- story ----------
const clueSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
type: z.enum(CLUE_TYPES),
body: z.string(),
signal: z
.string()
.regex(/^[.\-/]+$/, 'signal may only contain . - /')
.optional(),
});
const optionSchema = z.object({ id: z.string().min(1), label: z.string().min(1) });
const lockSchema = z.object({
id: z.string().min(1),
prompt: z.string().min(1),
inputType: z.enum(['code', 'text', 'choice']),
format: z.string().optional(),
options: z.array(optionSchema).min(2).optional(),
answers: z.array(z.string().min(1)).min(1),
hints: z.array(z.string().min(1)).length(3),
successText: z.string().optional(),
keyName: z.string().optional(),
});
const chapterSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
narrative: z.string(),
clues: z.object({
A: z.array(clueSchema).default([]),
B: z.array(clueSchema).default([]),
shared: z.array(clueSchema).default([]),
}),
lock: lockSchema.extend({ successText: z.string().min(1), keyName: z.string().min(1) }),
});
const finaleSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('accusation'),
prompt: z.string().min(1),
questions: z
.array(
z.object({
id: z.string().min(1),
label: z.string().min(1),
options: z.array(optionSchema).min(2),
answer: z.string().min(1),
}),
)
.min(1),
maxAttempts: z.number().int().positive(),
successEnding: z.string().min(1),
failEnding: z.string().min(1),
reveal: z.string().min(1),
}),
z.object({
type: z.literal('escape'),
prompt: z.string().min(1),
clues: z.object({ A: z.array(clueSchema).default([]), B: z.array(clueSchema).default([]) }),
lock: lockSchema,
successEnding: z.string().min(1),
}),
]);
const storySchema = z.object({
...header,
engine: z.literal('story'),
suspects: z
.array(z.object({ id: z.string(), name: z.string(), role: z.string(), description: z.string() }))
.optional(),
chapters: z.array(chapterSchema).min(1),
finale: finaleSchema,
});
// ---------- defuse ----------
const range = z.object({ min: z.number().int().nonnegative(), max: z.number().int().nonnegative() });
const moduleId = z.enum(['wires', 'button', 'keypad']);
const defuseSchema = z.object({
...header,
engine: z.literal('defuse'),
device: z.object({
serial: z.object({ length: z.number().int().min(2), alphabet: z.string().min(2) }),
batteries: range,
}),
levels: z
.array(
z.object({
id: z.string().min(1),
title: z.string().min(1),
timeSeconds: z.number().int().positive(),
modules: z.array(moduleId).min(1),
keyName: z.string().min(1),
}),
)
.min(1),
strikes: z.object({ max: z.number().int().positive(), speedUpPerStrike: z.number().nonnegative() }),
modules: z.object({
wires: z.object({ count: range, colors: z.array(z.string()).min(2) }),
button: z.object({
labels: z.array(z.string()).min(1),
colors: z.array(z.string()).min(1),
stripColors: z.array(z.string()).min(1),
}),
keypad: z.object({
symbols: z.record(z.string()),
columns: z.array(z.array(z.string()).min(4)).min(1),
}),
}),
manual: z.string().min(1),
fixtures: z.object({
wires: z.array(
z.object({
serial: z.string(),
batteries: z.number().int(),
wires: z.array(z.string()),
expect: z.object({ type: z.literal('cut'), index: z.number().int() }),
}),
),
button: z.array(
z.object({
batteries: z.number().int(),
label: z.string(),
color: z.string(),
strip: z.string(),
expect: z.union([
z.object({ type: z.literal('tap') }),
z.object({ type: z.literal('hold'), releaseDigit: z.number().int() }),
]),
}),
),
keypad: z.array(z.object({ symbols: z.array(z.string()), expect: z.array(z.string()) })),
}),
});
// ---------- sync ----------
const questionSchema = z.object({
id: z.string().min(1),
q: z.string().min(1),
options: z.array(z.coerce.string()).min(2),
});
const syncSchema = z.object({
...header,
engine: z.literal('sync'),
rules: z.object({
questionsPerRound: z.number().int().positive(),
pointsForKey: z.number().int().positive(),
replaysPerRound: z.number().int().nonnegative(),
}),
rounds: z
.array(
z.object({
id: z.string().min(1),
title: z.string().min(1),
type: z.enum(['match', 'predict']),
subject: z.enum(['A', 'B']).optional(),
blurb: z.string(),
keyName: z.string().min(1),
}),
)
.min(1),
match: z.array(questionSchema),
predict: z.array(questionSchema),
});
// ---------- labyrinth ----------
const labyrinthSchema = z.object({
...header,
engine: z.literal('labyrinth'),
grid: z.array(z.string().regex(/^[#.SEKT]+$/, 'grid may only contain # . S E K T')).min(3),
glyphs: z.array(
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) })),
explorerView: z.object({
flavour: z.object({
blank: z.string(),
trap: z.string(),
lantern: z.string(),
exitLocked: z.string(),
exitOpen: z.string(),
}),
}),
navigatorView: z.object({ showMoveCount: z.boolean().default(true) }),
scoring: z.object({ trapPenaltySeconds: z.number().nonnegative(), parMoves: z.number().int().positive() }),
successEnding: z.string().min(1),
});
export const gameSchema = z.discriminatedUnion('engine', [
storySchema,
defuseSchema,
syncSchema,
labyrinthSchema,
]);
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 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>;
+154
View File
@@ -0,0 +1,154 @@
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';
export const MIN_SYNC_QUESTIONS = 15;
/** Checks beyond the zod schema. Returns human-readable problems (empty = valid). */
export function validateGame(game: Game): string[] {
switch (game.engine) {
case 'story':
return validateStory(game);
case 'defuse':
return validateDefuse(game);
case 'sync':
return validateSync(game);
case 'labyrinth':
return validateLabyrinth(game);
}
}
function validateStory(game: StoryGame): string[] {
const errors: string[] = [];
const ids = new Set<string>();
const unique = (kind: string, id: string) => {
if (ids.has(`${kind}:${id}`)) errors.push(`duplicate ${kind} id "${id}"`);
ids.add(`${kind}:${id}`);
};
const checkClues = (where: string, clues: Clue[]) => {
for (const clue of clues) {
unique('clue', clue.id);
if (clue.type === 'signal' && !clue.signal) errors.push(`${where}: signal clue "${clue.id}" has no signal`);
}
};
const checkLock = (where: string, lock: Lock) => {
unique('lock', lock.id);
if (lock.answers.length < 1) errors.push(`${where}: lock has no answers`);
if (lock.hints.length !== 3) errors.push(`${where}: lock needs exactly 3 hints`);
if (lock.answers.some((a) => normalizeAnswer(a) === '')) errors.push(`${where}: an answer normalizes to nothing`);
if (lock.inputType === 'choice') {
const options = (lock.options ?? []).map((o) => normalizeAnswer(o.id));
if (options.length === 0) errors.push(`${where}: choice lock has no options`);
for (const a of lock.answers) {
if (!options.includes(normalizeAnswer(a))) errors.push(`${where}: answer "${a}" is not one of the options`);
}
}
};
for (const ch of game.chapters) {
const where = `chapter ${ch.id}`;
unique('chapter', ch.id);
if (ch.clues.A.length < 1) errors.push(`${where}: needs at least one clue for A`);
if (ch.clues.B.length < 1) errors.push(`${where}: needs at least one clue for B`);
checkClues(where, [...ch.clues.A, ...ch.clues.B, ...ch.clues.shared]);
checkLock(where, ch.lock);
}
if (game.finale.type === 'accusation') {
for (const q of game.finale.questions) {
if (!q.options.some((o) => o.id === q.answer)) {
errors.push(`finale question ${q.id}: answer "${q.answer}" is not one of the options`);
}
}
} else {
if (game.finale.clues.A.length < 1) errors.push('finale: needs at least one clue for A');
if (game.finale.clues.B.length < 1) errors.push('finale: needs at least one clue for B');
checkClues('finale', [...game.finale.clues.A, ...game.finale.clues.B]);
checkLock('finale', game.finale.lock);
}
return errors;
}
function validateDefuse(game: DefuseGame): string[] {
const errors: string[] = [];
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
const { fixtures, modules } = game;
fixtures.wires.forEach((f, i) => {
const got = solveWires(f.serial, 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);
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) => {
const got = solveKeypad(f.symbols, modules.keypad.columns);
if (!same(got, f.expect)) errors.push(`keypad fixture ${i + 1}: expected ${f.expect.join(',')}, solver says ${got?.join(',') ?? 'no unique column'}`);
});
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;
columns.forEach((col, i) => {
for (const s of col) if (!(s in symbols)) errors.push(`keypad column ${i + 1}: unknown symbol "${s}"`);
// Any 4 symbols must identify exactly one column.
for (let j = i + 1; j < columns.length; j++) {
if (col.filter((s) => columns[j].includes(s)).length > 3) errors.push(`keypad columns ${i + 1} and ${j + 1} share 4+ symbols`);
}
});
return errors;
}
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;
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`);
if (new Set(bank.map((q) => q.id)).size !== bank.length) errors.push(`${type} bank has duplicate question ids`);
}
for (const r of game.rounds) {
if (r.type === 'predict' && !r.subject) errors.push(`round ${r.id}: predict rounds need a subject`);
}
if (game.rules.pointsForKey > game.rules.questionsPerRound) errors.push('pointsForKey is more than questionsPerRound');
return errors;
}
function validateLabyrinth(game: LabyrinthGame): string[] {
const errors: string[] = [];
const { grid } = game;
if (new Set(grid.map((row) => row.length)).size !== 1) errors.push('grid rows are not all the same length');
const starts = findCells(grid, 'S');
const exits = findCells(grid, 'E');
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}`);
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`);
}
if (starts.length === 1) {
const reach = safeReachable(grid);
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`);
}
}
return errors;
}
+269
View File
@@ -0,0 +1,269 @@
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 ----------
export type WireSolution = { type: 'cut'; index: number };
export type ButtonSolution = { type: 'tap' } | { type: 'hold'; releaseDigit: number };
/** 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');
const yellows = indexesOf('yellow');
const lastDigit = Number(serial.match(/\d(?=\D*$)/)?.[0] ?? NaN);
if (reds.length === 2) return { type: 'cut', index: reds[1] };
if (lastDigit % 2 === 0 && yellows.length > 0) return { type: 'cut', index: yellows[0] };
if (!wires.includes('blue')) return { type: 'cut', index: wires.length };
if (wires.length === 5) return { type: 'cut', index: 4 };
return { type: 'cut', index: 1 };
}
export function solveButton(b: {
batteries: number;
label: string;
color: string;
strip: string;
}): ButtonSolution {
if (b.label === 'LOVE') return { type: 'tap' };
if (b.color === 'red' && b.label === 'HOLD') return { type: 'tap' };
if (b.batteries >= 2 && b.label === 'PRESS') return { type: 'tap' };
const releaseDigit = b.strip === 'blue' ? 4 : b.strip === 'yellow' ? 5 : 1;
return { type: 'hold', releaseDigit };
}
/** 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)));
if (matches.length !== 1) return null;
return matches[0].filter((s) => symbols.includes(s));
}
// ---------- Generator ----------
export function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export interface DefuseDevice {
serial: string;
batteries: number;
wires?: { colors: string[]; solved: boolean };
button?: { label: string; color: string; strip: string; holding: boolean; solved: boolean };
keypad?: { symbols: string[]; pressed: string[]; solved: boolean };
}
export function generateDevice(game: DefuseGame, levelIndex: number, seed: number): DefuseDevice {
const rng = mulberry32(seed);
const int = (min: number, max: number) => min + Math.floor(rng() * (max - min + 1));
const pick = <T>(items: readonly T[]): T => items[Math.floor(rng() * items.length)];
const shuffle = <T>(items: T[]): T[] => {
const out = [...items];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
};
const { serial: serialDef, batteries } = game.device;
const alphabet = [...serialDef.alphabet];
const digits = alphabet.filter((c) => /\d/.test(c));
let serial = '';
for (let i = 0; i < serialDef.length - 1; i++) serial += pick(alphabet);
serial += pick(digits); // last char is always a digit
const device: DefuseDevice = { serial, batteries: int(batteries.min, batteries.max) };
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 };
} else if (id === 'button') {
device.button = {
label: pick(mods.button.labels),
color: pick(mods.button.colors),
strip: pick(mods.button.stripColors),
holding: false,
solved: false,
};
} else {
const column = pick(mods.keypad.columns);
device.keypad = { symbols: shuffle(shuffle(column).slice(0, 4)), pressed: [], solved: false };
}
}
return device;
}
/** 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 (device.keypad && !solveKeypad(device.keypad.symbols, game.modules.keypad.columns)) return false;
return true;
}
// ---------- Level state ----------
export interface DefuseState {
level: number;
status: DefuseStatus;
failReason: 'strikes' | 'time' | null;
seed: number;
device: DefuseDevice | null;
strikes: number;
/** `remaining` game-seconds as of `anchor`; ticks down at `speed` per real second. */
clock: { remaining: number; anchor: number; speed: number } | null;
}
const newSeed = () => Math.floor(Math.random() * 0xffffffff);
export function initDefuse(): DefuseState {
return { level: 0, status: 'ready', failReason: null, seed: newSeed(), device: null, strikes: 0, clock: null };
}
const state = (ctx: Ctx) => ctx.room.engine as DefuseState;
export function remainingAt(s: DefuseState, now: number): number {
if (!s.clock) return 0;
if (s.status !== 'active') return s.clock.remaining;
return s.clock.remaining - ((now - s.clock.anchor) / 1000) * s.clock.speed;
}
export function timerText(remaining: number): string {
const t = Math.max(0, Math.ceil(remaining));
return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`;
}
function freezeClock(s: DefuseState, now: number) {
if (s.clock) s.clock = { ...s.clock, remaining: Math.max(0, remainingAt(s, now)), anchor: now };
}
function explode(ctx: Ctx, s: DefuseState, reason: 'strikes' | 'time') {
freezeClock(s, ctx.now);
s.status = 'exploded';
s.failReason = reason;
if (s.device?.button) s.device.button.holding = false;
}
function strike(game: DefuseGame, ctx: Ctx, s: DefuseState) {
const remaining = remainingAt(s, ctx.now);
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.`);
}
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;
freezeClock(s, ctx.now);
s.status = 'cleared';
ctx.addKey(game.levels[s.level].keyName);
if (s.level === game.levels.length - 1) {
ctx.finish(
true,
'**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. 💣🔑',
);
}
}
/** Starts the level, retries after an explosion, or moves on after a clear. */
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 (s.status === 'active') return fail('busy', 'The clock is running!');
if (s.status === 'cleared') {
if (s.level >= game.levels.length - 1) return fail('done', 'Every level is cleared.');
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 });
return OK;
}
export function defuseAction(
game: DefuseGame,
ctx: Ctx,
role: Role,
moduleId: string,
action: DefuseAction,
): ActionResult {
const s = state(ctx);
if (role !== 'A') 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;
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;
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 });
if (action.type === 'tap' && !b.holding) {
if (solution.type === 'tap') b.solved = true;
else strike(game, ctx, s);
} else if (action.type === 'hold' && !b.holding) {
if (solution.type === 'hold') b.holding = true;
else strike(game, ctx, s);
} else if (action.type === 'release' && b.holding && solution.type === 'hold') {
// Checked against the server's own clock, with a second of grace for latency.
const remaining = remainingAt(s, ctx.now);
const digit = String(solution.releaseDigit);
const shown = [remaining - 1, remaining, remaining + 1].map(timerText);
b.holding = false;
if (shown.some((text) => text.includes(digit))) b.solved = true;
else strike(game, ctx, s);
} else {
return fail('bad_action', "That doesn't work on the button right now.");
}
} 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)) {
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 (k.pressed.length === order.length) k.solved = true;
} else {
strike(game, ctx, s);
}
} else {
return fail('bad_action', "That module isn't on this device.");
}
if (state(ctx).status === 'active') checkCleared(game, ctx, s);
return OK;
}
/** Called on a timer; returns true if the room changed (the clock ran out). */
export function tickDefuse(ctx: Ctx): boolean {
const s = state(ctx);
if (!s || s.status !== 'active' || remainingAt(s, ctx.now) > 0) return false;
explode(ctx, s, 'time');
return true;
}
+46
View File
@@ -0,0 +1,46 @@
import type { Game } from '../content/schemas';
import { continueDefuse, initDefuse } from './defuse';
import { initLabyrinth } from './labyrinth';
import { continueChapter, initStory } from './story';
import { continueSync, initSync } from './sync';
import type { ActionResult, Ctx } from './types';
export function initEngine(game: Game): unknown {
switch (game.engine) {
case 'story':
return initStory();
case 'defuse':
return initDefuse();
case 'sync':
return initSync(game);
case 'labyrinth':
return initLabyrinth(game);
}
}
export function totalKeys(game: Game): number {
switch (game.engine) {
case 'story':
return game.chapters.length;
case 'defuse':
return game.levels.length;
case 'sync':
return game.rounds.length;
case 'labyrinth':
return game.lanterns.length;
}
}
/** `chapter:continue` means "move on" in every engine that has stages. */
export function continueGame(game: Game, ctx: Ctx): ActionResult {
switch (game.engine) {
case 'story':
return continueChapter(game, ctx);
case 'defuse':
return continueDefuse(game, ctx);
case 'sync':
return continueSync(game, ctx);
case 'labyrinth':
return { ok: false, code: 'no_stages', message: 'Nothing to continue.' };
}
}
+102
View File
@@ -0,0 +1,102 @@
import type { Dir, Role } from '@two-keys/shared';
import type { LabyrinthGame } from '../content/schemas';
import { OK, fail, type ActionResult, type Ctx } from './types';
export interface Point {
x: number;
y: number;
}
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 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) === '#';
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 })));
return found;
}
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> {
const [start] = findCells(grid, 'S');
const seen = new Set<string>();
if (!start) return seen;
const queue: Point[] = [start];
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 };
const c = cellAt(grid, next.x, next.y);
if (c === '#' || c === 'T' || seen.has(key(next))) continue;
seen.add(key(next));
queue.push(next);
}
}
return seen;
}
export const isReachable = (reach: Set<string>, p: Point) => reach.has(key(p));
export interface LabyrinthState {
x: number;
y: number;
collected: boolean[];
moves: number;
falls: number;
message: 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: '' };
}
export function moveExplorer(game: LabyrinthGame, ctx: Ctx, role: Role, dir: Dir): ActionResult {
const s = ctx.room.engine as LabyrinthState;
if (role !== 'B') return fail('wrong_role', 'Only the Explorer can move.');
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.");
const flavour = game.explorerView.flavour;
s.moves++;
s.x = next.x;
s.y = next.y;
s.message = '';
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.falls++;
s.message = flavour.trap;
ctx.addPenalty(game.scoring.trapPenaltySeconds);
ctx.toast('A', 'Your Explorer fell into a trap and is back at the entrance.');
} else if (cell === 'K') {
const i = game.lanterns.findIndex((l) => l.x === next.x && l.y === next.y);
if (i >= 0 && !s.collected[i]) {
s.collected[i] = true;
ctx.addKey(game.lanterns[i].name);
s.message = flavour.lantern.replace('{name}', game.lanterns[i].name);
}
} else if (cell === 'E') {
if (s.collected.every(Boolean)) {
s.message = flavour.exitOpen;
ctx.finish(true, game.successEnding);
} else {
s.message = flavour.exitLocked;
}
}
return OK;
}
+155
View File
@@ -0,0 +1,155 @@
import { matchesAnswer, type Role } from '@two-keys/shared';
import type { Lock, StoryGame } from '../content/schemas';
import { OK, fail, type ActionResult, type Ctx } from './types';
export const HINT_PENALTY_SECONDS = 120;
export const SUBMIT_COOLDOWN_MS = 2000;
export const NUDGE_AFTER_WRONG = 5;
export interface StoryState {
/** Index of the open chapter; equals chapters.length once the finale is open. */
chapter: number;
awaitingContinue: boolean;
solved: string[];
hints: Record<string, number>;
wrong: Record<string, number>;
lastSubmit: Record<string, number>;
finale: {
proposal: { answers: Record<string, string>; by: Role } | null;
agreed: boolean;
attempts: number;
};
}
export function initStory(): StoryState {
return {
chapter: 0,
awaitingContinue: false,
solved: [],
hints: {},
wrong: {},
lastSubmit: {},
finale: { proposal: null, agreed: false, attempts: 0 },
};
}
const state = (ctx: Ctx) => ctx.room.engine as StoryState;
const atFinale = (game: StoryGame, s: StoryState) => s.chapter >= game.chapters.length;
/** The one lock that can currently be worked on, if any. */
export function openLock(game: StoryGame, s: StoryState): { lock: Lock; finale: boolean } | null {
if (!atFinale(game, s)) {
return s.awaitingContinue ? null : { lock: game.chapters[s.chapter].lock, finale: false };
}
if (game.finale.type === 'escape' && !s.solved.includes(game.finale.lock.id)) {
return { lock: game.finale.lock, finale: true };
}
return null;
}
export function submitLock(game: StoryGame, ctx: Ctx, lockId: string, value: string): ActionResult {
const s = state(ctx);
const open = openLock(game, s);
if (!open || open.lock.id !== lockId) return fail('lock_closed', "That lock isn't open right now.");
const last = s.lastSubmit[lockId] ?? 0;
if (ctx.now - last < SUBMIT_COOLDOWN_MS) {
return fail('rate_limited', 'Easy! One guess every two seconds.');
}
s.lastSubmit[lockId] = ctx.now;
if (!matchesAnswer(value, open.lock.answers)) {
s.wrong[lockId] = (s.wrong[lockId] ?? 0) + 1;
ctx.room.wrongGuesses++;
return { ok: true, correct: false };
}
s.solved.push(lockId);
if (open.finale) {
ctx.finish(true, game.finale.successEnding);
} else {
ctx.addKey(open.lock.keyName ?? 'Key');
s.awaitingContinue = true;
}
return { ok: true, correct: true };
}
export function requestHint(game: StoryGame, ctx: Ctx, lockId: string): ActionResult {
const s = state(ctx);
const open = openLock(game, s);
if (!open || open.lock.id !== lockId) return fail('lock_closed', "That lock isn't open right now.");
const tier = s.hints[lockId] ?? 0;
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)`);
return OK;
}
export function continueChapter(game: StoryGame, ctx: Ctx): ActionResult {
const s = state(ctx);
if (!s.awaitingContinue) return fail('not_ready', 'Solve the lock first.');
s.awaitingContinue = false;
s.chapter++;
return OK;
}
function accusation(game: StoryGame, s: StoryState) {
return atFinale(game, s) && game.finale.type === 'accusation' ? game.finale : null;
}
export function proposeAccusation(
game: StoryGame,
ctx: Ctx,
role: Role,
answers: Record<string, string>,
): ActionResult {
const s = state(ctx);
const finale = accusation(game, s);
if (!finale) return fail('no_finale', "The finale isn't open yet.");
const clean: Record<string, string> = {};
for (const q of finale.questions) {
const pick = answers[q.id];
if (!q.options.some((o) => o.id === pick)) return fail('incomplete', 'Answer every question first.');
clean[q.id] = pick;
}
s.finale.proposal = { answers: clean, by: role };
s.finale.agreed = false;
return OK;
}
export function agreeAccusation(game: StoryGame, ctx: Ctx, role: Role): ActionResult {
const s = state(ctx);
if (!accusation(game, s) || !s.finale.proposal) return fail('no_proposal', 'Nothing to agree to yet.');
if (s.finale.proposal.by === role) return fail('own_proposal', 'Your partner has to agree, not you.');
s.finale.agreed = true;
return OK;
}
export function submitAccusation(game: StoryGame, ctx: Ctx): ActionResult {
const s = state(ctx);
const finale = accusation(game, s);
if (!finale || !s.finale.proposal) return fail('no_proposal', 'Propose an accusation first.');
if (!s.finale.agreed) return fail('not_agreed', 'You both have to agree before submitting.');
const { answers } = s.finale.proposal;
const correct = finale.questions.every((q) => answers[q.id] === q.answer);
if (correct) {
ctx.finish(true, finale.successEnding);
return { ok: true, correct: true };
}
s.finale.attempts++;
s.finale.proposal = null;
s.finale.agreed = false;
ctx.room.wrongGuesses++;
const left = finale.maxAttempts - s.finale.attempts;
if (left <= 0) {
ctx.finish(false, finale.failEnding, finale.reveal);
} else {
// Deliberately doesn't say which part was wrong.
ctx.toast('all', `Not quite. ${left} attempt${left === 1 ? '' : 's'} left.`);
}
return { ok: true, correct: false };
}
+120
View File
@@ -0,0 +1,120 @@
import type { Role } from '@two-keys/shared';
import type { SyncGame, SyncQuestion } from '../content/schemas';
import { OK, fail, type ActionResult, type Ctx } from './types';
export interface SyncState {
round: number;
/** 0 on the first try, 1+ on a replay. */
attempt: number;
status: 'intro' | 'question' | 'reveal' | 'summary';
questions: SyncQuestion[];
q: number;
answers: Record<string, Partial<Record<Role, number>>>;
marks: boolean[];
earned: boolean;
/** Question ids already drawn this session, so nothing repeats. */
used: string[];
}
export function initSync(game: SyncGame): SyncState {
const s: SyncState = {
round: 0,
attempt: 0,
status: 'intro',
questions: [],
q: 0,
answers: {},
marks: [],
earned: false,
used: [],
};
deal(game, s);
return s;
}
const state = (ctx: Ctx) => ctx.room.engine as SyncState;
/** 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 hand: SyncQuestion[] = [];
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.questions = hand;
s.q = 0;
s.answers = {};
s.marks = [];
s.earned = false;
}
export function canReplay(game: SyncGame, s: SyncState): boolean {
return !s.earned && s.attempt < game.rules.replaysPerRound;
}
export function answerSync(
game: SyncGame,
ctx: Ctx,
role: Role,
questionId: string,
option: number,
): 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 answers = (s.answers[questionId] ??= {});
if (answers[role] !== undefined) return fail('answered', "You've already locked in an answer.");
answers[role] = option;
if (answers.A !== undefined && answers.B !== undefined) {
s.marks.push(answers.A === answers.B);
s.status = 'reveal';
}
return OK;
}
export function continueSync(game: SyncGame, ctx: Ctx): ActionResult {
const s = state(ctx);
const round = game.rounds[s.round];
if (s.status === 'intro') {
s.status = 'question';
} else if (s.status === 'question') {
return fail('waiting', 'Both of you need to answer first.');
} else if (s.status === 'reveal') {
if (s.q < s.questions.length - 1) {
s.q++;
s.status = 'question';
} else {
s.earned = s.marks.filter(Boolean).length >= game.rules.pointsForKey;
if (s.earned) ctx.addKey(round.keyName);
s.status = 'summary';
}
} else if (canReplay(game, s)) {
s.attempt++;
deal(game, s);
s.status = 'question';
} else if (s.round < game.rounds.length - 1) {
s.round++;
s.attempt = 0;
deal(game, s);
s.status = 'intro';
} else {
const keys = ctx.room.keys.length;
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. 💞🔑`
: 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? 😏`;
ctx.finish(keys > 0, ending);
}
return OK;
}
+42
View File
@@ -0,0 +1,42 @@
import type { Role } from '@two-keys/shared';
import type { Room } from '../rooms/store';
export interface Toast {
to: Role | 'all';
message: string;
}
/** What an engine may do to a room while handling an action. */
export interface Ctx {
room: Room;
now: number;
toasts: Toast[];
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;
}
export function createCtx(room: Room, now = Date.now()): Ctx {
const toasts: Toast[] = [];
return {
room,
now,
toasts,
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) {
room.phase = 'finished';
room.finishedAt = now;
room.result = { success, ending, reveal };
},
};
}
export type ActionResult =
| { ok: true; correct?: boolean }
| { ok: false; code: string; message: string };
export const OK: ActionResult = { ok: true };
export const fail = (code: string, message: string): ActionResult => ({ ok: false, code, message });
+12
View File
@@ -0,0 +1,12 @@
import { createApp } from './app';
const app = createApp();
const port = await app.listen(Number(process.env.PORT) || 3001);
console.log(`Two Keys server on http://localhost:${port} (${app.games.size} games, ${app.store.rooms.size} rooms restored)`);
// Snapshot rooms on the way out so a restart doesn't kill a game in progress.
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
app.close().finally(() => process.exit(0));
});
}
+17
View File
@@ -0,0 +1,17 @@
import { randomInt } from 'node:crypto';
/** No 0/O/1/I, so codes survive being read out loud. */
export const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
export const CODE_LENGTH = 5;
export function makeCode(isTaken: (code: string) => boolean = () => false): string {
for (;;) {
let code = '';
for (let i = 0; i < CODE_LENGTH; i++) code += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)];
if (!isTaken(code)) return code;
}
}
export function cleanCode(input: string): string {
return input.trim().toUpperCase();
}
+77
View File
@@ -0,0 +1,77 @@
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 type { PlayerRec, Room } from './store';
export function computeResult(room: Room, game: Game): GameResult | null {
if (room.phase !== 'finished' || !room.result || room.startedAt === null || room.finishedAt === null) {
return null;
}
const elapsedSeconds = Math.round((room.finishedAt - room.startedAt) / 1000);
const finalSeconds = elapsedSeconds + room.penaltySeconds;
const keys = totalKeys(game);
return {
success: room.result.success,
elapsedSeconds,
penaltySeconds: room.penaltySeconds,
finalSeconds,
hintsUsed: room.hintsUsed,
wrongGuesses: room.wrongGuesses,
keysEarned: room.keys.length,
totalKeys: keys,
rank: rankFor({
success: room.result.success,
hintsUsed: room.hintsUsed,
finalSeconds,
estimatedMinutes: game.estimatedMinutes,
keysEarned: room.keys.length,
totalKeys: keys,
}),
ending: room.result.ending,
reveal: room.result.reveal,
};
}
/** The full, sanitized state for one player. This is the only thing sockets ever receive. */
export function buildState(
room: Room,
player: PlayerRec,
game: Game,
isOnline: (token: string) => boolean,
now = Date.now(),
): RoomState {
const roles: Role[] = player.seat === 'both' ? ['A', 'B'] : player.seat ? [player.seat] : [];
const views: RoomState['views'] = {};
if (room.phase !== 'lobby' && room.engine) {
for (const role of roles) views[role] = engineView(game, room, role, now);
}
const partner = room.players.find((p) => p !== player);
return {
code: room.code,
mode: room.mode,
phase: room.phase,
game: gameMeta(game),
you: { seat: player.seat, ready: player.ready },
partner: partner
? {
online: isOnline(partner.token),
seat: partner.seat === 'both' ? null : partner.seat,
ready: partner.ready,
viewing: partner.viewing,
}
: null,
notepad: room.notepad,
keys: [...room.keys],
timer: {
startedAt: room.startedAt,
finishedAt: room.finishedAt,
penaltySeconds: room.penaltySeconds,
serverNow: now,
},
stats: { hintsUsed: room.hintsUsed, wrongGuesses: room.wrongGuesses },
views,
result: computeResult(room, game),
};
}
+131
View File
@@ -0,0 +1,131 @@
import fs from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import type { Phase, PlayMode, Role, Seat } from '@two-keys/shared';
import { makeCode } from './codes';
export interface PlayerRec {
token: string;
seat: Seat | null;
ready: boolean;
viewing: string | null;
}
export interface Room {
code: string;
gameId: string;
mode: PlayMode;
phase: Phase;
players: PlayerRec[];
createdAt: number;
lastActivity: number;
notepad: string;
keys: string[];
startedAt: number | null;
finishedAt: number | null;
penaltySeconds: number;
hintsUsed: number;
wrongGuesses: number;
/** Engine-specific state, owned by the game's engine. */
engine: unknown;
result: { success: boolean; ending: string | null; reveal: string | null } | null;
}
export const ROOM_TTL_MS = 24 * 60 * 60 * 1000;
export const SNAPSHOT_INTERVAL_MS = 10_000;
export function seatFor(room: Room, role: Role): PlayerRec | undefined {
return room.players.find((p) => p.seat === role || p.seat === 'both');
}
/** In-memory room store, snapshotted to disk so a restart doesn't kill a game in progress. */
export class RoomStore {
readonly rooms = new Map<string, Room>();
private timer: NodeJS.Timeout | null = null;
constructor(private readonly file: string | null) {}
create(gameId: string, mode: PlayMode, now = Date.now()): { room: Room; player: PlayerRec } {
const player: PlayerRec = {
token: randomUUID(),
seat: mode === 'couch' ? 'both' : null,
ready: false,
viewing: null,
};
const room: Room = {
code: makeCode((code) => this.rooms.has(code)),
gameId,
mode,
phase: 'lobby',
players: [player],
createdAt: now,
lastActivity: now,
notepad: '',
keys: [],
startedAt: null,
finishedAt: null,
penaltySeconds: 0,
hintsUsed: 0,
wrongGuesses: 0,
engine: null,
result: null,
};
this.rooms.set(room.code, room);
return { room, player };
}
get(code: string): Room | undefined {
return this.rooms.get(code);
}
/** Drop rooms idle for more than 24h. */
sweep(now = Date.now()): number {
let removed = 0;
for (const [code, room] of this.rooms) {
if (now - room.lastActivity > ROOM_TTL_MS) {
this.rooms.delete(code);
removed++;
}
}
return removed;
}
load(isKnownGame: (gameId: string) => boolean): void {
if (!this.file || !fs.existsSync(this.file)) return;
try {
const rooms = JSON.parse(fs.readFileSync(this.file, 'utf8')) as Room[];
for (const room of rooms) {
if (room?.code && isKnownGame(room.gameId)) this.rooms.set(room.code, room);
}
this.sweep();
} catch (err) {
console.warn(`Could not restore rooms from ${this.file}:`, err);
}
}
snapshot(): void {
if (!this.file) return;
try {
fs.mkdirSync(path.dirname(this.file), { recursive: true });
const tmp = `${this.file}.tmp`;
fs.writeFileSync(tmp, JSON.stringify([...this.rooms.values()]));
fs.renameSync(tmp, this.file);
} catch (err) {
console.warn('Room snapshot failed:', err);
}
}
start(): void {
this.timer = setInterval(() => {
this.sweep();
this.snapshot();
}, SNAPSHOT_INTERVAL_MS);
this.timer.unref();
}
stop(): void {
if (this.timer) clearInterval(this.timer);
this.timer = null;
this.snapshot();
}
}
+24
View File
@@ -0,0 +1,24 @@
import { loadAllFiles } from '../content/loader';
const files = loadAllFiles();
let failed = 0;
for (const { file, game, errors } of files) {
if (errors.length === 0 && game) {
console.log(`✔ ${file} (${game.engine})`);
} else {
failed++;
console.error(`✖ ${file}`);
for (const e of errors) console.error(` ${e}`);
}
}
if (files.length === 0) {
console.error('No game files found in content/games.');
process.exit(1);
}
if (failed > 0) {
console.error(`\n${failed} of ${files.length} game files failed validation.`);
process.exit(1);
}
console.log(`\nAll ${files.length} games are valid.`);
+282
View File
@@ -0,0 +1,282 @@
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 type { Game } from '../content/schemas';
import { defuseAction, tickDefuse } from '../engines/defuse';
import { continueGame, initEngine } from '../engines/index';
import { moveExplorer } from '../engines/labyrinth';
import { agreeAccusation, proposeAccusation, requestHint, submitAccusation, submitLock } from '../engines/story';
import { answerSync } from '../engines/sync';
import { createCtx, fail, type ActionResult, type Ctx } from '../engines/types';
import { cleanCode } from '../rooms/codes';
import { buildState } from '../rooms/state';
import type { PlayerRec, Room, RoomStore } from '../rooms/store';
const NOTEPAD_MAX = 5000;
const role = z.enum(['A', 'B']);
const asRole = { asRole: role.optional() };
const empty = z.object(asRole).default({});
const schemas = {
create: z.object({ gameId: z.string(), mode: z.enum(['duo', 'couch']).default('duo') }),
join: z.object({ code: z.string().min(1).max(12), playerToken: z.string().max(64).nullish() }),
pickRole: z.object({ role }),
lockSubmit: z.object({ lockId: z.string(), value: z.string().max(200), ...asRole }),
hint: z.object({ lockId: z.string(), ...asRole }),
propose: z.object({ answers: z.record(z.string()), ...asRole }),
defuse: z.object({
moduleId: z.enum(['wires', 'button', 'keypad']),
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() }),
]),
...asRole,
}),
sync: z.object({ questionId: z.string(), optionId: z.number().int(), ...asRole }),
move: z.object({ dir: z.enum(['n', 'e', 's', 'w']), ...asRole }),
notepad: z.object({ text: z.string() }),
ping: z.object({ emoji: z.enum(PING_EMOJIS), ...asRole }),
view: z.object({ label: z.string().max(80) }),
};
interface ActionArgs<T> {
socket: Socket;
room: Room;
player: PlayerRec;
game: Game;
data: T;
/** The role this action is taken as (couch players say which via `asRole`). */
role: Role;
ctx: Ctx;
}
export function registerHandlers(io: Server, games: Map<string, Game>, store: RoomStore): () => void {
const socketsIn = (code: string): Socket[] =>
[...(io.sockets.adapter.rooms.get(code) ?? [])]
.map((id) => io.sockets.sockets.get(id))
.filter((s): s is Socket => s !== undefined);
const playerOf = (room: Room, socket: Socket) => room.players.find((p) => p.token === socket.data.token);
function broadcast(room: Room, except?: Socket) {
const game = games.get(room.gameId);
if (!game) return;
const sockets = socketsIn(room.code);
const isOnline = (token: string) => sockets.some((s) => s.data.token === token);
const now = Date.now();
for (const s of sockets) {
const player = playerOf(room, s);
if (player && s !== except) s.emit('room:state', buildState(room, player, game, isOnline, now));
}
}
function flushToasts(room: Room, ctx: Ctx) {
for (const { to, message } of ctx.toasts) {
for (const s of socketsIn(room.code)) {
const seat = playerOf(room, s)?.seat;
if (to === 'all' || seat === 'both' || seat === to) s.emit('toast', { message });
}
}
}
const sendError = (socket: Socket, code: string, message: string) => socket.emit('error', { code, message });
function maybeStart(room: Room, game: Game) {
if (room.phase !== 'lobby') return;
const seated = room.mode === 'couch' ? room.players.length === 1 : room.players.length === 2;
if (!seated || !room.players.every((p) => p.ready && p.seat)) return;
room.phase = 'playing';
room.startedAt = Date.now();
room.engine = initEngine(game);
}
io.on('connection', (socket) => {
/** Registers a room-scoped event: validate, resolve room + role, run, then broadcast. */
function on<S extends z.ZodTypeAny>(
event: string,
schema: S,
phase: Phase | null,
handler: (args: ActionArgs<z.infer<S>>) => ActionResult | void,
opts: { echo?: boolean } = {},
) {
socket.on(event, (payload: unknown) => {
const parsed = schema.safeParse(payload ?? {});
if (!parsed.success) return sendError(socket, 'bad_request', 'That request made no sense.');
const room = store.get(socket.data.code);
const player = room && playerOf(room, socket);
const game = room && games.get(room.gameId);
if (!room || !player || !game) return sendError(socket, 'not_in_room', 'Join a room first.');
if (phase && room.phase !== phase) return sendError(socket, 'wrong_phase', "You can't do that right now.");
const data = parsed.data as z.infer<S> & { asRole?: Role };
const actingAs: Role = player.seat === 'both' ? (data.asRole ?? 'A') : (player.seat ?? 'A');
const ctx = createCtx(room);
const result = handler({ socket, room, player, game, data, role: actingAs, ctx });
if (result && !result.ok) sendError(socket, result.code, result.message);
room.lastActivity = ctx.now;
flushToasts(room, ctx);
broadcast(room, opts.echo === false ? socket : undefined);
});
}
socket.on('room:create', (payload: unknown, ack?: (res: JoinAck) => void) => {
const reply = (res: JoinAck) => typeof ack === 'function' && ack(res);
const parsed = schemas.create.safeParse(payload);
if (!parsed.success || !games.has(parsed.data.gameId)) {
return reply({ ok: false, code: 'unknown_game', message: "That game doesn't exist." });
}
const { room, player } = store.create(parsed.data.gameId, parsed.data.mode);
attach(room, player);
reply({ ok: true, code: room.code, playerToken: player.token });
broadcast(room);
});
socket.on('room:join', (payload: unknown, ack?: (res: JoinAck) => void) => {
const reply = (res: JoinAck) => typeof ack === 'function' && ack(res);
const parsed = schemas.join.safeParse(payload);
const room = parsed.success ? store.get(cleanCode(parsed.data.code)) : undefined;
if (!parsed.success || !room) {
return reply({ ok: false, code: 'not_found', message: "We couldn't find that room. Check the code?" });
}
// Reconnect: same token (or the same socket asking twice) gets the same seat back.
const token = parsed.data.playerToken ?? (socket.data.code === room.code ? socket.data.token : null);
let player = room.players.find((p) => p.token === token);
if (!player) {
const capacity = room.mode === 'couch' ? 1 : 2;
if (room.players.length >= capacity) {
return reply({ ok: false, code: 'room_full', message: 'This room already has two players.' });
}
const taken = room.players[0]?.seat;
player = {
token: randomUUID(),
seat: taken === 'A' || taken === 'B' ? otherRole(taken) : null,
ready: false,
viewing: null,
};
room.players.push(player);
}
attach(room, player);
room.lastActivity = Date.now();
reply({ ok: true, code: room.code, playerToken: player.token });
broadcast(room);
});
function attach(room: Room, player: PlayerRec) {
if (socket.data.code && socket.data.code !== room.code) socket.leave(socket.data.code);
socket.data.code = room.code;
socket.data.token = player.token;
socket.join(room.code);
}
// ----- lobby -----
on('room:pickRole', schemas.pickRole, 'lobby', ({ room, player, data }) => {
if (room.mode === 'couch') return;
// Picking a role hands your partner the other one.
for (const p of room.players) {
p.seat = p === player ? data.role : otherRole(data.role);
p.ready = false;
}
});
on('room:ready', empty, 'lobby', ({ room, player, game }) => {
if (!player.seat) return fail('no_role', 'Pick a role first.');
player.ready = !player.ready;
maybeStart(room, game);
});
// ----- story -----
on('lock:submit', schemas.lockSubmit, 'playing', ({ room, game, data, ctx }) => {
if (game.engine !== 'story') return fail('wrong_engine', 'This game has no locks.');
const result = submitLock(game, ctx, data.lockId, data.value);
if (result.ok) io.to(room.code).emit('lock:result', { lockId: data.lockId, correct: result.correct === true });
return result;
});
on('hint:request', schemas.hint, 'playing', ({ game, data, ctx }) =>
game.engine === 'story' ? requestHint(game, ctx, data.lockId) : fail('wrong_engine', 'No hints here.'),
);
on('chapter:continue', empty, 'playing', ({ game, ctx }) => continueGame(game, ctx));
on('finale:propose', schemas.propose, 'playing', ({ game, data, role, ctx }) =>
game.engine === 'story' ? proposeAccusation(game, ctx, role, data.answers) : fail('wrong_engine', 'No finale here.'),
);
on('finale:agree', empty, 'playing', ({ game, role, ctx }) =>
game.engine === 'story' ? agreeAccusation(game, ctx, role) : fail('wrong_engine', 'No finale here.'),
);
on('finale:submit', empty, 'playing', ({ game, ctx }) =>
game.engine === 'story' ? submitAccusation(game, ctx) : fail('wrong_engine', 'No finale here.'),
);
// ----- other engines -----
on('defuse:action', schemas.defuse, 'playing', ({ game, data, role, ctx }) =>
game.engine === 'defuse'
? defuseAction(game, ctx, role, data.moduleId, data.action)
: fail('wrong_engine', 'Nothing to defuse.'),
);
on('sync:answer', schemas.sync, 'playing', ({ game, data, role, ctx }) =>
game.engine === 'sync'
? answerSync(game, ctx, role, data.questionId, data.optionId)
: fail('wrong_engine', 'No questions here.'),
);
on('maze:move', schemas.move, 'playing', ({ game, data, role, ctx }) =>
game.engine === 'labyrinth' ? moveExplorer(game, ctx, role, data.dir) : fail('wrong_engine', 'No maze here.'),
);
// ----- presence -----
on(
'notepad:update',
schemas.notepad,
null,
({ room, data }) => {
room.notepad = data.text.slice(0, NOTEPAD_MAX); // last write wins
},
{ echo: false },
);
on('presence:view', schemas.view, null, ({ player, data }) => {
player.viewing = data.label;
}, { echo: false });
socket.on('ping', (payload: unknown) => {
const parsed = schemas.ping.safeParse(payload);
const room = store.get(socket.data.code);
const player = room && playerOf(room, socket);
if (!parsed.success || !room || !player) return;
const from: Role = player.seat === 'both' ? (parsed.data.asRole ?? 'A') : (player.seat ?? 'A');
socket.to(room.code).emit('ping', { from, emoji: parsed.data.emoji });
});
socket.on('disconnect', () => {
const room = store.get(socket.data.code);
if (room) broadcast(room);
});
});
// The defuse clock can run out with nobody touching anything.
const ticker = setInterval(() => {
for (const room of store.rooms.values()) {
const game = games.get(room.gameId);
if (room.phase !== 'playing' || game?.engine !== 'defuse') continue;
const ctx = createCtx(room);
if (tickDefuse(ctx)) {
flushToasts(room, ctx);
broadcast(room);
}
}
}, 500);
ticker.unref();
return () => clearInterval(ticker);
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { loadAllFiles } from '../src/content/loader';
import { gameSchema } from '../src/content/schemas';
import { validateGame } from '../src/content/validate';
import { CODE_ALPHABET, makeCode } from '../src/rooms/codes';
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', () => {
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'],
);
});
it('rejects a story chapter with no clue for one player', () => {
const game = structuredClone(getGame('clockmakers-attic', 'story'));
game.chapters[0].clues.B = [];
expect(validateGame(game).join('\n')).toMatch(/at least one clue for B/);
});
it('rejects a finale answer that is not an option, and a lock without 3 hints', () => {
const game = structuredClone(getGame('vineyard-hollow', 'story'));
if (game.finale.type !== 'accusation') throw new Error('expected accusation');
game.finale.questions[0].answer = 'nobody';
expect(validateGame(game).join('\n')).toMatch(/not one of the options/);
const raw = structuredClone(getGame('vineyard-hollow', 'story')) as any;
raw.chapters[0].lock.hints.pop();
expect(gameSchema.safeParse(raw).success).toBe(false);
});
it('rejects a labyrinth whose lantern is only reachable through a trap', () => {
const game = structuredClone(labyrinth());
// Wall off (1,6)... the only trap-free way to the Listening lantern is via (3,6)/(3,7).
game.grid[6] = '#######.#.#';
expect(validateGame(game).join('\n')).toMatch(/not reachable/);
});
it('rejects a labyrinth with ragged rows or the wrong number of lanterns', () => {
const ragged = structuredClone(labyrinth());
ragged.grid[1] = ragged.grid[1] + '#';
expect(validateGame(ragged).join('\n')).toMatch(/same length/);
const fewer = structuredClone(labyrinth());
fewer.grid[7] = '#...#.T.#.#';
expect(validateGame(fewer).join('\n')).toMatch(/exactly 3 K/);
});
it('rejects a sync bank with fewer than 15 questions', () => {
const game = structuredClone(sync());
game.match = game.match.slice(0, 14);
expect(validateGame(game).join('\n')).toMatch(/at least 15/);
});
});
describe('rooms', () => {
it('makes 5-char codes with no confusable characters', () => {
for (let i = 0; i < 200; i++) {
const code = makeCode();
expect(code).toMatch(/^[A-Z2-9]{5}$/);
expect(code).not.toMatch(/[0O1I]/);
}
expect(CODE_ALPHABET).not.toMatch(/[0O1I]/);
});
it('expires rooms after 24h idle', () => {
const store = new RoomStore(null);
const { room } = store.create('in-sync', 'duo', 0);
expect(store.sweep(ROOM_TTL_MS - 1)).toBe(0);
expect(store.sweep(ROOM_TTL_MS + 1)).toBe(1);
expect(store.get(room.code)).toBeUndefined();
});
});
+125
View File
@@ -0,0 +1,125 @@
// End-to-end over real sockets: two clients complete The Clockmaker's Attic.
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { io as connect, type Socket } from 'socket.io-client';
import type { JoinAck, RoomState, StoryView } from '@two-keys/shared';
import { createApp } from '../src/app';
const app = createApp({ dataFile: null });
let url = '';
const sockets: Socket[] = [];
beforeAll(async () => {
url = `http://localhost:${await app.listen(0)}`;
});
afterAll(async () => {
sockets.forEach((s) => s.close());
await app.close();
});
function client() {
const socket = connect(url, { transports: ['websocket'], forceNew: true });
sockets.push(socket);
const seen: RoomState[] = [];
let latest: RoomState | undefined;
let wake = () => {};
socket.on('room:state', (state: RoomState) => {
latest = state;
seen.push(state);
wake();
});
return {
socket,
seen,
get state() {
return latest!;
},
ack: (event: string, payload: object) =>
new Promise<JoinAck>((resolve) => socket.emit(event, payload, resolve)),
/** Resolves once a received state satisfies the predicate. */
async until(predicate: (s: RoomState) => boolean): Promise<RoomState> {
const deadline = Date.now() + 5000;
while (!(latest && predicate(latest))) {
if (Date.now() > deadline) throw new Error('timed out waiting for room state');
await new Promise<void>((resolve) => {
wake = resolve;
setTimeout(resolve, 100);
});
}
return latest;
},
};
}
describe('two players over sockets', () => {
it('create, join, pick roles, play Clockmaker\'s Attic to the end, and reconnect', async () => {
const game = app.games.get('clockmakers-attic')!;
if (game.engine !== 'story' || game.finale.type !== 'escape') throw new Error('unexpected game shape');
const a = client();
const b = client();
const created = await a.ack('room:create', { gameId: game.id });
if (!created.ok) throw new Error(created.message);
expect(created.code).toMatch(/^[A-Z2-9]{5}$/);
a.socket.emit('room:pickRole', { role: 'A' });
const joined = await b.ack('room:join', { code: created.code.toLowerCase() });
if (!joined.ok) throw new Error(joined.message);
await b.until((s) => s.you.seat === 'B'); // partner gets the other role
// A third player can't get in.
const c = client();
expect(await c.ack('room:join', { code: created.code })).toMatchObject({ ok: false, code: 'room_full' });
expect(await c.ack('room:join', { code: 'ZZZZZ' })).toMatchObject({ ok: false, code: 'not_found' });
a.socket.emit('room:ready');
b.socket.emit('room:ready');
await a.until((s) => s.phase === 'playing');
await b.until((s) => s.phase === 'playing');
for (const [i, chapter] of game.chapters.entries()) {
// Either player can submit; alternate.
const [me, partner] = i % 2 ? [b, a] : [a, b];
me.socket.emit('lock:submit', { lockId: chapter.lock.id, value: chapter.lock.answers[0] });
await partner.until((s) => s.keys.length === i + 1);
partner.socket.emit('chapter:continue');
await me.until((s) => (s.views[s.you.seat as 'A' | 'B'] as StoryView).currentChapter === i + 1);
}
// Refresh mid-game: a new socket with the same token gets the same seat and progress.
b.socket.close();
await a.until((s) => s.partner?.online === false);
const b2 = client();
expect(await b2.ack('room:join', { code: created.code, playerToken: joined.playerToken })).toMatchObject({ ok: true });
const restored = await b2.until((s) => s.phase === 'playing');
expect(restored.you.seat).toBe('B');
expect(restored.keys).toHaveLength(game.chapters.length);
expect((restored.views.B as StoryView).finale?.type).toBe('escape');
b2.socket.emit('lock:submit', { lockId: game.finale.lock.id, value: 'forever' });
const done = await a.until((s) => s.phase === 'finished');
expect(done.result).toMatchObject({ success: true, keysEarned: 3, totalKeys: 3, hintsUsed: 0 });
expect(done.result?.ending).toContain('FOREVER');
// Nothing A ever received contained B's clues, or any answers.
const everything = JSON.stringify(a.seen);
for (const chapter of game.chapters) {
for (const clue of chapter.clues.B) expect(everything).not.toContain(JSON.stringify(clue.title));
}
expect(everything).not.toContain('"answers"');
expect(everything).not.toContain(JSON.stringify(game.finale.clues.B[0].title));
});
it('couch mode: one device holds both roles and sees both views', async () => {
const solo = client();
const created = await solo.ack('room:create', { gameId: 'labyrinth-of-lanterns', mode: 'couch' });
if (!created.ok) throw new Error(created.message);
solo.socket.emit('room:ready');
const state = await solo.until((s) => s.phase === 'playing');
expect(state.you.seat).toBe('both');
expect(Object.keys(state.views).sort()).toEqual(['A', 'B']);
solo.socket.emit('maze:move', { dir: 'e', asRole: 'B' });
await solo.until((s) => (s.views.B as { moves: number }).moves === 1);
});
});
+257
View File
@@ -0,0 +1,257 @@
import { describe, expect, it } from 'vitest';
import type { Dir } from '@two-keys/shared';
import {
continueDefuse,
defuseAction,
generateDevice,
isSolvable,
remainingAt,
solveButton,
solveKeypad,
solveWires,
tickDefuse,
timerText,
type DefuseState,
} from '../src/engines/defuse';
import { DELTAS, cellAt, findCells, moveExplorer, type LabyrinthState } from '../src/engines/labyrinth';
import { answerSync, continueSync, type SyncState } from '../src/engines/sync';
import { clock, defuse, labyrinth, playingRoom, sync } from './helpers';
describe('defuse solver', () => {
const game = defuse();
it.each(game.fixtures.wires)('wires fixture %#', (f) => {
expect(solveWires(f.serial, f.wires)).toEqual(f.expect);
});
it.each(game.fixtures.button)('button fixture %#', (f) => {
expect(solveButton(f)).toEqual(f.expect);
});
it.each(game.fixtures.keypad)('keypad fixture %#', (f) => {
expect(solveKeypad(f.symbols, game.modules.keypad.columns)).toEqual(f.expect);
});
it('never generates a device the solver cannot solve', () => {
for (let seed = 1; seed <= 2000; seed++) {
const device = generateDevice(game, 2, seed);
expect(isSolvable(game, device), `seed ${seed}`).toBe(true);
expect(device.serial).toMatch(/^[A-Z0-9]{5}\d$/);
expect(device.wires!.colors.length).toBeGreaterThanOrEqual(3);
expect(device.wires!.colors.length).toBeLessThanOrEqual(5);
expect(device.keypad!.symbols).toHaveLength(4);
}
});
it('is deterministic per seed and only builds the modules of the level', () => {
expect(generateDevice(game, 2, 42)).toEqual(generateDevice(game, 2, 42));
const lvl1 = generateDevice(game, 0, 42);
expect(lvl1.wires).toBeDefined();
expect(lvl1.button).toBeUndefined();
expect(lvl1.keypad).toBeUndefined();
});
it('formats the timer like the device shows it', () => {
expect(timerText(180)).toBe('3:00');
expect(timerText(64.2)).toBe('1:05');
expect(timerText(-3)).toBe('0:00');
});
});
describe('defuse level flow', () => {
const game = defuse();
/** Solves whatever is on the device, using only the solver. */
function solveAll(room: ReturnType<typeof playingRoom>, t: ReturnType<typeof clock>) {
const s = room.engine as DefuseState;
const d = s.device!;
if (d.wires) defuseAction(game, t.ctx(100), 'A', 'wires', solveWires(d.serial, d.wires.colors));
if (d.keypad) {
for (const symbol of solveKeypad(d.keypad.symbols, game.modules.keypad.columns)!) {
defuseAction(game, t.ctx(100), 'A', 'keypad', { type: 'press', symbol });
}
}
if (d.button) {
const solution = solveButton({ ...d.button, batteries: d.batteries });
if (solution.type === 'tap') {
defuseAction(game, t.ctx(100), 'A', 'button', { type: 'tap' });
} else {
defuseAction(game, t.ctx(100), 'A', 'button', { type: 'hold' });
// Wait until the server's own timer shows the digit, then let go.
for (let i = 0; i < 400; i++) {
if (timerText(remainingAt(s, t.now)).includes(String(solution.releaseDigit))) break;
t.ctx(500);
}
defuseAction(game, t.ctx(0), 'A', 'button', { type: 'release' });
}
}
}
it('clears all three 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); // start
solveAll(room, t);
const s = room.engine as DefuseState;
expect(s.strikes, `level ${level}`).toBe(0);
expect(s.status).toBe('cleared');
if (level < game.levels.length - 1) continueDefuse(game, t.ctx());
}
expect(room.keys).toEqual(game.levels.map((l) => l.keyName));
expect(room.phase).toBe('finished');
expect(room.result?.success).toBe(true);
});
it('strikes speed the clock up by 25% and three strikes blow the level without losing keys', () => {
const room = playingRoom(game);
room.keys.push('Earlier Key');
const t = clock(room);
continueDefuse(game, t.ctx());
const s = room.engine as DefuseState;
const right = solveWires(s.device!.serial, s.device!.wires!.colors).index;
const wrong = right === 1 ? 2 : 1;
defuseAction(game, t.ctx(), 'A', 'wires', { type: 'cut', index: wrong });
expect(s.strikes).toBe(1);
expect(s.clock!.speed).toBeCloseTo(1.25);
const before = remainingAt(s, t.now);
expect(before - remainingAt(s, t.now + 4000)).toBeCloseTo(5);
expect(defuseAction(game, t.ctx(), 'B', 'wires', { type: 'cut', index: right }).ok).toBe(false); // Expert can't cut
defuseAction(game, t.ctx(), 'A', 'wires', { type: 'cut', index: wrong });
defuseAction(game, t.ctx(), 'A', 'wires', { type: 'cut', index: wrong });
expect(s.status).toBe('exploded');
expect(s.failReason).toBe('strikes');
expect(room.keys).toEqual(['Earlier Key']);
const oldSeed = s.seed;
continueDefuse(game, t.ctx()); // retry → briefing with a new seed
expect(s).toMatchObject({ status: 'ready', level: 0, strikes: 0, device: null });
expect(s.seed).not.toBe(oldSeed);
});
it('explodes when the clock runs out', () => {
const room = playingRoom(game);
const t = clock(room);
continueDefuse(game, t.ctx());
expect(tickDefuse(t.ctx(10_000))).toBe(false);
expect(tickDefuse(t.ctx(game.levels[0].timeSeconds * 1000))).toBe(true);
expect(room.engine).toMatchObject({ status: 'exploded', failReason: 'time' });
});
});
describe('sync', () => {
const game = sync();
it('awards a key for 3+ matches, offers one replay otherwise, and never repeats a question', () => {
const room = playingRoom(game);
const t = clock(room);
const s = room.engine as SyncState;
const seen: string[] = [];
const playRound = (matches: number) => {
for (let i = 0; i < game.rules.questionsPerRound; i++) {
const q = s.questions[s.q];
seen.push(q.id);
expect(answerSync(game, t.ctx(), 'A', q.id, 0).ok).toBe(true);
expect(answerSync(game, t.ctx(), 'A', q.id, 1).ok).toBe(false); // locked in
expect(continueSync(game, t.ctx()).ok).toBe(false); // partner still thinking
expect(answerSync(game, t.ctx(), 'B', q.id, i < matches ? 0 : 1).ok).toBe(true);
expect(s.status).toBe('reveal');
continueSync(game, t.ctx());
}
expect(s.status).toBe('summary');
};
continueSync(game, t.ctx()); // intro → first question
playRound(2); // fail
expect(s.earned).toBe(false);
continueSync(game, t.ctx()); // replay with fresh questions
expect(s).toMatchObject({ round: 0, attempt: 1, status: 'question' });
playRound(1); // fail again: no more replays
continueSync(game, t.ctx());
expect(s).toMatchObject({ round: 1, attempt: 0, status: 'intro' });
continueSync(game, t.ctx());
playRound(3);
expect(room.keys).toEqual([game.rounds[1].keyName]);
continueSync(game, t.ctx()); // earned → straight to round 3
expect(s.round).toBe(2);
continueSync(game, t.ctx());
playRound(5);
continueSync(game, t.ctx());
expect(room.phase).toBe('finished');
expect(room.keys).toHaveLength(2);
expect(new Set(seen).size).toBe(seen.length);
});
});
describe('labyrinth', () => {
const game = labyrinth();
const walk = (room: ReturnType<typeof playingRoom>, t: ReturnType<typeof clock>, path: string) =>
[...path].map((dir) => moveExplorer(game, t.ctx(), 'B', dir as Dir));
it('validates every move on the server', () => {
const room = playingRoom(game);
const t = clock(room);
const s = room.engine as LabyrinthState;
expect(s).toMatchObject({ x: 1, y: 1 });
expect(moveExplorer(game, t.ctx(), 'B', 'n')).toMatchObject({ ok: false, code: 'wall' });
expect(moveExplorer(game, t.ctx(), 'A', 'e')).toMatchObject({ ok: false, code: 'wrong_role' });
expect(s).toMatchObject({ x: 1, y: 1, moves: 0 });
expect(moveExplorer(game, t.ctx(), 'B', 'e').ok).toBe(true);
expect(s).toMatchObject({ x: 2, y: 1, moves: 1 });
});
it('sends the Explorer back to the start on a trap, with a penalty, keys kept', () => {
const room = playingRoom(game);
const t = clock(room);
const s = room.engine as LabyrinthState;
walk(room, t, 'ssss'); // (1,5)
walk(room, t, 'ee'); // (3,5)
const ctx = t.ctx();
moveExplorer(game, ctx, 'B', 'e'); // (4,5) is a trap
expect(s).toMatchObject({ x: 1, y: 1, falls: 1 });
expect(room.penaltySeconds).toBe(60);
expect(ctx.toasts).toEqual([{ to: 'A', message: expect.stringMatching(/fell into a trap/) }]);
});
it('can be completed: three lanterns, then the exit', () => {
const room = playingRoom(game);
const t = clock(room);
const s = room.engine as LabyrinthState;
// Breadth-first route planner over safe cells, standing in for a good Navigator.
const goTo = (target: { x: number; y: number }) => {
const start = `${s.x},${s.y}`;
const prev = new Map<string, { from: string; dir: Dir }>();
const queue = [{ x: s.x, y: s.y }];
const seen = new Set([start]);
while (queue.length) {
const p = queue.shift()!;
for (const dir of Object.keys(DELTAS) as Dir[]) {
const n = { x: p.x + DELTAS[dir].x, y: p.y + DELTAS[dir].y };
const c = cellAt(game.grid, n.x, n.y);
if (c === '#' || c === 'T' || seen.has(`${n.x},${n.y}`)) continue;
seen.add(`${n.x},${n.y}`);
prev.set(`${n.x},${n.y}`, { from: `${p.x},${p.y}`, dir });
queue.push(n);
}
}
const path: Dir[] = [];
for (let at = `${target.x},${target.y}`; at !== start; at = prev.get(at)!.from) path.unshift(prev.get(at)!.dir);
for (const dir of path) expect(moveExplorer(game, t.ctx(), 'B', dir).ok).toBe(true);
};
const [exit] = findCells(game.grid, 'E');
goTo(exit);
expect(room.phase).toBe('playing'); // locked without lanterns
expect(s.message).toBe(game.explorerView.flavour.exitLocked);
for (const lantern of game.lanterns) goTo(lantern);
expect(room.keys).toEqual(expect.arrayContaining(game.lanterns.map((l) => l.name)));
goTo(exit);
expect(room.phase).toBe('finished');
expect(s.falls).toBe(0);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { loadGames } from '../src/content/loader';
import type { DefuseGame, Game, LabyrinthGame, StoryGame, SyncGame } from '../src/content/schemas';
import { initEngine } from '../src/engines/index';
import { createCtx } from '../src/engines/types';
import { RoomStore, type Room } from '../src/rooms/store';
export const games = loadGames();
export function getGame<E extends Game['engine']>(id: string, engine: E): Extract<Game, { engine: E }> {
const game = games.get(id);
if (!game || game.engine !== engine) throw new Error(`no ${engine} game "${id}"`);
return game as Extract<Game, { engine: E }>;
}
export const storyGames = [...games.values()].filter((g): g is StoryGame => g.engine === 'story');
export const defuse = () => getGame('tick-tock-together', 'defuse') as DefuseGame;
export const sync = () => getGame('in-sync', 'sync') as SyncGame;
export const labyrinth = () => getGame('labyrinth-of-lanterns', 'labyrinth') as LabyrinthGame;
/** A room that is already in the `playing` phase for the given game. */
export function playingRoom(game: Game, startedAt = 1_000_000): Room {
const { room } = new RoomStore(null).create(game.id, 'duo', startedAt);
room.players[0].seat = 'A';
room.players.push({ token: 'b', seat: 'B', ready: true, viewing: null });
room.phase = 'playing';
room.startedAt = startedAt;
room.engine = initEngine(game);
return room;
}
/** A clock for tests: every call to `ctx()` is 3 seconds later, clear of the rate limit. */
export function clock(room: Room, start = 1_000_000) {
let now = start;
return {
ctx: (advanceMs = 3000) => createCtx(room, (now += advanceMs)),
get now() {
return now;
},
};
}
+194
View File
@@ -0,0 +1,194 @@
import { describe, expect, it } from 'vitest';
import type { Role } from '@two-keys/shared';
import type { Clue, Lock, StoryGame } from '../src/content/schemas';
import { continueDefuse, defuseAction, solveButton, type DefuseState } from '../src/engines/defuse';
import type { LabyrinthState } from '../src/engines/labyrinth';
import { continueChapter, requestHint, submitLock, type StoryState } from '../src/engines/story';
import { answerSync, continueSync, type SyncState } from '../src/engines/sync';
import { buildState } from '../src/rooms/state';
import type { Room } from '../src/rooms/store';
import { clock, defuse, games, labyrinth, playingRoom, storyGames, sync } from './helpers';
const FORBIDDEN_KEYS = ['answers', 'answer', 'solution', 'fixtures', 'expect', 'failEnding', 'successEnding'];
function payload(room: Room, role: Role, now = 2_000_000): string {
const player = room.players.find((p) => p.seat === role)!;
return JSON.stringify(buildState(room, player, games.get(room.gameId)!, () => true, 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;
}
const locksOf = (game: StoryGame): Lock[] => [
...game.chapters.map((c) => c.lock),
...(game.finale.type === 'escape' ? [game.finale.lock] : []),
];
const cluesFor = (game: StoryGame, role: Role): Clue[] => [
...game.chapters.flatMap((c) => c.clues[role]),
...(game.finale.type === 'escape' ? game.finale.clues[role] : []),
];
describe.each(storyGames.map((g) => [g.id, g] as const))('sanitized story payloads: %s', (_id, game) => {
// Walk the whole game, checking both players' payloads at every step.
it('never contain answers, unrevealed hints, unearned text, or the other role\'s clues', () => {
const room = playingRoom(game);
const t = clock(room);
const s = room.engine as StoryState;
const check = () => {
for (const role of ['A', 'B'] as const) {
const json = payload(room, role);
const other: Role = role === 'A' ? 'B' : 'A';
for (const key of keysOf(JSON.parse(json))) expect(FORBIDDEN_KEYS, `${role} got key "${key}"`).not.toContain(key);
// The other role's private clues never arrive, in any chapter.
const prefix = (c: Clue) => JSON.stringify(c.body).slice(1, 60);
const mine = cluesFor(game, role).map(prefix);
for (const clue of cluesFor(game, other)) {
expect(json, `${role} can see ${other}'s clue ${clue.id}`).not.toContain(JSON.stringify(clue.title));
// (Station Echo hands the same Morse chart to each player in turn.)
if (!mine.includes(prefix(clue))) expect(json).not.toContain(prefix(clue));
}
for (const lock of locksOf(game)) {
const revealed = s.hints[lock.id] ?? 0;
lock.hints.forEach((hint, i) => {
if (i >= revealed) expect(json, `${role} got unrevealed hint ${i + 1} of ${lock.id}`).not.toContain(JSON.stringify(hint));
});
if (!s.solved.includes(lock.id) && lock.successText) {
expect(json, `${role} got successText of unsolved ${lock.id}`).not.toContain(JSON.stringify(lock.successText));
}
// Lock objects carry no answer list at all. (Answer *words* may legitimately
// appear inside a player's own clues, so those can't be grepped for.)
const sent = (JSON.parse(json).views[role].chapters as { lock: object }[]).map((c) => c.lock);
for (const l of sent) expect(Object.keys(l)).not.toContain('answers');
}
// Signal patterns only ever ride on signal clues.
const view = JSON.parse(json).views[role];
const clues = [...view.chapters.flatMap((c: { clues: Clue[] }) => c.clues), ...(view.finale?.clues ?? [])];
for (const clue of clues) if (clue.type !== 'signal') expect(clue.signal).toBeUndefined();
if (room.phase !== 'finished') {
expect(json).not.toContain(JSON.stringify(game.finale.successEnding));
if (game.finale.type === 'accusation') expect(json).not.toContain(JSON.stringify(game.finale.reveal));
}
// Future chapters are not sent ahead of time.
expect(view.chapters.length).toBeLessThanOrEqual(Math.min(s.chapter, game.chapters.length - 1) + 1);
}
};
check();
for (const ch of game.chapters) {
requestHint(game, t.ctx(), ch.lock.id);
check();
submitLock(game, t.ctx(), ch.lock.id, 'wrong');
check();
submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0]);
check();
continueChapter(game, t.ctx());
check();
}
});
it('sends each player only their own and shared clues', () => {
const room = playingRoom(game);
const view = JSON.parse(payload(room, 'A')).views.A;
const ids = view.chapters[0].clues.map((c: Clue) => c.id);
const ch = game.chapters[0];
expect(ids).toEqual([...ch.clues.A, ...ch.clues.shared].map((c) => c.id));
expect(JSON.parse(payload(room, 'A')).views.B).toBeUndefined();
});
});
describe('sanitized payloads: other engines', () => {
it('defuse: the Technician never gets the manual, the Expert never gets the device', () => {
const game = defuse();
const room = playingRoom(game);
const t = clock(room);
continueDefuse(game, t.ctx());
const s = room.engine as DefuseState;
const tech = payload(room, 'A', t.now);
const expert = payload(room, 'B', t.now);
expect(tech).not.toContain('Bomb Defusal Manual');
expect(keysOf(JSON.parse(tech))).not.toContain('manual');
expect(tech).toContain(s.device!.serial);
expect(expert).toContain('Bomb Defusal Manual');
expect(expert).not.toContain(s.device!.serial);
for (const key of ['device', 'serial', 'batteries', 'wires', 'modules', 'strip', 'seed']) {
expect(keysOf(JSON.parse(expert)), `expert got "${key}"`).not.toContain(key);
}
for (const json of [tech, expert]) {
for (const key of FORBIDDEN_KEYS) expect(keysOf(JSON.parse(json))).not.toContain(key);
expect(keysOf(JSON.parse(json))).not.toContain('seed');
}
});
it('defuse: the strip colour is only sent while the button is held', () => {
const game = defuse();
const room = playingRoom(game);
const t = clock(room);
const s = room.engine as DefuseState;
s.level = 1;
// Find a seed whose button has to be held.
for (s.seed = 1; ; s.seed++) {
s.status = 'ready';
continueDefuse(game, t.ctx());
if (solveButton({ ...s.device!.button!, batteries: s.device!.batteries }).type === 'hold') break;
}
const button = () => JSON.parse(payload(room, 'A', t.now)).views.A.device.modules.find((m: { id: string }) => m.id === 'button');
expect(button().strip).toBeNull();
defuseAction(game, t.ctx(100), 'A', 'button', { type: 'hold' });
expect(button()).toMatchObject({ holding: true, strip: s.device!.button!.strip });
});
it('sync: the partner\'s answer is hidden until both have answered', () => {
const game = sync();
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, 2);
const b = JSON.parse(payload(room, 'B')).views.B;
expect(b).toMatchObject({ myAnswer: null, partnerAnswered: true, reveal: null });
expect(JSON.parse(payload(room, 'A')).views.A).toMatchObject({ myAnswer: 2, partnerAnswered: false, reveal: null });
answerSync(game, t.ctx(), 'B', q.id, 1);
expect(JSON.parse(payload(room, 'B')).views.B.reveal).toEqual({ A: 2, B: 1, match: false });
});
it('labyrinth: the Explorer never gets the map, the Navigator never gets the position', () => {
const game = labyrinth();
const room = playingRoom(game);
const s = room.engine as LabyrinthState;
Object.assign(s, { x: 5, y: 3 }); // standing on the crescent moon
const explorer = JSON.parse(payload(room, 'B')).views.B;
for (const key of ['grid', 'glyphs', 'lanterns', 'x', 'y', 'traps']) {
expect(keysOf(explorer), `explorer got "${key}"`).not.toContain(key);
}
expect(explorer.glyphLabel).toMatch(/crescent moon/);
expect(explorer.open).toEqual({ n: true, e: false, s: false, w: true });
expect(JSON.stringify(explorer)).not.toContain('#');
const navigator = JSON.parse(payload(room, 'A')).views.A;
expect(navigator.grid).toEqual(game.grid);
// The only coordinates the Navigator has are the static map features.
const { glyphs, lanterns, grid, ...rest } = navigator;
expect(keysOf(rest)).not.toContain('x');
expect(keysOf(rest)).not.toContain('y');
expect(Object.keys(navigator)).not.toEqual(expect.arrayContaining(['open', 'glyphLabel', 'atExit', 'message']));
});
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import type { Role } from '@two-keys/shared';
import type { StoryGame } from '../src/content/schemas';
import {
agreeAccusation,
continueChapter,
proposeAccusation,
requestHint,
submitAccusation,
submitLock,
type StoryState,
} from '../src/engines/story';
import { computeResult } from '../src/rooms/state';
import { clock, getGame, playingRoom, storyGames } from './helpers';
const correctAccusation = (game: StoryGame) => {
if (game.finale.type !== 'accusation') throw new Error('not an accusation finale');
return Object.fromEntries(game.finale.questions.map((q) => [q.id, q.answer]));
};
describe.each(storyGames.map((g) => [g.id, g] as const))('story: %s', (_id, game) => {
it('accepts every answer listed in the YAML, for every lock', () => {
const locks = [...game.chapters.map((c) => c.lock), ...(game.finale.type === 'escape' ? [game.finale.lock] : [])];
for (const lock of locks) {
for (const answer of lock.answers) {
// Fresh room fast-forwarded to this lock.
const room = playingRoom(game);
const s = room.engine as StoryState;
const index = game.chapters.findIndex((c) => c.lock.id === lock.id);
s.chapter = index === -1 ? game.chapters.length : index;
const t = clock(room);
expect(submitLock(game, t.ctx(), lock.id, answer), `${lock.id}: ${answer}`).toEqual({ ok: true, correct: true });
// Sloppy formatting is fine too.
const room2 = playingRoom(game);
(room2.engine as StoryState).chapter = s.chapter;
const sloppy = ` ${answer.toLowerCase().split('').join(' ')}! `;
expect(submitLock(game, clock(room2).ctx(), lock.id, sloppy).ok && true).toBe(true);
expect((room2.engine as StoryState).solved).toContain(lock.id);
}
}
});
it('plays start to finish and earns one key per chapter', () => {
const room = playingRoom(game);
const t = clock(room);
for (const ch of game.chapters) {
expect(submitLock(game, t.ctx(), ch.lock.id, 'definitely wrong')).toEqual({ ok: true, correct: false });
expect(submitLock(game, t.ctx(), ch.lock.id, ch.lock.answers[0])).toEqual({ ok: true, correct: true });
expect(continueChapter(game, t.ctx()).ok).toBe(true);
}
expect(room.keys).toEqual(game.chapters.map((c) => c.lock.keyName));
if (game.finale.type === 'escape') {
expect(submitLock(game, t.ctx(), game.finale.lock.id, game.finale.lock.answers[0])).toMatchObject({ correct: true });
} else {
expect(proposeAccusation(game, t.ctx(), 'A', correctAccusation(game)).ok).toBe(true);
expect(submitAccusation(game, t.ctx()).ok).toBe(false); // not agreed yet
expect(agreeAccusation(game, t.ctx(), 'A').ok).toBe(false); // can't agree with yourself
expect(agreeAccusation(game, t.ctx(), 'B').ok).toBe(true);
expect(submitAccusation(game, t.ctx())).toMatchObject({ correct: true });
}
expect(room.phase).toBe('finished');
expect(room.result).toMatchObject({ success: true, ending: game.finale.successEnding });
expect(room.wrongGuesses).toBe(game.chapters.length);
expect(computeResult(room, game)).toMatchObject({ success: true, keysEarned: game.chapters.length });
});
});
describe('story rules', () => {
const game = getGame('vineyard-hollow', 'story');
const lock = game.chapters[0].lock;
it('rate-limits to one submission per 2s per lock', () => {
const room = playingRoom(game);
const t = clock(room);
expect(submitLock(game, t.ctx(), lock.id, '0000')).toMatchObject({ ok: true, correct: false });
expect(submitLock(game, t.ctx(500), lock.id, lock.answers[0])).toMatchObject({ ok: false, code: 'rate_limited' });
expect(submitLock(game, t.ctx(2000), lock.id, lock.answers[0])).toMatchObject({ ok: true, correct: true });
});
it('only accepts the lock that is currently open', () => {
const room = playingRoom(game);
const t = clock(room);
const next = game.chapters[1].lock;
expect(submitLock(game, t.ctx(), next.id, next.answers[0])).toMatchObject({ ok: false, code: 'lock_closed' });
submitLock(game, t.ctx(), lock.id, lock.answers[0]);
// Solved but not continued: neither lock is open.
expect(submitLock(game, t.ctx(), next.id, next.answers[0])).toMatchObject({ ok: false });
expect(continueChapter(game, t.ctx()).ok).toBe(true);
expect(continueChapter(game, t.ctx()).ok).toBe(false);
});
it('reveals hints in order with a 2:00 penalty each', () => {
const room = playingRoom(game);
const t = clock(room);
for (let tier = 1; tier <= 3; tier++) {
expect(requestHint(game, t.ctx(), lock.id).ok).toBe(true);
expect((room.engine as StoryState).hints[lock.id]).toBe(tier);
}
expect(requestHint(game, t.ctx(), lock.id).ok).toBe(false);
expect(room.hintsUsed).toBe(3);
expect(room.penaltySeconds).toBe(360);
});
it('fails the case after maxAttempts wrong accusations without saying which part was wrong', () => {
if (game.finale.type !== 'accusation') throw new Error('expected accusation');
const room = playingRoom(game);
(room.engine as StoryState).chapter = game.chapters.length;
const t = clock(room);
const wrong = { ...correctAccusation(game), who: 'clara' };
for (let attempt = 1; attempt <= game.finale.maxAttempts; attempt++) {
const by: Role = attempt % 2 ? 'A' : 'B';
expect(proposeAccusation(game, t.ctx(), by, { who: 'clara' }).ok).toBe(false); // incomplete
proposeAccusation(game, t.ctx(), by, wrong);
agreeAccusation(game, t.ctx(), by === 'A' ? 'B' : 'A');
const ctx = t.ctx();
expect(submitAccusation(game, ctx)).toMatchObject({ ok: true, correct: false });
if (attempt < game.finale.maxAttempts) {
expect(ctx.toasts.map((x) => x.message).join()).toMatch(/Not quite\. 1 attempt left/);
expect(room.phase).toBe('playing');
}
}
expect(room.phase).toBe('finished');
expect(room.result).toEqual({ success: false, ending: game.finale.failEnding, reveal: game.finale.reveal });
});
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["src", "test"]
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@two-keys/shared",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": { ".": "./src/index.ts" },
"scripts": {
"build": "tsc --noEmit -p tsconfig.json",
"test": "vitest run"
},
"devDependencies": {
"vitest": "^2.1.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './types';
export * from './normalize';
export * from './scoring';
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { matchesAnswer, normalizeAnswer } from './normalize';
import { formatClock, rankFor } from './scoring';
describe('normalizeAnswer', () => {
it('strips separators from times', () => {
expect(normalizeAnswer('20:40')).toBe('2040');
expect(normalizeAnswer('20 40')).toBe('2040');
expect(normalizeAnswer(' 20.40 ')).toBe('2040');
});
it('uppercases and removes punctuation', () => {
expect(normalizeAnswer('Fire Swart, Friday!')).toBe('FIRESWARTFRIDAY');
expect(normalizeAnswer('grn-3')).toBe('GRN3');
});
it('strips accents', () => {
expect(normalizeAnswer('Rosé')).toBe('ROSE');
expect(normalizeAnswer('Zoë Müller')).toBe('ZOEMULLER');
});
it('keeps leading zeros', () => {
expect(normalizeAnswer('0138')).toBe('0138');
});
it('returns an empty string for junk', () => {
expect(normalizeAnswer(' ?! ')).toBe('');
});
});
describe('matchesAnswer', () => {
it('matches any accepted answer, normalized on both sides', () => {
expect(matchesAnswer('8:40 pm', ['2040', '840PM', '840'])).toBe(true);
expect(matchesAnswer('red, gold, blue', ['REDGOLDBLUE'])).toBe(true);
expect(matchesAnswer('sammy', ['sammy'])).toBe(true);
});
it('rejects wrong and empty answers', () => {
expect(matchesAnswer('2041', ['2040'])).toBe(false);
expect(matchesAnswer('138', ['0138'])).toBe(false);
expect(matchesAnswer('', [''])).toBe(false);
});
});
describe('scoring', () => {
const base = { success: true, hintsUsed: 0, finalSeconds: 1000, estimatedMinutes: 40, keysEarned: 3, totalKeys: 3 };
it('ranks by hints, time and keys', () => {
expect(rankFor(base)).toBe('Soulmate Sleuths');
expect(rankFor({ ...base, hintsUsed: 1 })).toBe('Dream Team');
expect(rankFor({ ...base, finalSeconds: 40 * 60 * 2 })).toBe('Getting There');
expect(rankFor({ ...base, keysEarned: 1 })).toBe('Getting There');
expect(rankFor({ ...base, success: false })).toBe('Love Is Patient 😅');
});
it('formats clocks', () => {
expect(formatClock(0)).toBe('0:00');
expect(formatClock(125)).toBe('2:05');
expect(formatClock(3725)).toBe('1:02:05');
});
});
+18
View File
@@ -0,0 +1,18 @@
/**
* Answer normalizer: uppercase → strip accents → drop everything except A-Z and 0-9.
* "20:40" and "20 40" → "2040"; "Fire Swart, Friday!" → "FIRESWARTFRIDAY".
*/
export function normalizeAnswer(input: string): string {
return String(input)
.toUpperCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^A-Z0-9]/g, '');
}
/** True if the normalized input equals any normalized accepted answer. */
export function matchesAnswer(input: string, answers: readonly string[]): boolean {
const value = normalizeAnswer(input);
if (value === '') return false;
return answers.some((answer) => normalizeAnswer(answer) === value);
}
+29
View File
@@ -0,0 +1,29 @@
export const RANKS = ['Soulmate Sleuths', 'Dream Team', 'Getting There', 'Love Is Patient 😅'] as const;
export type Rank = (typeof RANKS)[number];
export interface RankInput {
success: boolean;
hintsUsed: number;
finalSeconds: number;
estimatedMinutes: number;
keysEarned: number;
totalKeys: number;
}
export function rankFor(r: RankInput): Rank {
const estimate = r.estimatedMinutes * 60;
const allKeys = r.keysEarned >= r.totalKeys;
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';
return 'Getting There';
}
export function formatClock(totalSeconds: number): string {
const t = Math.max(0, Math.floor(totalSeconds));
const h = Math.floor(t / 3600);
const m = Math.floor((t % 3600) / 60);
const s = t % 60;
const mm = h > 0 ? String(m).padStart(2, '0') : String(m);
return `${h > 0 ? `${h}:` : ''}${mm}:${String(s).padStart(2, '0')}`;
}
+295
View File
@@ -0,0 +1,295 @@
// Types shared by server and client. Everything here describes what may travel over the
// wire: sanitized, per-role views. Raw game content (with answers) is typed on the server.
export type Role = 'A' | 'B';
export const ROLES: readonly Role[] = ['A', 'B'];
export const otherRole = (role: Role): Role => (role === 'A' ? 'B' : 'A');
/** A couch-mode player holds both roles on one device. */
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 Category = 'murder-mystery' | 'escape-room' | 'co-op';
export interface Theme {
primary: string;
accent: string;
background: string;
headingFont: string;
}
export interface RoleInfo {
name: string;
blurb: string;
}
export interface GameSummary {
id: string;
engine: EngineId;
category: Category;
title: string;
tagline: string;
estimatedMinutes: number;
difficulty: number;
theme: Theme;
roles: Record<Role, RoleInfo>;
totalKeys: number;
}
export interface GameMeta extends GameSummary {
intro: string;
}
// ---------- Story engine ----------
export const CLUE_TYPES = [
'note',
'letter',
'document',
'report',
'log',
'table',
'photo',
'map',
'transcript',
'signal',
'diagram',
] as const;
export type ClueType = (typeof CLUE_TYPES)[number];
export interface ClueView {
id: string;
title: string;
type: ClueType;
body: string;
/** Only present on clues of type `signal`. Never print it as text. */
signal?: string;
audience: 'private' | 'shared';
}
export interface LockOption {
id: string;
label: string;
}
export interface LockView {
id: string;
prompt: string;
inputType: 'code' | 'text' | 'choice';
format: string | null;
options: LockOption[] | null;
solved: boolean;
/** Revealed hint tiers only. */
hints: string[];
hintsTotal: number;
wrongAttempts: number;
nudge: boolean;
successText: string | null;
keyName: string | null;
}
export interface ChapterView {
id: string;
title: string;
narrative: string;
clues: ClueView[];
lock: LockView;
}
export interface Suspect {
id: string;
name: string;
role: string;
description: string;
}
export interface AccusationView {
type: 'accusation';
prompt: string;
questions: { id: string; label: string; options: LockOption[] }[];
proposal: { answers: Record<string, string>; by: Role } | null;
agreed: boolean;
attemptsLeft: number;
maxAttempts: number;
}
export interface EscapeFinaleView {
type: 'escape';
prompt: string;
clues: ClueView[];
lock: LockView;
}
export interface StoryView {
engine: 'story';
suspects: Suspect[];
/** Chapters unlocked so far (earlier ones feed the Evidence Locker). */
chapters: ChapterView[];
currentChapter: number;
totalChapters: number;
awaitingContinue: boolean;
finale: AccusationView | EscapeFinaleView | null;
}
// ---------- Defuse engine ----------
export type DefuseStatus = 'ready' | 'active' | 'exploded' | 'cleared';
export interface DefuseClock {
/** Game-seconds left at the moment the state was built. */
remaining: number;
/** Game-seconds per real second (speeds up with strikes). */
speed: number;
running: boolean;
}
export interface WiresModuleView {
id: 'wires';
solved: boolean;
wires: string[];
}
export interface ButtonModuleView {
id: 'button';
solved: boolean;
label: string;
color: string;
holding: boolean;
strip: string | null;
}
export interface KeypadModuleView {
id: 'keypad';
solved: boolean;
symbols: { id: string; label: string; pressed: boolean }[];
}
export type DefuseModuleView = WiresModuleView | ButtonModuleView | KeypadModuleView;
interface DefuseCommon {
engine: 'defuse';
level: { index: number; total: number; title: string; timeSeconds: number };
status: DefuseStatus;
failReason: 'strikes' | 'time' | null;
strikes: number;
maxStrikes: number;
clock: DefuseClock | null;
}
export interface TechnicianView extends DefuseCommon {
seat: 'technician';
device: { serial: string; batteries: number; modules: DefuseModuleView[] } | null;
}
export interface ExpertView extends DefuseCommon {
seat: 'expert';
manual: string;
}
export type DefuseView = TechnicianView | ExpertView;
export type DefuseAction =
| { type: 'cut'; index: number }
| { type: 'tap' }
| { type: 'hold' }
| { type: 'release' }
| { type: 'press'; symbol: string };
// ---------- Sync engine ----------
export interface SyncView {
engine: 'sync';
status: 'intro' | 'question' | 'reveal' | 'summary';
round: {
index: number;
total: number;
title: string;
type: 'match' | 'predict';
blurb: string;
subject: Role | null;
};
isReplay: boolean;
questionNumber: number;
questionsPerRound: number;
pointsForKey: number;
points: number;
question: { id: string; text: string; options: string[] } | null;
iAmSubject: boolean;
myAnswer: number | null;
partnerAnswered: boolean;
/** Both answers, only once both are in. */
reveal: { A: number; B: number; match: boolean } | null;
/** Match / miss for each question revealed so far this round. */
marks: boolean[];
summary: { earned: boolean; canReplay: boolean; keyName: string } | null;
}
// ---------- Labyrinth engine ----------
export type Dir = 'n' | 'e' | 's' | 'w';
export interface ExplorerView {
engine: 'labyrinth';
seat: 'explorer';
glyphLabel: string | null;
open: Record<Dir, boolean>;
keysCarried: number;
totalKeys: number;
atExit: boolean;
message: string;
moves: number;
falls: number;
}
export interface NavigatorView {
engine: 'labyrinth';
seat: 'navigator';
grid: string[];
glyphs: { x: number; y: number; label: string }[];
lanterns: { x: number; y: number; name: string; collected: boolean }[];
keysCarried: number;
totalKeys: number;
moves: number | null;
falls: number;
}
export type LabyrinthView = ExplorerView | NavigatorView;
export type EngineView = StoryView | DefuseView | SyncView | LabyrinthView;
// ---------- Room ----------
export interface GameResult {
success: boolean;
elapsedSeconds: number;
penaltySeconds: number;
finalSeconds: number;
hintsUsed: number;
wrongGuesses: number;
keysEarned: number;
totalKeys: number;
rank: string;
ending: string | null;
reveal: string | null;
}
export interface RoomState {
code: string;
mode: PlayMode;
phase: Phase;
game: GameMeta;
you: { seat: Seat | null; ready: boolean };
partner: { online: boolean; seat: Role | null; ready: boolean; viewing: string | null } | null;
notepad: string;
keys: string[];
timer: {
startedAt: number | null;
finishedAt: number | null;
penaltySeconds: number;
serverNow: number;
};
stats: { hintsUsed: number; wrongGuesses: number };
/** One view per role this socket may see: own role, or both in couch mode. */
views: Partial<Record<Role, EngineView>>;
result: GameResult | null;
}
export type JoinAck =
| { ok: true; code: string; playerToken: string }
| { ok: false; code: string; message: string };
export const PING_EMOJIS = ['👋', '🔍', '💡', '❤️'] as const;
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.base.json",
"include": ["src"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"isolatedModules": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true
}
}