|
| 1 | +# Out Of Context Party Games |
| 2 | + |
| 3 | +Online multiplayer party game platform at [outofcontext.party](https://www.outofcontext.party). |
| 4 | +Players join lobbies via 4-character codes and play collaborative/social games in real time. |
| 5 | + |
| 6 | +**Stack:** React 19 + Tailwind v4 + Vite in `client/`, TypeScript Express + tRPC on the backend, |
| 7 | +Node 22. The realtime transport is a typed **tRPC** contract: mutations/queries over HTTP, |
| 8 | +server->client push over **SSE**. Everything is TypeScript - there is no JavaScript left in the app. |
| 9 | + |
| 10 | +The Vue 2 / Semantic UI / Socket.IO / webpack app this replaced is gone, along with the document that |
| 11 | +described it. If you find a doc or comment presenting any of that as current, it is stale - fix it or |
| 12 | +delete it. The behaviours worth keeping from the old code now live as comments beside the code that |
| 13 | +kept them (see `Random.gauss` and `LobbyPlayer.id`); anything else is in the git history. |
| 14 | + |
| 15 | +The rewrite plan that produced this codebase is finished, so it and its phase documents were |
| 16 | +deleted. They are in the git history if you ever need them. |
| 17 | + |
| 18 | +Remaining planned work: **horizontal scale** is designed but not enabled - one instance runs the |
| 19 | +whole app today. The intended shape, since this note is now the only record of it: partition by |
| 20 | +lobby, each owned by one instance and routed by its code; Redis holds the lobby->instance registry |
| 21 | +plus pub/sub for cross-instance stats and failover; the existing pako+JSON persistence becomes the |
| 22 | +failover substrate. No tRPC procedure signature changes - only where a lobby lives and how a client |
| 23 | +finds it. `REDIS_URL` in `.env.example` is the switch, unused today. |
| 24 | + |
| 25 | +## Hard rules |
| 26 | + |
| 27 | +- **No decorative unicode.** The rule targets typographic garnish, not letters: no emoji, no em/en |
| 28 | + dashes, no arrows, no ellipsis character, no curly quotes, no box-drawing. Write `->` / `<->`, `-`, |
| 29 | + `...`, and build diagrams from `- | +`. This applies in code, comments, docs, and commit messages. |
| 30 | + **Words are exempt.** A word in another language keeps its correct spelling wherever it appears, |
| 31 | + including in commit messages and docs - German umlauts and eszett, Spanish tildes and inverted |
| 32 | + punctuation, French accents and cedillas. Do not transliterate `Woerder` for `Wörder`: a |
| 33 | + misspelling is worse than the character it avoids. Two further deliberate exceptions live in data: |
| 34 | + the em dash in an authorship credit, and Redacted's block-character subtitle. |
| 35 | +- **All copy is translated.** No hardcoded user-facing strings. Every UI string lives in a locale |
| 36 | + file rendered via `t()`; the server returns `AppErrorCode` values, never prose. |
| 37 | +- **Plans are checklists.** When working from a plan document, flip each `- [ ]` to `- [x]` |
| 38 | + immediately after completing AND verifying it, and skip items already checked. Partition work by |
| 39 | + whole milestone so parallel agents do not collide. |
| 40 | + |
| 41 | +## Architecture |
| 42 | + |
| 43 | +``` |
| 44 | +React + Tailwind + tRPC client |
| 45 | + <-> tRPC over HTTP (mutations/queries) + SSE (server push) |
| 46 | +Express + tRPC router (port 8080, single instance today) |
| 47 | + -> Lobby -> Game instances |
| 48 | + -> Persistence (JSON + pako, local disk) + Drawings (content-addressed blobs) |
| 49 | +``` |
| 50 | + |
| 51 | +Planned for scale: N instances partitioned by lobby code, with a Redis lobby->instance registry and |
| 52 | +persistence as the failover substrate. |
| 53 | + |
| 54 | +## Project Structure |
| 55 | + |
| 56 | +``` |
| 57 | +main.ts # Express entry: tRPC mount, REST, static, cron |
| 58 | +gameInfo.ts # Game SHAPE only - config fields, types, bounds, option ids. No copy. |
| 59 | +core/ # Game engine, transport-agnostic |
| 60 | + Lobby.ts # Lobby lifecycle, members, config, game selection |
| 61 | + Member.ts # Member registry, SSE stream tracking, inactivity culling |
| 62 | + Persistence.ts # Save/load lobby state (JSON + pako) |
| 63 | + Drawings.ts # Content-addressed drawing blobs on disk + LRU cache |
| 64 | + games/ |
| 65 | + game.ts # Abstract base class |
| 66 | + story.ts # Raconteur - chain-based collaborative writing |
| 67 | + comic.ts # Dilettante - drawing chains (extends Story) |
| 68 | + draw.ts # Scribble - draw/describe telephone (extends Story) |
| 69 | + redacted.ts # Redacted - write/tamper/repair (extends Story) |
| 70 | + recipe.ts # Hodgepodge - recipe creation (extends Story) |
| 71 | + assassin.ts # Wurderer - word assassination setup |
| 72 | + util/ # Chain, Random, Sanitize, wordLists, reactions |
| 73 | + dicts/ # Word lists (en/de/es/fr) + animals, colors |
| 74 | +server/ # HTTP/transport layer |
| 75 | + trpc/router.ts # Root router |
| 76 | + trpc/routers/ # lobby, game, member, rocketcrab, serverInfo |
| 77 | + trpc/context.ts # Member resolution per request |
| 78 | + trpc/trpc.ts # publicProcedure / adminProcedure |
| 79 | +shared/ # Types shared by client and server |
| 80 | + types.ts # LobbyInfo, GameMeta, ConfigFieldDef, GameId |
| 81 | + events.ts # SERVER_EVENT_NAMES, GAME_MESSAGE_TYPES |
| 82 | + errors.ts # AppErrorCode |
| 83 | + drawing.ts # Canvas size, MIME, byte caps, image sniffing |
| 84 | +client/src/ |
| 85 | + pages/ # Home, GameList, NotFound, JoinLobbyModal, lobby/, game-list/ |
| 86 | + games/ # GameRenderer + one directory per game + shared/ |
| 87 | + components/ui/ # 25 Tailwind primitives (Button, Select, Table, ...) |
| 88 | + components/widgets/ # SettingsPanel, Timer, PageWrapper, doodle/, player-list/ |
| 89 | + contexts/ # LobbyContext, GameStateContext, PreferencesContext |
| 90 | + hooks/ # useLobby, useGame, useTurnSound, usePreferences, ... |
| 91 | + trpc/ # Client, links, connection state, member id |
| 92 | + i18n/ # Setup, resources, languages, pseudo-locale |
| 93 | + locales/<lang>/ # en, de, es, fr - all user-facing copy |
| 94 | +test/ # Backend integration tests (vitest, node) |
| 95 | +e2e/ # Playwright flows + per-game specs |
| 96 | +``` |
| 97 | + |
| 98 | +## Key Concepts |
| 99 | + |
| 100 | +- **Lobby:** 4-char code, members (players + spectators), one admin, one selected game, config |
| 101 | + values. States: WAITING <-> PLAYING. |
| 102 | +- **Game base class:** `start()`, `stop()`, `handleMessage(pid, type, data)`, `getState()`, |
| 103 | + `getPlayerState(pid)`, `save()`/`restore()`, `onPlayersChanged()`. |
| 104 | +- **Game inheritance:** Story is the base for Comic, Draw, Redacted, Recipe. Assassin is standalone. |
| 105 | +- **Chain:** the core data structure - a sequence of links edited by rotating players. |
| 106 | +- **Player states:** typically cycle EDITING -> WAITING -> READING. |
| 107 | +- **Copy vs shape:** `gameInfo.ts` defines which config fields exist and their bounds; every string |
| 108 | + comes from `client/src/locales/en/game-<id>.json`, keyed by the ids in gameInfo. A coverage test |
| 109 | + fails when one is missing. |
| 110 | +- **Client state:** React context, not Redux. `LobbyContext` (connection + lobby info + emotes), |
| 111 | + `GameStateContext`, `PreferencesContext` (localStorage-backed). i18next owns the language. |
| 112 | +- **Member identity:** the client sends a stable id (`oocMemberId`); a member survives brief SSE |
| 113 | + reconnects and is reaped only after a grace window. |
| 114 | + |
| 115 | +## 6 Games |
| 116 | + |
| 117 | +| Key | Title | Type | Players | |
| 118 | +|-----|-------|------|---------| |
| 119 | +| story | Raconteur | Write story lines with limited context | 2-256 | |
| 120 | +| comic | Dilettante | Drawing/caption chains | 2-256 | |
| 121 | +| draw | Scribble | Draw<->describe telephone | 2-256 | |
| 122 | +| redacted | Redacted | Write -> tamper -> repair cycle | 4-256 | |
| 123 | +| recipe | Hodgepodge | Collaborative recipe with ITEM placeholders | 2-256 | |
| 124 | +| assassin | Wurderer | Word assassination (in-person, web setup only) | 2-256 | |
| 125 | + |
| 126 | +## tRPC Procedures |
| 127 | + |
| 128 | +**lobby:** `exists`, `create`, `join`, `leave`, `spectate`, `replace`, `emote` (rate-limited), |
| 129 | +`setGame`*, `setConfig`*, `toggleAdmin`*, `grantAdmin`*, `onInfo` (subscription) |
| 130 | +**game:** `start`*, `end`*, `message`, `onState` (subscription) |
| 131 | +**member:** `setName` | **rocketcrab:** `create` | **serverInfo:** `info`, `version` |
| 132 | + |
| 133 | +`*` = `adminProcedure`, which enforces the admin check in one place. |
| 134 | + |
| 135 | +**Server->client events** (`SERVER_EVENT_NAMES` in `shared/events.ts`, pushed over SSE): |
| 136 | +`member:id`, `member:nameOk`, `member:kicked`, `version`, `lobby:join`, `lobby:leave`, `lobby:info`, |
| 137 | +`lobby:emote`, `game:info`, `game:player:info`, `game:reaction`, and `{game}:result` for |
| 138 | +story/comic/draw/redacted/recipe (assassin has no results phase). |
| 139 | + |
| 140 | +## REST API |
| 141 | + |
| 142 | +- `GET /api/v1/lobby/:code` - does this lobby exist |
| 143 | +- `GET /api/v1/info` - server stats |
| 144 | +- `GET /api/v1/drawing/:id` - drawing bytes (immutable, long-cached) |
| 145 | +- `POST /api/v1/drawing` - upload a drawing, returns its id (member must be in a lobby) |
| 146 | +- `POST /api/v1/rocketcrab` - RocketCrab integration |
| 147 | + |
| 148 | +Drawings travel as ids over SSE and as bytes over these plain GETs. tRPC sets |
| 149 | +`cache-control: no-transform` on SSE, so `compression` never compresses subscription traffic - the |
| 150 | +only way to shrink that channel is to send less through it. |
| 151 | + |
| 152 | +## Commands |
| 153 | + |
| 154 | +```bash |
| 155 | +npm run dev # Backend (tsx watch) + Vite client, concurrently |
| 156 | +npm run dev:server # Backend only npm run dev:client # Client only |
| 157 | +npm run dev:storybook # Storybook |
| 158 | +npm test # All vitest projects (client = jsdom, backend = node) |
| 159 | +npm run test:client # or test:backend / test:coverage / test:watch |
| 160 | +npm run typecheck:server |
| 161 | +npm run typecheck:client |
| 162 | +npm run lint:client |
| 163 | +npm run build # Production client build |
| 164 | +npm run e2e # Playwright |
| 165 | +``` |
| 166 | + |
| 167 | +Prettier covers `client/src` (`npm run format` there); `client/src/index.css` is exempt via |
| 168 | +`.prettierignore` because its compact one-line rules are deliberate. |
| 169 | + |
| 170 | +## i18n |
| 171 | + |
| 172 | +Languages: **en, de, es, fr**, plus `en-XA` - a pseudo-locale generated from `en` at load and |
| 173 | +reached with `?lng=en-XA`. It accents and pads every string, so anything still rendering as plain |
| 174 | +English is hardcoded copy to fix and anything clipped is a layout bug. |
| 175 | + |
| 176 | +- One namespace per JSON file in `client/src/locales/<lang>/`. Keys are typed from the `en` files, so |
| 177 | + a typo in a key is a compile error. |
| 178 | +- Adding a language: create the folder, copy the `en` files, and add the code to `LANGUAGES` in |
| 179 | + `client/src/i18n/languages.ts` (it drives both `supportedLngs` and the settings picker). Without |
| 180 | + that the language silently resolves to `en`. |
| 181 | +- A game's own copy - title, tagline, how-to steps, config labels, option labels - lives in its |
| 182 | + `game-*.json`. `configCoverage.test.ts` fails on a missing entry. |
| 183 | +- Game titles are product names and are NOT translated. Their taglines are. |
| 184 | +- Server-side copy does not exist: the server returns `AppErrorCode`s and the client renders them |
| 185 | + with `t('errors:' + code)`. `errorCoverage.test.ts` fails on a code with no message. |
| 186 | + |
| 187 | +## Global Features |
| 188 | + |
| 189 | +- **Dark mode:** `html.dark` class driving Tailwind's `dark:` variant |
| 190 | +- **Turn sounds:** 5 WAV options, with a volume preference |
| 191 | +- **Streamer mode:** hides the lobby code |
| 192 | +- **Reactions:** 5 emoji reactions on results, broadcast live |
| 193 | +- **Vibration API** on turn notifications |
| 194 | +- **RocketCrab integration** via query params |
| 195 | +- **Version mismatch:** the client auto-reloads when the server reports a different version |
| 196 | +- **Analytics:** GA4 via `VITE_GA_MEASUREMENT_ID` |
| 197 | +- **localStorage keys** - preserve verbatim, note `occDarkMode` uses the `occ` prefix while the rest |
| 198 | + use `ooc`: `occDarkMode`, `oocHideLobby`, `oocTurnSound`, `oocSoundVolume`, `oocName`, `oocLang`, |
| 199 | + `oocMemberId` |
0 commit comments