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 16 players on a video call split into 2β3 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) 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
- 258 unit + 9 Playwright, gate CI
1 Β· Runtime request path
- Everything a player sees is a static file; all game logic lives in
ten functions:
config,lookup,create,solo,join,state,answer,hint,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. - 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}. Joins use hsetnx; max 16 players. |
| game:CODE:team:ID | Per-team progress: puzzleIdx, solves (with elapsed-ms stamps), hintsTaken per puzzle, penaltyMs, wrongCount, finishedAtMs. |
| game:CODE:log | Append-only event feed for the host (joins, solves, wrong guesses, hints, host actions), capped at 200 entries. |
| rl:* | Fixed-window rate-limit counters: per-IP on create/join/lookup/history, per-team (15/min) on answer attempts. |
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. 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.
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): the client accumulates hidden-tab time while the game runs and reports it with its polls; the server clamps it and only lets it grow. It surfaces β only when nonzero β per player on the host console and as a team total on the final ranking. A deterrent, not a penalty.
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. ADMIN_TOKEN (env)
gates game creation and the history page.
Answer attempts: 15/minute per team in Redis β no
brute-forcing puzzles. Create/join/lookup/history: 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.
- 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
- 142 Vitest unit tests β engine, timing, sanitizer leak checks, content schema + meta chains, i18n parity, host tools β 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.
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. All three
absent locally β file store + skipped history writes keep every feature
testable offline.
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
EscapeRoom/
βββ public/ # everything Vercel serves statically
β βββ index.html Β· play.html Β· host.html Β· history.html Β· architecture.html
β βββ js/ # api client, 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 Β· 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)
β βββ riddles.js # sealed riddle bank loader (optional)
β βββ solo/ # the generator: rng Β· themes Β· families Β· words
β βββ 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__/ # 142 Vitest specs (real handlers, file store)
βββ e2e/ # Playwright: full game Β· create form Β· solo
βββ dev-server.js # local static + api host on :3400
βββ .github/workflows/ci.yml # test gate β prebuilt production deploy