Files
vngeoguessr/docs/game-flow.md
T
tiennm99 e649259518 feat(game): explain a miss, share a round, and keep the phone header in reach
Below 3 points the result dialog says whether the guess had the right
district or province, located server-side from the boundaries. The
reveal links the spot on OpenStreetMap, and a Share button builds a
squares line with the region's URL. Region pages and the root carry
Open Graph metadata for link previews.

An expired round is worded as expired rather than as a failed save. A
skipped round gets a fresh session id so the unawaited delete cannot
kill the next round. Below sm the theme switch collapses to one cycling
button and the tally hides, so the mute control stays on a 360px
screen. The panorama viewer loads on demand, taking three.js out of
the first load.
2026-09-20 23:02:05 +07:00

7.5 KiB
Raw Blame History

Gameplay Flow

Complete Game Flow

1. Username Setup

  • Check localStorage for existing username
  • When no name is stored, UsernameModal opens on landing to suggest picking one; it also opens at a Play click if a name is somehow still missing, and navigation resumes after the name is settled
  • Skip, dismissing the prompt (Esc / overlay click), or a deep link straight into /game generates a random Player-xxxxxx name and stores it like a typed one
  • The header chip ("Playing as X" / "Set name") reopens the modal to change the name at any time
  • Store username for leaderboard tracking

2. Region Selection

  • Choose anywhere on the region tree:
    • Vietnam — draws from every covered province
    • A province — Ha Noi, Ho Chi Minh, Da Nang, Lam Dong, Long An, Dong Nai, Binh Duong, Thanh Hoa, Quang Nam
    • A district or town — 75 of them, expanded from their province
  • Regions with no usable panoramas are listed but disabled, with the reason shown. See the Coverage note in project-overview.md

The game URL

The region is a lowercase path segment: /game/tphcm, /game/vn, /game/hn-badinh. Codes are uppercase inside the app (they key the region tree); regionSlug / regionFromSlug in src/lib/regions.js own the conversion, and are the only place the URL casing convention lives.

  • An unknown region is a 404. A real region with no imagery is not — it renders and shows the coverage message, which is a better answer than a 404
  • An uppercase URL plays rather than redirecting. There is deliberately no canonical-casing redirect: a redirect on a prerendered route gets cached as that route's response, and nothing in the app generates an uppercase link
  • Re-casing is the only difference regionFromSlug accepts. A spelling that merely uppercases into a code (hn-badınh, dotless i) is a 404: it is a different URL, so it would be its own cache entry and its own analytics row. Percent-encoding is the exception it cannot reach — the router decodes the segment first, so /game/%74phcm plays; seeing the raw form would mean giving up static rendering on all 85 pages
  • Each region page carries its own title and description (Ba Dinh — VNGeoGuessr), so the tab, the history entry and a bookmark name the region
  • /game with no region plays the whole country
  • ?region= and ?location= still work; they redirect to /game/{slug} and are kept for links and bookmarks already in the wild
  • API routes deliberately keep their query params. /api/new-game?region=, /api/leaderboard?region= and the debug routes are unchanged and should stay that way — they are fetch calls, not navigations, so there is nothing for a path segment to buy there

3. Session Creation

  • Server generates unique UUID v4 session ID
  • Server stores the exact target location and the district the panorama sits in in Redis with 30-minute expiry
  • Client receives session ID only — never the coordinates, never the district

4. Location Display Process

  • Identify the browser: an anonymous vng_pid cookie, minted server-side on the first round and refreshed on every one after (src/lib/player-id.js). It exists only so the next step can happen; it is never joined to a username or a score
  • Exclude what this player just saw: their last 50 panorama ids (src/lib/pano-history.js) are passed to the draw as an exclusion set. If they would empty a small region's pool the filter is dropped and a repeat allowed — a repeat is always preferable to "no coverage here"
  • Pick from the index: the server draws a random panorama id from the prebuilt index in Postgres for the selected region (pickRandomPano in src/lib/pano-index.js). A country draw picks a province uniformly first, so the round is not dominated by whichever province has the most panoramas
  • Resolve the image: one Mapillary lookup by id, ~230ms. Up to three candidates are tried in case an image was deleted upstream
  • Credit the district that won: the resolving district comes from the attempt that succeeded, not the first candidate — each retry may sit in a different district
  • Record it as seen: the chosen panorama joins the player's history here, at round creation rather than at guess time, so a round they skip still counts
  • Image Display: thumb_2048_url, falling back to thumb_original_url
  • Security: client never receives the coordinates or the district

5. Guessing Phase

  • Image Interaction: View street-level thumbnail image
  • Map Interaction: Place guess marker on interactive Leaflet map with OpenStreetMap
  • Map Search: optional search for a district, street, or place to pan the map before clicking; results never reveal or place the answer
  • Submission: Click submit button to finalize guess

6. Server-Side Processing

  • Input: Client submits guess coordinates + session ID only
  • Retrieval: Server retrieves exact location from Redis session storage
  • Validation: Server validates coordinate ranges and session existence
  • Calculation: Server calculates distance using Turf.js distance function
  • Scoring: Server-side score calculation with distance-based points
  • Cleanup: the session is claimed with an atomic DEL before any score is written, so a replay or a concurrent submit scores exactly once

7. Scoring System

Distance-based points (0-5 scale), on one ladder for every region — the picked region's size does not move a threshold:

  • 0-50m: 5 points
  • 50m-100m: 4 points
  • 100m-200m: 3 points
  • 200m-500m: 2 points
  • 500m-1km: 1 point
  • beyond: 0 points

See SCORE_BANDS in src/lib/game.js. Leaderboards use the same ladder (submitRoundScore in src/lib/leaderboard.js), so the headline score and the points added at every level are the same number.

8. Results Display

  • Show calculated distance between guess and actual location
  • Display earned points for current round (0-5 scale)
  • Reveal the region path the panorama was in, e.g. Vietnam › Ho Chi Minh › District 7 — this is the first time the client learns the district
  • Show the accumulated total and rank at each of the three levels
  • Show distance leaderboard rankings for the current game at each level
  • Reveal exact target coordinates and compare guess vs actual on the map

9. Leaderboard Management

  • Rollup fan-out: each game updates the score board and the distance board at every level above the panorama — normally district, province and Vietnam, or province and Vietnam when the panorama fell outside every district outline. The levels are written concurrently
  • Score Leaderboards: Accumulated scoring system with single entry per user
  • Distance Leaderboards: Best distance records with multiple entries per user allowed
  • Redis Sorted Sets: Persistent storage using ZADD/ZRANGE operations
  • Top 200 Window: Score boards serve their top 200 but keep every total; distance boards are trimmed to 200
  • Score Accumulation: New scores added to existing totals in score leaderboards
  • Distance Records: Each game creates new timestamped distance record entry
  • Real-time Ranking: Dynamic rank calculation using ZREVRANK/ZRANK for all leaderboard types
  • Persistent Storage: No expiration on leaderboard data

10. Continue or Exit

  • Option to start next round with new session and location in the same region
  • Option to return to the region picker and choose somewhere else
  • Option to view full leaderboard with pagination
  • Redis session cleanup ensures fresh start for each round