commit 0de6617c82c2f4fe38ae985f26afbee854e7a998 Author: Joshua Oosthuizen Date: Mon Oct 12 22:36:12 2026 +0200 V1.0.0 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c1a234 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +server/data/ +*.log +.DS_Store +.idea/ +temp/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7d7ccf6 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/GAMES.md b/GAMES.md new file mode 100644 index 0000000..e4b0821 --- /dev/null +++ b/GAMES.md @@ -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# +########### +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..39d1865 --- /dev/null +++ b/README.md @@ -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. diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..f5860fb --- /dev/null +++ b/client/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + Two Keys ยท Co-op mysteries for couples + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..f5c9206 --- /dev/null +++ b/client/package.json @@ -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" + } +} diff --git a/client/postcss.config.js b/client/postcss.config.js new file mode 100644 index 0000000..e008c9c --- /dev/null +++ b/client/postcss.config.js @@ -0,0 +1,3 @@ +export default { + plugins: { tailwindcss: {}, autoprefixer: {} }, +}; diff --git a/client/public/favicon.svg b/client/public/favicon.svg new file mode 100644 index 0000000..775c6a2 --- /dev/null +++ b/client/public/favicon.svg @@ -0,0 +1 @@ +๐Ÿ”‘ diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..26973b9 --- /dev/null +++ b/client/src/App.tsx @@ -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. + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/client/src/components/ClueCard.tsx b/client/src/components/ClueCard.tsx new file mode 100644 index 0000000..d3919b3 --- /dev/null +++ b/client/src/components/ClueCard.tsx @@ -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 = { + 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 ( + + {isPrivate ? '๐Ÿ‘ Only you can see this' : 'You both have this'} + + ); +} + +function Header({ clue, light, mono }: { clue: ClueView; light: boolean; mono?: boolean }) { + return ( +
+
+
+ {TYPE_LABEL[clue.type]} +
+

{clue.title}

+
+ +
+ ); +} + +// `.` = 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; + 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 ( +
+
+
+
+ +
+ ); +} + +export function ClueCard({ clue }: { clue: ClueView }) { + switch (clue.type) { + case 'note': + return ( +
+
+ {clue.body} +
+ ); + case 'letter': + return ( +
+
+ {clue.body} +
+ ); + case 'report': + case 'document': + case 'log': + return ( +
+
+ {clue.type === 'log' ? 'โ–ฎ Access log' : clue.type === 'report' ? 'โ–ฎ Official report' : 'โ–ฎ On file'} +
+
+
+ {clue.body} +
+
+ ); + case 'photo': + return ( +
+
+ {clue.body} +
+
+
+
+
+ ); + case 'map': + case 'diagram': + return ( +
+
+ {clue.body} +
+ ); + case 'transcript': + return ( +
+
+ {clue.body} +
+ ); + case 'signal': + return ( +
+
+ {clue.signal && } + {clue.body} +
+ ); + case 'table': + return ( +
+
+ {clue.body} +
+ ); + } +} diff --git a/client/src/components/GameCard.tsx b/client/src/components/GameCard.tsx new file mode 100644 index 0000000..8ca513c --- /dev/null +++ b/client/src/components/GameCard.tsx @@ -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 = { + 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 ( +
+ + + setOpen(false)} title={game.title}> +

{game.tagline}

+
+ {(['A', 'B'] as const).map((role) => ( +
+ +

{game.roles[role].blurb}

+
+ ))} +
+

How are you playing?

+
+ + +
+
+
+ ); +} diff --git a/client/src/components/HintButton.tsx b/client/src/components/HintButton.tsx new file mode 100644 index 0000000..828a2f0 --- /dev/null +++ b/client/src/components/HintButton.tsx @@ -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 ( + <> + + + { setOpen(false); setConfirming(false); }} title="Hints"> +

+ Hints go from a gentle nudge to nearly the answer. Each one adds +2:00 to + your time, and you both see it. +

+
    + {lock.hints.map((hint, i) => ( +
  1. +
    Hint {i + 1}
    +

    {hint}

    +
  2. + ))} +
+ + {left === 0 ? ( +

That's every hint for this lock.

+ ) : confirming ? ( +
+

This basically solves it. Sure?

+
+ + +
+
+ ) : ( + + )} +
+ + ); +} diff --git a/client/src/components/KeyRing.tsx b/client/src/components/KeyRing.tsx new file mode 100644 index 0000000..21126a0 --- /dev/null +++ b/client/src/components/KeyRing.tsx @@ -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 ( + <> + + + setOpen(false)} title="Key ring"> + {keys.length === 0 ? ( +

No keys yet. Every lock you open together earns one.

+ ) : ( +
    + {keys.map((name, i) => ( +
  • + ๐Ÿ”‘ + {name} +
  • + ))} +
+ )} +

+ {keys.length} of {total} keys collected. +

+
+ + ); +} diff --git a/client/src/components/LockPanel.tsx b/client/src/components/LockPanel.tsx new file mode 100644 index 0000000..a417f2f --- /dev/null +++ b/client/src/components/LockPanel.tsx @@ -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 ( +
+
setShake(false)} className={`mx-auto max-w-2xl ${shake ? 'shake' : ''}`}> + + + {lock.inputType === 'choice' && lock.options ? ( +
+ {lock.options.map((o) => ( + + ))} +
+ ) : null} + +
+ {lock.inputType !== 'choice' && ( + 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`} + /> + )} + + +
+ +

+ {wrong ? ( + + Not quite. {lock.nudge ? 'Need a hint? ๐Ÿ’ก' : 'Compare notes and try again.'} + + ) : lock.nudge ? ( + Stuck? Need a hint? ๐Ÿ’ก + ) : lock.format && lock.inputType !== 'choice' ? ( + <>Format: {lock.format} + ) : ( + <>Either of you can answer. You both need to agree first! + )} +

+
+
+ ); +} diff --git a/client/src/components/Notepad.tsx b/client/src/components/Notepad.tsx new file mode 100644 index 0000000..2464c07 --- /dev/null +++ b/client/src/components/Notepad.tsx @@ -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>(); + 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 ( + <> + + { + flush(draft); + setOpen(false); + }} + title="Shared notepad" + > +

You both see this, live. Jot down times, names, half-baked theories.

+