Architecture
How No Exit is built β the path from a player's browser to Redis and Postgres, the game state machine and its lazy transitions, the content pipeline that keeps every answer server-side, and the CI gate in front of every deploy. Written for engineers extending or operating the system. Same architectural family as Terra Incognita; this page documents where it follows that pattern and where it diverges.
No Exit is a hosted virtual escape room for remote team events: up to up to 25 players (5 per team) on a video call split into 2β5 competing teams, racing the same 30-minute, ten-puzzle adventure while a host watches progress and dispenses hints. The client is three static HTML pages (join, play, host console), plus solo and history, with vanilla JS β no framework, no build step. A thin set of Vercel serverless functions keeps game state in Upstash Redis (auto-expiring), writes one results row per team to Neon Postgres when a game ends, and enforces the one inviolable rule: puzzle answers never leave the server. Interface and puzzle content localize to English, Spanish and Portuguese. Ten adventures ship in the repo as encrypted content, opened at runtime with a key held outside it.
- Client
- 3 static pages, vanilla JS
- Transport
- 2s polling β no websockets
- Game state
- Upstash Redis, 6h TTL
- History
- Neon Postgres, 1 write/game
- Auth
- Bearer-style tokens, no accounts
- Tests
- 274 unit + 9 Playwright, gate CI
1 Β· Runtime request path
- Everything a player sees is a static file; all game logic lives in
eleven functions:
config,lookup,create,solo,join,state,answer,hint,chat,host,leaderboard(+history). solois the only ungated endpoint: it starts a single-player run with no host key, so it is deliberately a separate file rather than a branch inside the ADMIN-gatedcreate. Its puzzles are generated at that moment and stored on the game record β see Solo mode below.- Both roles poll
/api/stateevery 2 seconds. State changes are minute-scale, so polling beats websocket complexity at this size β a lesson inherited directly from Terra Incognita's live rooms. - Team chat rides the same poll.
chatonly writes; each player's poll returns their own team's messages newer than the last sequence number they hold, and the host's poll returns every team's. Other teams never see a team's chat. - Locally,
dev-server.jsmaps the same handlers ontolocalhost:3400andstore.jsfalls back to a file store in the OS temp dir β the full game runs with zero cloud dependencies, which is also how unit tests and E2E run in CI.
2 Β· Game state machine
Nothing runs in the background. Timer expiry is computed on
read: every /api/state poll calls
maybeExpire(), which flips running β
finished when the clock runs out. Two polls racing produce the
same outcome, so the race is benign β the same philosophy applied to
teammate double-submits and log appends throughout.
Elapsed play time excludes pauses:
elapsed = end β startAt β pauseAccumMs
where end is pausedAt while
paused, endedAt when finished, else now. Ending
a paused game freezes endedAt at the pause point, so a
long host huddle never counts against anyone.
3 Β· Data model
| Redis key | Contents |
|---|---|
| game:CODE | Meta: state, startAt/pausedAt/pauseAccumMs/endedAt, duration, teams, hostToken, broadcast, resultsWritten flag. Solo runs add mode:'solo', soloSeed and the whole generated soloAdventure (~10 KB). Codes are 4 chars from an alphabet without 0/1/I/L/O. |
| game:CODE:players | Hash playerId β {name, teamId, token, awayMs, awaySince}. awayMs is the running total off the game tab; awaySince is set while the tab is hidden right now, so the host console can flag who is away live. Joins use hsetnx; max 5 players per team and 25 per game. |
| game:CODE:team:ID | Per-team progress: puzzleIdx, solves (with elapsed-ms stamps), hintsTaken per puzzle, penaltyMs, wrongCount, tabPenalties, finishedAtMs. |
| game:CODE:tabcharges | Hash of absences already charged the 1-minute tab penalty, one field per playerId:awaySince. A player off the tab more than 5 seconds while the clock runs costs their team a minute; whichever poll notices first writes the field with hsetnx, so racing polls never charge the same absence twice. Solo runs are exempt. |
| game:CODE:log | Append-only event feed for the host (joins, solves, wrong guesses, hints, returns from off-tab absences, host actions), capped at 200 entries. |
| game:CODE:chat:ID | Team chat: a Redis list of {seq, at, name, text} (plus the sender's id, never sent to clients), capped at 200 with rpush+ltrim so racing senders never drop each other. Players read their own team's list on the state poll, asking only for messages after the last seq they hold; the host reads every team's. game:CODE:chatseq:ID is the counter behind seq. |
| rl:* | Fixed-window rate-limit counters: per-IP on create/solo/join/lookup/history/leaderboard, per-team (15/min) on answer attempts, per-player (20/min) on chat messages. |
All keys carry a 6-hour TTL β a finished game simply evaporates.
Durable history is two Postgres tables. game_results
(one row per team per game: adventure, players, solved count, adjusted
finish, penalties, winner flag), written exactly once when a game
reaches finished, guarded by a flag saved before
the insert so a poll race can at worst drop the row, never duplicate
it. Test-fixture adventures are never recorded. The host can delete a
game from the history page; the delete matches the game code
and the minute it was played, since codes are reused. solo_scores
holds one row per completed solo run and is kept separate so public play
cannot crowd out a host's actual events.
4 Β· The anti-spoiler pipeline
Each adventure is pure data β ten puzzles with accepted answers,
ordered hints with penalties, and a solve message that doubles as the
meta-puzzle's token. Content is loaded only by
api/_lib/content.js, whose sanitizer strips answers,
patterns, unrevealed hint text, unearned solve messages, and
all future puzzles from every player response.
The sanitizer stops answers reaching a browser mid-game; it can't
stop someone reading the source. So the ten real adventures are
committed only as AES-256-GCM ciphertext in
content/sealed/*.enc, opened at load time with
ADVENTURE_KEY β a Vercel environment variable in
production, an Actions secret in CI, never in the repository. Two
plaintext fixtures stay behind so the format is documented by example
and a clone with no key still runs the whole engine suite. GCM's auth
tag means a tampered file fails loudly rather than loading junk.
ADVENTURE_KEY is never used directly: HKDF-SHA256 derives
separate subkeys for the cipher and for the synthetic nonce, which is
taken from the content so re-sealing unchanged files is byte-identical.
The sealing covers the adventure data β puzzle text, answer keys,
hints, solve messages. Images a puzzle shows, in
public/puzzles/, are plain SVGs served as-is, and some
necessarily depict clues or answers in readable form.
The fixture adventure's secrets are distinctive
(XYZZY-β¦) strings; unit tests grep entire serialized API
responses for them at every stage of play. The host console is
sanitized too β safe to screen-share β with the full answer key
available only via an explicit ?answers=1 fetch when the
host opens the crib sheet.
Both the guess and the authored answers are normalized β
lowercase, accents folded (NFKD), punctuation stripped, whitespace
collapsed β so βBΓΊho!β matches βbuhoβ. An optional regex per puzzle
handles patterned answers. The submitted puzzleId guards
against double-advance when two teammates answer simultaneously.
Puzzles that asked players to tally objects on screen have all been removed β from the authored rooms and the generator alike. Two reasons. In a browser the objects are in the DOM, so one line of JavaScript answers the question; and even played honestly, counting is clerical work rather than an insight. What replaced them deduces, decodes or spots a rule: the gallery's night plan now prints its figures instead of hiding them in rows of tiny frames.
Every adventure's finale derives from tokens its own solve messages hand out β initials, quantity sorting, constraint elimination, dial lookup, Roman-numeral arithmetic, or walking a chart. CI parses each chain out of the content and solves it (including simulating the chart walk square by square), so an edit that breaks a puzzle chain cannot deploy.
5 Β· Scoring & ranking
- Teams rank by puzzles solved (descending), then by adjusted time (ascending). Finished teams use their frozen finish time; unfinished teams use their last solve plus penalties, so earlier progress wins ties at the buzzer.
- Each hint reveals in order and adds its penalty exactly once, no matter how many teammates click. Host-granted free hints reveal without penalty; host force-advance marks the puzzle solved with a visible βhost assistβ flag.
- Off-tab telemetry (a Terra Incognita tradition): every poll says whether the player's tab is hidden right now, and a visibility change polls at once. The host console flags who is away live (a badge per player and a banner listing everyone) and logs each return with its duration; a running total also shows on the final ranking.
- Tab penalty: a player off the game tab for more
than 5 seconds while the clock runs costs their team
1:00, once per absence, like a hint penalty. The
grace period forgives a page refresh. Whichever poll notices first β
the host's or any player's β charges it, so a hidden tab that has
stopped polling (or a team of one) is still charged; an
hsetnxontabchargesmakes a double charge impossible. Paused clocks, escaped teams and solo runs are exempt. Players see the rule on the join card, in the lobby and above every puzzle, and the whole team gets an alert naming who left.
6 Β· Security model
hostToken (UUID) is minted at creation;
players get a per-player UUID token at join, held in
localStorage for refresh survival. Every endpoint checks
the caller's token against Redis, in constant time, and roster
lookups only match the game's own player ids. ADMIN_TOKEN
(env) gates game creation, the history page (reading and deleting)
and leaderboard removals,
and fails closed: unset, history and removals refuse everyone, and
creation is open only off Vercel (local dev) β any deployment without
it refuses to create games.
Answer attempts: 15/minute per team in Redis β no
brute-forcing puzzles. Chat: 20 messages/minute per player.
Create/solo/join/lookup/history/leaderboard:
fixed-window per-IP. Loopback traffic without a proxy header is exempt so local
dev and E2E never throttle; real Vercel traffic always carries
x-forwarded-for.
Pause/resume, broadcast, free hint, force-advance, kick (removes the roster entry; the kicked client's next poll 403s and returns to the join page), and end. All host actions append to the audit log the console displays.
7 Β· Client architecture
- Three pages, one poll loop each. The play page is a state renderer: every 2s it repaints lobby / running / paused / finished from the server response. The input field only resets on puzzle transition, so polling never eats a half-typed guess.
- Drift-proof timer. Each response anchors
remainingMstoserverNow; the client extrapolates between polls, so clock skew on a player's machine never shows a wrong countdown. Client-side 5-minute and 1-minute warnings fire from the same anchor, localized per player. - i18n, interface. Interface strings ship as EN/ES/PT
dictionaries (
public/js/i18n.js); static markup usesdata-i18nattributes, dynamic strings go throught(). A test enforces identical key sets and placeholder parity across locales. - i18n, puzzle content. All 100 authored puzzles carry
a per-puzzle
i18nblock. The poll sendslang; the server localizes titles, prompts, hints and solve messages at read time and falls back field by field, so a partial translation degrades to English rather than to blanks. The one rule that makes this safe: accepted answers do not localize β they are the union across every language. The toggle is a display choice a player can flip mid-run, and an answer they already derived must not stop being correct because they switched. - What translation must not touch. Every adventure's finale reads marks out of earlier solve messages β crew words, sigil letters, tally figures, Roman numerals. Translate one and the finale breaks in that language only, which no English-only test would catch. So the marks and the embedded artefacts (ciphertext, anagram tiles, acrostic lines) stay exactly as authored, and a test re-derives every chain in all three languages. Where a mechanic simply cannot survive β an English-only pun such as Today/Tomorrow both starting with T β the puzzle is re-authored for that language rather than translated.
- Served at a root or under a prefix. The same
deployment answers at its own domain root and behind
www.portman.ca/no-exit/. Nothing is configured for it:public/js/api.jsreads the prefix offlocation.pathnameintoBASE, and every API call and script-driven navigation goes throughurl(). Static markup uses relative paths (js/api.js, not/js/api.js) and the stylesheet reaches its backgrounds via../backgrounds/, so both forms resolve alike. Puzzlemediais the exception β sealed content stores it as site-root paths, so it is re-pointed throughurl()at render. The prefix must be served with its trailing slash (/no-exit/) for the relative paths to land inside it. - Theme & art. A day/night toggle swaps CSS variables; each adventure maps to a hand-drawn SVG scene rendered as a fixed background layer, drawn in translucent midtones so one file serves both themes. Unknown slugs fall back to the keyhole motif.
- Effects. Chimes are synthesized with the Web Audio API (no assets); confetti is a ~50-line canvas overlay. Both suppress on first render so a page refresh never replays a celebration.
8 Β· CI/CD & operations
- 290 Vitest unit tests β engine, timing, sanitizer leak checks, content schema + meta chains, i18n parity, host tools, team chat, off-tab status and tab penalties, team caps, admin auth and content sealing β run against the file store (no cloud needed).
- Playwright drives a real multi-team game through the actual UI: host + two player browsers, wrong answer, hint, pause, force-advance, ranking, refresh-resume. Further specs cover team chat privacy, live off-tab alerts and the tab penalty, a full team on the join page, the create form, the language toggle and a seeded solo run.
vercel pull β build β deploy --prebuilt --prodwith a scoped token from GitHub secrets.- Vercel's own git auto-deploy is disabled
(
vercel.json) β a red suite blocks production entirely. Same gate design as Terra Incognita.
Environment: KV_REST_API_URL/TOKEN (Upstash),
DATABASE_URL (Neon), ADMIN_TOKEN,
ADVENTURE_KEY. All four can be absent locally β file store,
skipped history writes and the plaintext fixtures keep every feature
testable offline (history needs ADMIN_TOKEN to be read).
A deployment needs ADMIN_TOKEN to create games at all.
Supply chain: the workflow runs with a read-only GITHUB_TOKEN,
and every action is pinned to a commit SHA rather than a movable tag, so a
compromised or retagged action can't change what runs here. Dependabot
checks the pins monthly and opens one grouped PR, which goes through the
same gate as any other change.
Solo mode Β· generated rooms
Solo is a public, single-player run whose puzzles are built on the spot, so a visitor can try the format without organising an event β and without spending any authored content.
api/_lib/solo/generate.js is a pure function of a seed:
no Math.random, no clock, no I/O beyond the memoised
riddle bank. It runs once, in api/solo.js, and the
finished adventure is stored on the game record. Regenerating per
request would be wasted work and, worse, a mid-deploy generator change
would swap puzzle ids under a live player β which silently blanks the
solved history the finale depends on.
A generated room cannot be translated the way an authored one is β
its sentences do not exist until a seed picks them. So the generator
chooses data once (which cipher, which numbers, which corner
of the room) and renders that data through one lexicon per language in
api/_lib/solo/lang.js. Language never touches the rng, so
every run is the same room in all three. Anything drawn from the theme
is picked as an index into parallel vocabulary lists, and the
Spanish and Portuguese templates write βde el mostradorβ with a
contraction pass repairing it to βdel mostradorβ β which keeps the
vocabulary lists free of grammar.
The generator is public, so a leaked seed is a full answer key. The
client-facing slug is the constant solo, puzzle ids are
literal p1β¦p7, and visuals are inline SVG rather than
files whose names could give something away. A fixed seed can be
supplied by the environment for tests, but only where
VERCEL_ENV is unset β never in production, and never from
a request.
Each run picks one of eight rooms β a drowned library, a night market, a frozen station β which supplies the title, the missing keeper, and the vocabulary every prompt and solve message is dressed in. Underneath, six puzzles are drawn from ciphers, sequences, story arithmetic, rule-spotting, ledger reconciliation and chart-reading, each handing over a mark that the finale consumes.
Classic riddles need human wit, so they come from a sealed bank of
80 (content/solo/riddles.enc) rather than being invented.
CI's E2E job runs with no ADVENTURE_KEY, so the generator
must produce a complete run with zero riddles β a self-contained
anagram stands in. Property tests cover both paths.
- A completed run is written to its own
solo_scorestable, never togame_resultsβ public traffic would otherwise bury real event history, which is read back capped at 200 rows. The score is taken from the game record server-side, never posted by the client. - The board shows each player's fastest escape, and the page says plainly that every room is generated fresh, so runs are not strictly comparable: bragging rights rather than a fair race. A browser-local personal best sits alongside it, so a player with no interest in the board still has something to beat.
- They are also private β
joinrejects them andlookupreports them as missing, so a guessed code cannot add company. - Finishing the last puzzle ends the run in
api/answer.js, settingendedAtas a host's end would. Without a host nothing else would end it, and a missingendedAtmakes every derived timeNaN.
9 Β· Repository map
no-exit/
βββ public/ # everything Vercel serves statically
β βββ index.html Β· play.html Β· host.html Β· solo.html Β· history.html Β· architecture.html
β βββ js/ # api client (BASE + url()), i18n, ui chrome, effects, per-page logic
β βββ css/style.css # theme variables (night/day) + all styling
β βββ backgrounds/ # one hand-drawn SVG scene per adventure
β βββ puzzles/<slug>/ # images referenced by puzzle prompts
βββ api/
β βββ config Β· lookup Β· create Β· join Β· state Β· answer Β· hint Β· chat Β· host Β· history
β βββ solo.js # public, ungated: starts a generated run
β βββ leaderboard.js # public read; host-key delete
β βββ _lib/
β βββ store.js # Upstash Redis β file-store fallback (from TI)
β βββ games.js # engine: codes, timing, ranking, answer checking
β βββ content.js # adventure loader + anti-spoiler sanitizer
β βββ ratelimit.js # fixed-window IP + team limiters (from TI)
β βββ seal.js # AES-256-GCM encrypt/decrypt, ADVENTURE_KEY
β βββ riddles.js # sealed riddle bank loader (optional)
β βββ solo/ # the generator: generate Β· rng Β· themes Β· families Β· words Β· lang
β βββ db.js # Neon writer: game_results + solo_scores
βββ content/adventures/ # plaintext fixtures + authoring guide
βββ content/sealed/ # the ten real adventures, AES-256-GCM
βββ content/solo/riddles.enc # sealed riddle bank for generated runs
βββ content/source/ # gitignored plaintext working copies
βββ scripts/{seal,unseal}.js # encrypt for commit / recover from ciphertext
βββ __tests__/ # 290 Vitest specs (real handlers, file store)
βββ e2e/ # Playwright: full game Β· chat & off-tab Β· full team Β· create form Β· i18n Β· solo
βββ dev-server.js # local static + api host on :3400
βββ .github/workflows/ci.yml # test gate β prebuilt production deploy
βββ LICENSE Β· NOTICE # code MIT; adventures, riddles, artwork Β© all rights reserved