mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-11 12:29:01 +00:00
docs/development.md states the rules the code now enforces or keeps: server-only modules and the import-graph test (which now also forbids the daily pick and the region locator from client bundles), route decides best-effort and the library never swallows, typed failure kinds mapped to statuses, claim before write, one module per storage concern through the guarded store, useEffectEvent for imperative callbacks, unprefixed logical keys, three fixed region levels, and the response contract. The parameter rule now says what it always meant: React components destructure props. @radix-ui/react-tabs had no component using it. Stale lines about tabs, pagination, a leaderboard migration and a sub-second suite are corrected.
7.7 KiB
7.7 KiB
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
/gamegenerates a randomPlayer-xxxxxxname 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
regionFromSlugaccepts. 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/%74phcmplays; 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 /gamewith 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_pidcookie, 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 (
pickRandomPanoinsrc/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 tothumb_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
DELbefore 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
- Daily challenge:
/dailyruns one round from/api/dailyin the same client; the result is stored in localStorage and the page replays it on a revisit that day. See Daily Challenge in features.md - 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 the leaderboards (top 200 per board)
- Redis session cleanup ensures fresh start for each round