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 four decks: three curated famous-place decks — North America (37, the menu default), World (40), and South America (35), from a 111-location pool with statically served photos — or Random world, which drops players into a random panorama anywhere on Earth. A Weekly Expedition gives everyone the same seeded deck each ISO week — one server-scored attempt per name, with its own weekly board. The client is one ~350 KB 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. 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.
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 |
| played_at | timestamptz, default now() |
+ 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 the top 20 by score.
4 · Key flows
Host creates a room
POST /api/createwith mode, rounds (1–10), and round seconds (clamped 10–300).- Famous decks (World / North America / South America): 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 for solo and multiplayer; in rooms the formula runs server-side so a modified client cannot inflate scores. Games run 1–10 rounds (default 5), so the maximum is rounds × 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 & sessions
- No accounts. A room is a capability: the 4-letter code admits you in 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.
- Guess coordinates are validated and scored server-side; state responses omit scores and answers during open questions.
- Player names are length-capped and stripped of markup; duplicates get numbered.
- Session tokens live in per-tab
sessionStorage— refresh rejoins, but tabs are independent participants. - The Maps key is public by design, defended by referrer + API restrictions and a monthly quota cap.
- An
ADMIN_TOKEN-gated endpoint (/api/admin, UI under the leaderboard) can clear the all-time leaderboard; E2E test players (E2E-*names) are filtered out server-side and never reach it.
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 ≥200k + country bounding boxes (~160 KB) by
build/build-search.js. - 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.
9 · Repository map
index.html the game — built artifact, fully self-contained architecture.html this page vercel.json git auto-deploy off · /architecture rewrite api/ _lib/store.js Upstash Redis wrapper + file-store dev fallback _lib/rooms.js codes · decks · scoring · lazy transitions _lib/db.js Neon client + leaderboard DDL create.js · join.js room lifecycle guess.js · next.js server-side scoring · host transitions state.js · leaderboard.js polling endpoint · all-time top 20 admin.js ADMIN_TOKEN-gated actions (clear leaderboard) weekly.js · _lib/weekly.js weekly expedition: seeded deck, server-scored attempts photos/ 50 famous-place photos, served statically config.js serves the referrer-locked Maps key shared/locations.js 111 locations — client decks + server answer key (append-only) shared/decks.js world / na / sa famous-deck pools (key lists) build/ game-template.html client source (edit this, not index.html) assemble.js embeds map + photos + gazetteer -> index.html build-map.js GeoJSON -> compact SVG paths build-photos.sh Wikipedia photos -> data URIs build-search.js GeoNames -> offline search index __tests__/ vitest: scoring, store contract, API handlers e2e/ playwright: menu · solo · search · live room .github/workflows/ci.yml test gate + production deploy