πŸ” No Exit ← Back to the game

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.

Executive summary

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

player browser play.html Β· poll 2s host browser host.html Β· poll 2s Vercel static public/ pages, art, i18n, effects Vercel functions api/*.js Upstash Redis game:CODE* keys TTL 6h Neon Postgres game_results Β· solo_scores GET /api/stateΒ·answerΒ·hintΒ·host every read/write once, on finish

2 Β· Game state machine

lobby running paused finished host: start host: pause host: resume host: end Β· timer expiry (lazy) Β· β€” results written once via recordResultsOnce
Lazy transitions, no cron

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.

Pause math

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 keyContents
game:CODEMeta: 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:playersHash playerId β†’ {name, teamId, token, awayMs}. Joins use hsetnx; max 16 players.
game:CODE:team:IDPer-team progress: puzzleIdx, solves (with elapsed-ms stamps), hintsTaken per puzzle, penaltyMs, wrongCount, finishedAtMs.
game:CODE:logAppend-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

Answers exist in exactly one place

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.

Sealed at rest, because the repo is public

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.

Enforced by tests, not discipline

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.

Forgiving answer checking

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.

Nothing is solved by counting

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.

Meta chains are machine-verified

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

adjusted = elapsedAtLastSolve + penaltyMs

6 Β· Security model

Capability tokens, no accounts

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.

Rate limits

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.

Host controls

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

8 Β· CI/CD & operations

Every push
  1. 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).
  2. Playwright drives a real multi-team game through the actual UI: host + two player browsers, wrong answer, hint, pause, force-advance, ranking, refresh-resume.
Main only, after green
  1. vercel pull β†’ build β†’ deploy --prebuilt --prod with a scoped token from GitHub secrets.
  2. 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.

Generated, then frozen

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.

Generated content, localized

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 seed never leaves the server

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.

Themed, not assorted

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.

Riddles are optional by design

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.

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