Terra Incognita ← Back to the game

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.

Executive summary

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

Tests gate every deploy

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.

Rooms clean themselves up

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.

Locked-down Maps key

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.

Browser — index.html (~350 KB) + /photos static files Google guess map (dark-styled, full labels) · SVG atlas fallback + results map · 50 famous places (static photos, prefetched) · gazetteer Street View panorama · random-deck resolver (free metadata) · solo engine · party polling loop (1.5 s) WebAudio sfx (ticks, stings, fanfare; muteable) · count-up + confetti theatrics · sessionStorage per tab fetch /api/* (JSON) Maps JS API (street mode only) Vercel Functions — api/ create · join · guess · next · state · weekly · leaderboard · admin · config All scoring server-side (haversine + exponential decay) Lazy transitions: any state read can flip question → reveal Host token / player tokens checked on every call room state final scores · top 20 Upstash Redis room:CODE (meta JSON) room:CODE:players (hash) room:CODE:g:N (hash / round) 4 h TTL on everything Neon Postgres leaderboard + weekly_scores one row / player / game written once, on final HTTP driver, no pool mgmt Google Maps Street View panoramas dynamic guess map referrer-locked key

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.

lobby players join by code question timer runs · pins drop reveal all pins + standings final host starts all answered · time up · host ends host: next round (×4) after round 5 — writes leaderboard once

3 · Data model

Redis — ephemeral room state

room:CODEmeta JSON: state, mode, rounds, roundIdx, roundStartAt, roundMs, deck, customDeck (random-world panos), hostToken, savedToLb
room:CODE:playershash playerId → {name, token, score} — per-field writes, no races
room:CODE:g:Nhash 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

idserial PK
room_codetext — 4-letter room
player_nametext
scoreint — out of 25,000
roundsint
played_attimestamptz, 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

  1. POST /api/create with mode, rounds (1–10), and round seconds (clamped 10–300).
  2. 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.
  3. Host screen shows the 4-letter code; players join at the site or via /?join=CODE.

Player guesses

  1. Pin click is bounds-checked against the map, then POST /api/guess sends only lat/lon.
  2. Server validates token, phase, and clock; scores with haversine; HSETNX blocks double guesses.
  3. Points are added to the player's score but hidden from state responses until reveal.

Rounds advance lazily

  1. No timers run server-side; every /api/state poll checks the clock.
  2. question → reveal fires when all players answered or time (+2 s grace) expired.
  3. Concurrent polls racing the transition are benign — both write the same state.

Street View resolution

  1. /api/config supplies the referrer-locked Maps key; the Maps script loads once, on demand.
  2. Each round asks StreetViewService for the nearest outdoor panorama within 1.5 km.
  3. No coverage → the round silently falls back to the embedded photo. Labels, addresses, and dates are hidden.
  4. 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

points = round( 5000 · e−km / 2000 )
DistancePointsFeels like
0 km5,000Perfect pin
125 km4,698Right region
1,000 km3,033Right country, roughly
3,000 km1,116Right continent
10,000 km34Wrong 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.html is the source of truth; build/assemble.js injects data and writes index.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.

Push any branch github.com Actions · test vitest + coverage floor playwright vs vercel dev (11 E2E, real Redis) Actions · deploy main only, needs: test vercel build deploy --prebuilt --prod Production static + functions terra-incognita- amber.vercel.app green

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