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 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

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. 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.

Browser — index.html (~0.9 MB, gzipped in transit) + /photos static files Google guess map (dark-styled, full labels) · SVG atlas fallback + results map · five 50-place famous decks (static photos) · 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 · EN/ES/PT interface · sessionStorage per tab fetch /api/* (JSON) Maps JS API (street mode only) Vercel Functions — api/ create · join · guess · next · state · weekly · solo · 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 (all weeks kept) 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
decktext — which location deck the game played
played_attimestamptz, default now()
detailjsonb — 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

  1. POST /api/create with mode, rounds (1–10), and round seconds (clamped 10–300).
  2. 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.
  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 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 (hashed timingSafeEqual).

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)

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.

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
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