Architecture
How Terra Incognita is built — the runtime path from a phone tapping a map to Redis and Postgres, the live-room state machine, the build pipeline that embeds a world atlas into one HTML file, and the CI gate in front of every deploy. Written for engineers extending or operating the system.
Terra Incognita is a GeoGuessr-style geography game: look at a place — a Street View panorama or a photo — and drop a pin on a world map; points scale with distance. Games draw from six decks: five curated famous-place decks — Brazil, North America, South America, USA, and World, 50 places each from a 202-location pool with statically served photos — or Random world (the menu default), which drops players into a random panorama anywhere on Earth. A Weekly Expedition gives everyone the same Random world deck each ISO week — famous places make an occasional change of pace — with one server-scored attempt per name and its own weekly board, kept alongside every past week's results. The client is one ~0.9 MB HTML file served by Vercel, with a thin set of serverless functions adding Kahoot-style live rooms for up to 14 players (room state in Upstash Redis, expiring automatically), between-round standings, and a persistent all-time leaderboard (Neon Postgres) with an admin reset — separate group and solo boards, each score opening a map replay of the rounds it was earned on. The interface runs in English, Spanish, and Portuguese. Rounds (1–10) and the round timer (10–300 s) are configurable per game. Unit and end-to-end suites run in GitHub Actions on every push; production deploys only when they pass.
- Client
- Single-file HTML · Google guess map · SVG fallback
- Compute
- Vercel Functions (Node, CommonJS)
- State
- Upstash Redis (rooms) · Neon Postgres (leaderboard)
- Imagery
- Google Street View · Wikimedia photos
Operations
Vercel's git auto-deploy is off. GitHub Actions runs the Vitest suite
(with an enforced coverage floor) and the Playwright suite
(including a two-browser live room) on every push; main deploys via vercel deploy --prebuilt --prod
only when both are green.
Every room key carries a 4-hour Redis TTL, refreshed on writes. There is no cleanup job and no orphaned state — finished or abandoned games simply evaporate.
The Street View key is HTTP-referrer-restricted to the production
domain and localhost, limited to the Maps JavaScript API, and served at
runtime from GOOGLE_MAPS_KEY via /api/config —
rotatable without a rebuild, never committed.
1 · Runtime request path
The client is one HTML file with everything embedded — country polygons, photos, gazetteer. Solo games never touch the network; live rooms poll a small JSON API every 1.5 s. English, Spanish and Portuguese are chosen from a menu dropdown and persisted per browser — interface, the 202 curated place names, and API errors all follow. Errors localize on a stable code the server sends beside its English text, so an older client still shows something sensible. Random-world labels come from Google in the chosen language; deck labels are stored in English and translated at render, keeping the leaderboard filter a byte match.
2 · Live-room state machine
One host, up to 14 players, 1–10 rounds. The server holds a single small
state machine per room; clients poll /api/state and render
whatever phase comes back — a refresh rejoins mid-game losslessly.
3 · Data model
Redis — ephemeral room state
| room:CODE | meta JSON: state, mode, rounds, roundIdx, roundStartAt, roundMs, deck, customDeck (random-world panos), hostToken, savedToLb |
| room:CODE:players | hash playerId → {name, token, score} — per-field writes, no races |
| room:CODE:g:N | hash playerId → {lat, lon, km, pts, ms} — written once via HSETNX |
The SDK serializes JSON itself — values are stored as plain
objects (api/_lib/store.js). A file-based store with the same interface
stands in locally when Redis env vars are absent.
Neon — durable leaderboard
| id | serial PK |
| room_code | text — 4-letter room |
| player_name | text |
| score | int — out of 25,000 |
| rounds | int |
| deck | text — which location deck the game played |
| played_at | timestamptz, default now() |
| detail | jsonb — one entry per round {lat, lon, label, glat, glon, km, pts}, powering the click-through map replay. Null for games recorded before the column existed. |
+ weekly_scores: same shape keyed by ISO week, UNIQUE(week, player_name) — one attempt per name per week | |
Auto-created with CREATE TABLE IF NOT EXISTS;
written exactly once per game, guarded by the savedToLb flag in room meta.
/api/leaderboard serves a hall of fame: each
navigator once, at their personal best, with the deck it was earned on.
Round guesses live only in Redis (4 h TTL), so the final write is
also the last chance to keep them — ?detail=<id> replays
a saved game as one map of every real place and that player's pins.
4 · Key flows
Host creates a room
POST /api/createwith mode, rounds (1–10), and round seconds (clamped 10–300).- Famous decks (North America / World / South America / USA): server samples the chosen pool (
shared/decks.js). Random world: the host's browser resolves random panoramas (city-jittered, free metadata lookups) and submits a validated deck; questions carry only the pano id — coordinates appear at reveal. - Host screen shows the 4-letter code; players join at the site or via
/?join=CODE.
Player guesses
- Pin click is bounds-checked against the map, then
POST /api/guesssends only lat/lon. - Server validates token, phase, and clock; scores with haversine;
HSETNXblocks double guesses. - Points are added to the player's score but hidden from state responses until reveal.
Rounds advance lazily
- No timers run server-side; every
/api/statepoll checks the clock. - question → reveal fires when all players answered or time (+2 s grace) expired.
- Concurrent polls racing the transition are benign — both write the same state.
Street View resolution
/api/configsupplies the referrer-locked Maps key; the Maps script loads once, on demand.- Each round asks
StreetViewServicefor the nearest outdoor panorama within 1.5 km. - No coverage → the round silently falls back to the embedded photo. Labels, addresses, and dates are hidden.
- Random world: the resolver jitters around one of 3,057 gazetteer cities and snaps to the nearest pano within 5 km — coverage-biased without any extra data.
5 · Scoring
| Distance | Points | Feels like |
|---|---|---|
| 0 km | 5,000 | Perfect pin |
| 125 km | 4,698 | Right region |
| 1,000 km | 3,033 | Right country, roughly |
| 3,000 km | 1,116 | Right continent |
| 10,000 km | 34 | Wrong hemisphere |
Identical everywhere; in rooms, the weekly, and recorded solo games the formula runs server-side so a modified client cannot inflate scores. The solo Hall board ranks by average points per counted round, because solo games vary in length. Games run 1–10 rounds (default 5); games longer than five rounds score only the best five, so the maximum is min(rounds, 5) × 5,000. Reveals count the points up, drop confetti on 4,500+ pins, and — in live rooms — show ranked between-round standings with rank-movement arrows.
6 · Security
Organized by what each control defends against. There are no accounts and no PII — the whole durable footprint is names and scores.
Access control
- A room is a capability: the 4-letter code admits you during the lobby window; a per-player UUID token authorizes every later call.
- The host token is a separate secret — only its holder can start rounds, end them early, or advance the game.
- Session tokens live in per-tab
sessionStorage— refresh rejoins, but tabs are independent participants. - Admin actions (clear / archive the leaderboard) sit behind an
ADMIN_TOKEN-gated endpoint; the token compares in constant time (hashedtimingSafeEqual).
Cheating & fairness
- All scoring is server-side (haversine + exponential decay); coordinates are validated before scoring.
- State responses omit scores and answers during open questions; random-world rounds send only a pano id, coordinates at reveal.
- The weekly deck is revealed one round per guess — the console can't preview upcoming locations. Designated weeks play Random world instead of famous places: the first player's browser resolves the panoramas and the server stores them first-write-wins for the whole week, sending pano ids only and coordinates at reveal.
- Solo games recorded to the Hall are Random world only: the client resolves and submits the deck (the same trust model as hosting a random room — the submitting browser knows its own deck), and the server validates it, enforces timing and no-rewrites, scores every guess, and writes the row as
room_code 'SOLO'. The solo board appears only under the Random world deck filter and scores by average points per counted round, since solo games vary in length (1–10 rounds, best five counted); group games are the default Hall view. Famous-deck solo is casual and never recorded — 50 memorizable places make it a memory test, not a navigation one. - Round timing is enforced server-side (late guesses score zero, small grace window) and a first guess is final (
HSETNX). - Time spent off the tab mid-round shows as a social 👀 badge — solo reveals and the final table, live-room reveals, weekly boards. Informational, never punitive; values are clamped server-side.
Abuse & input
- Per-IP fixed-window rate limits over Redis on the write endpoints: room create and join, weekly and recorded-solo starts, and admin attempts. Fail-open — a store hiccup never locks players out.
- Names and labels are length-capped and markup-stripped at the API, then HTML-escaped again at render — two independent layers against stored XSS. Duplicate names get numbered.
- E2E test players (
E2E-*names) are filtered server-side and never reach a leaderboard. - Room-code allocation returns 503 if the code space is momentarily exhausted rather than overwriting a live room.
Platform
- Every response carries
X-Content-Type-Options: nosniff,X-Frame-Options: DENY, and a strict referrer policy. - The Maps key is public by design, defended by referrer + API restrictions and a monthly quota cap.
- GitHub Actions is the only path to production, using a dedicated scoped Vercel token — a red test means the deploy never starts.
- Room state expires after 4 hours (Redis TTL); the season archive moves rows in a single atomic statement, so a mid-flight failure can't duplicate or lose them.
Accepted risks: the current round's location is knowable client-side (the photo URL names it) — inherent to a client-rendered game, and the 👀 badge is the social answer. Off-tab time is client-reported and suppressible by a motivated cheater. Weekly names are first-come — no accounts means no identity. Room codes are guessable in principle; the short TTL, rate limits, and token-gated calls bound the blast radius.
7 · Build pipeline (assets)
build/game-template.htmlis the source of truth;build/assemble.jsinjects data and writesindex.html.- World map: Natural-Earth-derived GeoJSON → simplified SVG paths (~110 KB, 179 countries) by
build/build-map.js. - Photos: Wikipedia lead images, downscaled by
build/build-photos.sh(idempotent) and served statically from/photos. - Search gazetteer: GeoNames cities ≥50k (~12.4k), admin-1 regions (~2.8k, boxes derived from member cities) + country bounding boxes (~700 KB; country names indexed once) by
build/build-search.js. Sources re-downloaded, never committed. - Locations live once in
shared/locations.js— embedded into the client and imported by the scoring API; order is the contract. - Rebuild =
node build/assemble.js, prepend doctype, commit.
8 · CI/CD & deployment
Vercel's git integration is connected but its auto-deploy is disabled
(vercel.json). GitHub Actions is the only path to production,
and it runs both suites first — a red test means the deploy never starts.
The same deployment answers on two URLs: its own Vercel domain at the root, and
www.portman.ca/terra-incognita/, which proxies it as a subpath.
The client reads the prefix off location.pathname into
BASE and prepends it to every /api/ and
/version.txt request, so one build serves both with no
environment flag. e2e/subpath.spec.js stands up a local stand-in
for that proxy and holds both forms honest.
9 · Repository map
index.html the game — built artifact, fully self-contained version.txt build stamp the page polls for the update banner architecture.html this page vercel.json git auto-deploy off · /architecture rewrite · security headers api/ _lib/store.js Upstash Redis wrapper + file-store dev fallback _lib/rooms.js codes · decks · scoring · lazy transitions · round detail _lib/db.js Neon client + leaderboard / weekly / archive DDL _lib/hall.js personal-best hall query + one game's replay _lib/ratelimit.js per-IP fixed windows over Redis (fail-open) create.js · join.js room lifecycle guess.js · next.js server-side scoring · host transitions · final write state.js · leaderboard.js polling endpoint · hall top 20 + ?detail replay solo.js recorded solo attempts (random world, server-scored) admin.js ADMIN_TOKEN-gated actions (clear · archive season) weekly.js · _lib/weekly.js weekly expedition: seeded or random deck, server-scored config.js serves the referrer-locked Maps key photos/ 202 famous-place photos, served statically shared/locations.js 202 locations — client decks + server answer key (append-only) shared/decks.js world / na / sa / us / br deck pools + display labels shared/locations.i18n.js es/pt name + place for all 202 (build fails if one is missing) build/ game-template.html client source (edit this, not index.html) assemble.js embeds map + decks + gazetteer, stamps build id -> index.html build-map.js GeoJSON -> compact SVG paths build-photos.sh Wikipedia lead images -> photos/<key>.jpg (idempotent) build-search.js GeoNames -> offline search index (cities · regions · countries) __tests__/ vitest: scoring, store contract, API handlers, decks, weekly, solo, i18n e2e/ playwright: menu · solo · search · live room · weekly · gmap .github/workflows/ci.yml test gate + production deploy