diff --git a/.claude/agent-memory/code-reviewer/MEMORY.md b/.claude/agent-memory/code-reviewer/MEMORY.md index ee05e8c..6dbebd8 100644 --- a/.claude/agent-memory/code-reviewer/MEMORY.md +++ b/.claude/agent-memory/code-reviewer/MEMORY.md @@ -1,3 +1,4 @@ - [Anti-cheat invariant and its known bypass](project-anti-cheat-invariant.md) — pano coords are the answers; the import-walk test does not cover the debug API that leaks them. - [Quality gate blind spots](project-quality-gates-blind-spots.md) — no-undef is now on, but no gate checks React prop/state contracts, e2e stub drift, or whether /docs matches the code. -- [Scoring ladder vs leaderboard boards](project-scoring-ladder-and-boards.md) — no single ladder any more: each board is credited by its own, and the headline score is credited to none. +- [Scoring ladder vs leaderboard boards](project-scoring-ladder-and-boards.md) — one frozen ladder again, and the top-200 trim permanently resets anyone outside the window. +- [Free-tier budgets are the real ceiling](project-free-tier-budgets.md) — ~27 Redis commands per round against 500K/month, and nothing counts them. diff --git a/.claude/agent-memory/code-reviewer/project-free-tier-budgets.md b/.claude/agent-memory/code-reviewer/project-free-tier-budgets.md new file mode 100644 index 0000000..a9adc8b --- /dev/null +++ b/.claude/agent-memory/code-reviewer/project-free-tier-budgets.md @@ -0,0 +1,26 @@ +--- +name: project-free-tier-budgets +description: The per-round cost of a completed game across Upstash, Neon and Mapillary, and the fact that no route is rate limited and nothing counts the spend +metadata: + type: project +--- + +Measured by reading the call graph 2026-09-20: one completed round costs ~27 +Upstash commands — 4 in `/api/new-game` (history GET, session SET, history +GET+SET) and 23 in `/api/guess` (session GET + DEL, then 3 levels x 4 commands +for the score fan-out and 3 x 3 for the distance fan-out). Upstash free is 500K +commands/month, so roughly 18.5K rounds/month, plus one Neon draw query and one +Mapillary lookup each. + +No route is rate limited and there is no `middleware.js`. The three +`/api/debug/*` routes are unauthenticated, and `region-coverage` runs a window +function over a whole province partition (226k rows for HN) per call. + +**Why:** the free tiers, not correctness, are the likeliest cause of a real +outage here, and the quota is spent per Redis COMMAND, not per HTTP request — +so pipelining hides latency but saves nothing. + +**How to apply:** when reviewing anything that adds a Redis call inside the +round path, price it in commands/month, not in milliseconds. `zIncrBy` (absent +from `upstash.js`) and trimming less often are the two cheapest reductions. +Related: [[project-scoring-ladder-and-boards]], [[project-anti-cheat-invariant]]. diff --git a/.claude/agent-memory/code-reviewer/project-scoring-ladder-and-boards.md b/.claude/agent-memory/code-reviewer/project-scoring-ladder-and-boards.md index 552585b..6e1be2c 100644 --- a/.claude/agent-memory/code-reviewer/project-scoring-ladder-and-boards.md +++ b/.claude/agent-memory/code-reviewer/project-scoring-ladder-and-boards.md @@ -1,33 +1,38 @@ --- name: project-scoring-ladder-and-boards -description: Scoring is region-relative and each board is credited by its OWN ladder as of 2026-08-31 - what that did to the country board, and what the headline score now means +description: One frozen scoring ladder for every region (the per-region bbox ladder was reverted), and the top-200 trim that permanently resets any player outside the window metadata: type: project --- -As of 2026-08-31 there is no single scoring ladder. `calculateScore(distance, -bands)` takes one, `bandsForBbox(bbox)` builds it from a region's bbox diagonal -(reference 10 km, factor clamped at >= 1), and there are two different consumers: +**Superseded 2026-09-20.** The per-region bbox ladder recorded here on +2026-08-31 (`calculateScore(distance, bands)`, `bandsForBbox`) no longer exists; +commit `b15d199` "score every region on one distance ladder" reverted it. Verify +before quoting any ladder claim — this area has flipped once already. -- `/api/guess` grades the HEADLINE `gameResult.score` against the region the - player PICKED. This number is credited to no board — it is display only. -- `submitRoundScore` credits each board from the raw distance against THAT - board's own ladder, so a country round can no longer buy district points. - Each level's award comes back as `levels[].points`. +Current state, read from source 2026-09-20: -**Why:** without scaling, a country round was a guaranteed string of zeros. The -first fix (picked-region ladder + flat fan-out) let a country round credit -district boards at country precision; the per-level fan-out closed it. +- `SCORE_BANDS` in `src/lib/game.js` is a frozen 50/100/200/500/1000 m -> + 5/4/3/2/1 ladder. `calculateScore(distance)` takes distance only. +- `/api/guess` grades the headline `gameResult.score` with it, and + `submitRoundScore` credits district, province and country with the SAME + points from the same raw distance. Every level of one round records an + identical number. -**How to apply:** measured ladders — country 5 pts <= 6,380 m, TPHCM 442 m, -TPHCM-Q7 62 m, HN-BADINH 50 m. `SCORE_BANDS` is NOT "the district ladder", and -calling it that in UI copy or docs is wrong: only 20 of 58 playable districts sit -at factor 1.00; median is 1.51 (TPHCM-BINHTAN, 15.1 km) and max 4.73 -(TPHCM-CANGIO, 47.3 km). It is the ladder for any region up to a 10 km diagonal. -Consequence the user has not explicitly signed -off: the Vietnam board now pays +5 for any guess within 6.4 km, so it measures -rounds played rather than accuracy, and mixes with points banked under the old -50 m ladder. Anything that bands, colours, or labels a score must say which -ladder it means; the boards store no record of which one applied. Related: -[[project-anti-cheat-invariant]] (`bands` leaks nothing — the picked region is -already client-side). +**The trim is a ratchet, and it is the finding worth remembering.** +`creditScore` (`src/lib/leaderboard.js:130-154`) does `zScore` -> `zAdd(existing ++ points)` -> `zRemRangeByRank(key, 0, -(201))`. A player whose new total lands +outside the top 200 is deleted from the sorted set, so their next round reads a +null score and starts from zero. Their ceiling is one round's points, max 5. +Once 200th place holds more than 5 points the board is closed to new players +permanently. The `trimmed` flag only hides the number in the UI; it does not +preserve the total anywhere. + +**Why it matters:** this is a growth/retention defect disguised as a storage +optimisation, and no test covers it (the leaderboard suite never exceeds 200 +members). + +**How to apply:** when anything touches leaderboard writes, ask where a +non-top-200 player's total lives. Also note the read-modify-write in the same +function is not atomic — concurrent rounds under one username lose an update; +`upstash.js` has no `zIncrBy` yet. Related: [[project-anti-cheat-invariant]]. diff --git a/plans/reports/brainstorm-260920-2201-gameplay-and-retention.md b/plans/reports/brainstorm-260920-2201-gameplay-and-retention.md new file mode 100644 index 0000000..89c885a --- /dev/null +++ b/plans/reports/brainstorm-260920-2201-gameplay-and-retention.md @@ -0,0 +1,119 @@ +# Brainstorm: gameplay, product, retention + +Date: 2026-09-20 · Scope: advisory only, no code changed. + +## 1. Current state + +- Endless single rounds: pick region → one panorama → guess → dialog → Next Round. No run, no end state, nothing to finish. +- One absolute 0-5 ladder (max 1 km). A country round scores 0 unless the player pins the street; province rounds nearly as harsh. +- Leaderboards are all-time cumulative, top 200, username = editable localStorage string; distance board allows many entries per user. +- Retention surface today: "Continue in X" (last region), audio, recent-pano filter, per-visit tally that a reload erases. No streak, no daily, no share, no profile. +- Constraints that shape everything: no accounts, anti-cheat keeps coords/district server-side, Upstash free tier (~25 Redis commands per guess already), Vercel Hobby (no custom analytics events), Mapillary 50k tiles/day at build time only. + +**Challenge to the premise before the ideas:** retention work assumes there is traffic to retain. `/game/{region}` page rows in Vercel Analytics are the only traffic signal you have — if DAU is single digits, distribution (Vietnamese social posts, r/VietNam, a landing OG image) beats every mechanic below. Check that first; ideas 2/3 are the ones that also *create* distribution. + +## 2. Ideas + +### 1. Run structure: 5 rounds, one score out of 25, summary screen +**What:** rounds 1..5 in the same region, running total in the header, end-of-run summary (5 mini-maps, total, best round, "Play again" / "New region"). +**Why:** there is currently no unit of play, so there is nothing to complete, nothing to share, nothing to beat. Every other idea here (daily, share, challenge, weekly board) needs a "run" to exist first. +**Effort:** M. **Risk:** client-tracked totals are forgeable — keep runs display-only, keep per-round sessions and the existing fan-out unchanged so anti-cheat and boards are untouched. Second-order: dropout mid-run becomes a metric you cannot see on Hobby analytics. +**Touches:** `GameClient.js` (run state, replaces `sessionRounds`/`sessionPoints`), `RoundResultDialog.js` (round N of 5 + "Next"), new `RunSummary` component. No server change. + +### 2. Daily Challenge: same 5 panoramas for everyone, one attempt, streak +**What:** `/daily` — deterministic set of 5 panos per calendar day (materialise once into Redis `daily:YYYY-MM-DD`, 48h TTL), its own board keyed by day, streak counter. +**Why:** the strongest known return-visit loop in this genre (Wordle shape): a reason to open the site tomorrow that does not depend on beating a grinder's all-time total, and a fair comparison because everyone saw the same places. +**Effort:** M-L. **Risk:** one-attempt enforcement rests on the `vng_pid` cookie — trivially bypassed; accept it as casual, do not build anti-cheat for it. Caching resolved Mapillary thumb URLs in the daily key may break (signed URLs expire) — fall back to the normal per-round lookup, ~230 ms, cheap. Cost: +2-3 Redis commands/round. +**Touches:** new `src/app/api/daily/route.js`, `pano-index.js` (seeded pick), `session.js` (mark run kind), `leaderboard.js` (dated key + TTL), home page card, `GameClient.js`. + +### 3. Shareable result (emoji squares + Web Share API) +**What:** on the run summary, a copy/share button producing e.g. `VNGeoGuessr Daily #123 — 18/25 🟩🟩🟨⬜🟩 vngeoguessr…`. +**Why:** the only viral loop available at zero infra cost. Also the cheapest acquisition lever in this whole list. +**Effort:** S. **Risk:** must never include coordinates, pano id, or district names — squares and totals only. Second-order: shared links land on `/` cold; pair with a per-day OG image later, not now (OG generation costs Vercel invocations). +**Touches:** new `RunSummary`, small `src/lib/share.js`. + +### 4. Partial credit beyond 1 km (decision, not a patch) +**What:** the headline score currently goes to 0 past 1 km, so most country and many province rounds feel identical to a random click on the map. +**Your prior decision:** one absolute ladder for every region, deliberate and documented (`SCORE_BANDS`, features.md) so a point means the same on every board. +**The concern:** the ladder is a *board* rule, but it is also the only feedback the player gets. A 2 km guess and a 400 km guess both read "Missed / 0". No gradient = no sense of improvement = the classic reason a geo game is dropped in three rounds. +**Trade-off / options:** + a. Keep boards exactly as they are; add a display-only closeness readout below the score (percentile of the region's diagonal, or extra labels "2 km — very close for a country round"). Effort S, zero board impact, zero migration. **Simplest viable.** + b. Headline score becomes a continuous 0-1000 decay (GeoGuessr-style), boards keep the 0-5 ladder. Effort M; risk: two currencies on screen, needs careful UI. + c. Replace the ladder with a continuous curve on the boards too. Effort M + migration; reverses the decision and mixes old and new scores on one board — the exact asymmetry you removed once already. +**Recommendation:** (a) now, revisit (b) only if drop-off data says the gradient is still missing. Your call, not mine. +**Touches:** `game.js`, `RoundResultDialog.js`, home page scoring table, `docs/features.md`. + +### 5. Weekly board alongside all-time +**What:** `leaderboard:week:{iso-week}` with a ~9-day TTL, shown as a tab beside the all-time board. +**Why:** an all-time cumulative board is unwinnable for anyone who arrives late — it is a wall, not a goal. A weekly reset makes rank renewable and gives a calendar hook. +**Effort:** M. **Risk/cost:** do **not** fan weekly out over three levels. A guess already costs ~25 Upstash commands (12 score + 9 distance + session/history); a 3-level weekly fan-out adds ~12 (+50%) against a free tier measured in hundreds of thousands of commands a month. Country level only: +4. +**Touches:** `leaderboard.js` (key builder, TTL, a second fan-out), `/api/leaderboard`, `LeaderboardModal.js`. + +### 6. Distance board: one best entry per user +**What:** today `creditDistance` writes member `username:distance:timestamp`, so a grinder can occupy many of the 200 slots. +**Why:** a board showing one name ten times is not a board; it also suppresses every new player out of visible ranks. +**Effort:** S-M. **Risk:** member encoding changes → must use a new key namespace or the old records become unreadable; the existing `distance:city:` keys should be left in place (same reasoning as the kept prefix). +**Touches:** `leaderboard.js` (`creditDistance`, `getLeaderboard` distance parsing). + +### 7. Rank feedback for players outside the top 200 +**What:** "Below top 200" is a dead end. Replace with personal best + progress toward the cut ("you need 40 more points to enter Ha Noi's top 200"), which needs only the board's 200th score. +**Why:** the current message tells a new player they are invisible, at exactly the moment the game is asking them to keep going. +**Effort:** S-M. **Risk:** one extra ZRANGE per level per guess unless the cut score is cached (cache it, 60s, in Redis or memory). +**Touches:** `leaderboard.js` (`creditScore`, the `trimmed` branch), `RoundResultDialog.js`. + +### 8. "Passport": districts seen, districts mastered +**What:** per-player record of which of the 75 districts they have been shown and the best score in each; a Vietnam map on the home page filling in as they go. +**Why:** long-horizon completionism is the retention mechanic that fits a *national geography* game better than any leaderboard, and it is the only one that makes an unfashionable district worth picking. +**Effort:** M (localStorage version S). **Risk:** localStorage = lost on clear, zero cost, no privacy surface; Redis-by-`vng_pid` = survives but the cookie is deliberately never joined to identity, and a "profile" keyed on it starts eroding that line. Start localStorage. +**Touches:** new `src/lib/passport.js`, home page, `GameClient.js` (record the revealed district post-guess), `regions.js` for the district list. + +### 9. Curated "Landmarks" pool +**What:** an offline pass flagging panoramas near named OSM POIs / in dense urban cores; expose as a pool ("Landmarks" vs "Anywhere"). +**Why:** a random Mapillary frame in Vietnam is often an anonymous stretch of highway from a dashcam — unguessable and forgettable. Content quality outranks every mechanic here for first-impression retention, and no scoring tweak fixes a boring picture. +**Effort:** L (offline script + a column + a pool filter). **Risk:** build-time Mapillary/Overpass budget; the 50k/day tile cap applies to index rebuilds. Zero runtime cost, which is the appeal. +**Touches:** `scripts/`, pano DB schema, `pano-index.js` (`pickRandomPano` filter), `RegionPicker`. + +### 10. Hints that cost points +**What:** optional in-round hints — reveal the province, reveal a compass direction, narrow to a 10 km circle — each deducting from the round's points. +**Why:** turns a hopeless country round into a decision instead of a shrug; reduces 0-score frustration without touching the ladder. +**Effort:** M. **Risk:** hint state and the deduction must live in the Redis session or hints are free information; that is a real change to the session contract and to `/api/guess` scoring. Do not ship alongside idea 4 in the same release — you will not know which one moved the numbers. +**Touches:** `session.js`, new `/api/hint`, `guess/route.js`, `GameClient.js`. + +### 11. Challenge a friend (async duel) +**What:** a link encoding a fixed 5-pano set; both players play the same places, results compared on a shared page. +**Why:** highest-intent sharing there is — a friend invite converts far better than a public score post. +**Effort:** L. **Risk:** needs a challenge store, a result store, and a results page; identity is still a localStorage name, which is fine among friends but not ranked. Same machinery as the daily (idea 2) — build the daily first and this becomes M. +**Touches:** new challenge lib + routes + page, `GameClient.js`. + +### 12. Vietnamese UI (vi default, en toggle) +**What:** ~40-60 strings in one dictionary module, a tiny `t()` hook, language stored in localStorage, `lang` on the html element. +**Why:** the game is about Vietnamese streets, the audience is Vietnamese, the UI is English. This caps both reach and shareability on Vietnamese social — where the sharing from idea 3 would actually happen. +**Effort:** M, no library (a plain JS dict fits the JS-only rule; `next-intl` would be over-engineering at this size). **Risk:** string drift between the two locales; metadata/title per region page needs a locale too, and static rendering of 85 region pages must not become dynamic. +**Touches:** new `src/lib/i18n.js` + dict, every component with copy, `layout.js`, region page metadata. + +### 13. Post-round "explore this spot" +**What:** after the guess, link the revealed coordinates to Google Maps / OSM (and optionally the Mapillary image page — safe *only* after the guess, and the answer is already in the response). +**Why:** curiosity payoff; "where the hell was that?" is the moment people actually learn something, and learning is what makes a geo game sticky. Costs nothing. +**Effort:** S. **Risk:** must be strictly post-guess and never in the share text. +**Touches:** `RoundResultDialog.js`, and `guess/route.js` only if you want the pano id (coords alone are enough). + +## 3. Top 3 + +**1 — Run structure + shareable summary (ideas 1 + 3, ship together).** M effort, no server change, no cost. It creates the unit of play everything else needs and simultaneously gives you the only free acquisition channel. Without it, "come back" has nothing to come back *to*: a session of endless identical rounds ends when attention ends, never at a satisfying stopping point. + +**2 — Daily Challenge + streak (idea 2).** The return-visit engine. Fair by construction (same 5 panos for all), self-marketing through idea 3's share string, and cheap: one materialised Redis key per day plus a dated board with a TTL. Depends on #1, which is why it is second, not first. + +**3 — Fix the feedback gradient (idea 4a) plus rank feedback (idea 7).** Both are S/M, both attack the same wound: the game tells a mediocre player nothing except "0" and "below top 200". Option (a) keeps your one-ladder decision intact and adds only display, so it is reversible in a single commit — take that before considering any board-level scoring change. + +**Simplest viable option overall:** idea 13 + idea 3's share text + idea 4a. All three are S, none touches the server, none costs a Redis command, and together they change what a round *feels* like. If you want one afternoon of work with the best ratio, that is it. + +**Explicitly not in the top 3, and why:** curated landmarks (9) is probably the highest *ceiling* — content beats mechanics — but it is L, offline, and slow to validate. Weekly boards (5) are good but cost Redis commands on a free tier. Vietnamese UI (12) matters for reach, not retention, and belongs with a distribution push, not before one. + +## 4. Unresolved questions + +1. **Is there traffic?** What do the `/game/{region}` page rows actually show — visitors/day, and repeat rate? If it is <20/day, do idea 3 + a distribution push before any retention mechanic. +2. **Identity.** Any competitive feature (weekly, daily board, duels) is built on an editable localStorage name. Accept casual/forgeable boards forever, or introduce a lightweight account (OAuth, passkey) at some point? This decision gates ideas 2, 5, 11 and should be made before, not after. +3. **Upstash headroom.** What is the actual monthly command count today? ~25 commands per guess is the hard budget constraint on ideas 2, 5, 7 and 8, and nothing here is sized without that number. +4. **Do Mapillary thumb URLs expire?** Determines whether a daily set can be cached with its image URLs or must re-resolve per player (affects idea 2's latency, not its feasibility). +5. **Who is the player?** Vietnamese locals, diaspora, or foreign geo-game players? Locals → idea 12 and landmark curation; foreigners → the country mode's harshness (idea 4) matters much more. +6. **How harsh is country mode really?** What share of submitted guesses score 0? That single number decides whether idea 4 is cosmetic or urgent, and it is currently unmeasurable — a minimal server-side counter (one Redis INCR per score band) may be worth the commands. diff --git a/plans/reports/code-review-260920-2201-codebase-health.md b/plans/reports/code-review-260920-2201-codebase-health.md new file mode 100644 index 0000000..3caed56 --- /dev/null +++ b/plans/reports/code-review-260920-2201-codebase-health.md @@ -0,0 +1,101 @@ +# Codebase Health Scan — VNGeoGuessr + +Date: 2026-09-20 · Scope: whole repo (src/lib, src/app/api, scripts, tests, config) · Mode: brainstorm input, not PR review + +## 1. Health summary + +- Gates green: `npm test` 20 files / 292 tests passed (13s); `npx eslint .` 0 errors, 20 warnings (13 `react-hooks/set-state-in-effect`, 7 `react-hooks/refs` — all the established localStorage-in-effect / callback-ref patterns); `npm run build:check` compiled, 101 static pages, 5 dynamic API routes. +- Note: a local `npm install` was needed first — the checked-out `node_modules` was still on next@15.5.18 while `package.json`/lockfile want 16.3.3. Lockfile is committed and unchanged by the install. +- Server core (session claim-by-DEL, region-from-session scoring, pano-history boundary) is genuinely careful and well commented; the weak spots are at the edges: no rate limiting anywhere, no server-side username validation, three unauthenticated debug routes that spend Neon compute and the Mapillary token. +- Biggest structural risk is not a bug: the score leaderboard silently resets any player outside the top 200, so once 200th place exceeds 5 points the board is closed to new players forever. +- No CI (`.github/` absent), no backup path for the only durable player data (Upstash), ~27 Redis commands per completed round against a 500K/month free plan. + +## 2. Findings + +### High + +**H1 — Score board is a closed ratchet: players outside the top 200 can never accumulate** +`src/lib/leaderboard.js:130-154`. `creditScore` reads the member's current score, writes `existing + points`, then `zRemRangeByRank(key, 0, -(201))` trims everything below rank 200. A player whose new total lands below the 200th place is deleted from the set, so the *next* round reads `zScore → null` and starts from 0 again. Their ceiling is one round's points (max 5) forever. Once the 200th entry holds more than 5 points, no new player can ever enter the national board; the `trimmed` flag only tells the UI to hide the number, it does not preserve the total. +Fix: keep totals in a separate hash/key (or a second unbounded zset) and use the trimmed zset purely as a display window; or drop the trim and rely on `zrange 0..limit` for reads (200 members × ~3 boards per region is small). Effort: M. + +**H2 — No rate limiting on any route** +`src/app/api/new-game/route.js`, `guess/route.js`, `skip/route.js`, `leaderboard/route.js`, all three `debug/*` routes; no `middleware.js` exists, and grep finds no ratelimit dependency. One scripted loop costs a Mapillary lookup, a Neon query and ~27 Redis commands per iteration, all billed to free tiers, and can flood any board with submissions. Upstash free is 500K commands/month → ~18.5K rounds/month before the quota is the outage. +Fix: `@upstash/ratelimit` sliding window keyed on the existing `vng_pid` cookie plus IP, applied in a `middleware.js` over `/api/*`, with a tighter bucket for `/api/debug/*`. Effort: M. + +**H3 — `/api/guess` accepts non-finite coordinates and consumes the session before it can fail** +`src/app/api/guess/route.js:41-53` validates only `Math.abs(x) > 90/180`; `Math.abs(NaN) > 90` is false, so `guessLat: "abc"` passes. The session is then deleted at line 79, `calculateDistance` returns `NaN`, and `submitRoundScore` rejects it at `src/lib/leaderboard.js:246` → 500. The player loses the round with no score and no way to retry. +Fix: add `if (!Number.isFinite(numGuessLat) || !Number.isFinite(numGuessLng)) return 400` (and the same for the target read from the session) before the `deleteGameSession` call. Effort: S. + +**H4 — Username is validated only in the browser** +Only `src/app/components/UsernameModal.js:54` enforces `2-20` chars of `[a-zA-Z0-9_-]`. `src/app/api/guess/route.js:13` checks presence and nothing else, then `username.trim()` becomes a Redis sorted-set member (`src/lib/leaderboard.js:135`). Consequences: arbitrary-length members inflate a 256 MB free-tier store; a non-string (`username: 123`) throws inside `.trim()` → 500; a `:` in the name corrupts the distance board, whose entries are packed as `username:distance:timestamp` (`leaderboard.js:313`) and split back on every `:` at `leaderboard.js:99` (`distance` becomes `NaN` for the reader). +Fix: one exported validator in `src/lib/username.js` (`isValidUsername`) called by both the modal and the route; reject with 400 on failure. Effort: S. + +### Medium + +**M1 — Lost update on concurrent scoring for one name** +`src/lib/leaderboard.js:132-135` is a read-modify-write (`zScore` then absolute `zAdd`). Two rounds finishing at the same instant under one username (two tabs, or a shared name) lose one of the increments. Redis has an atomic primitive for exactly this. +Fix: add `zIncrBy` to `src/lib/upstash.js` and use its return value as the new total; drops a command per level as a bonus. Effort: S. + +**M2 — Unauthenticated debug routes spend metered resources** +`src/app/api/debug/region-coverage/route.js:36-39` accepts `limit` up to 40,000 and runs `row_number() OVER (ORDER BY lat, id)` over a province partition (225,966 rows for HN) on Neon compute, per request, with no auth and no `NODE_ENV` gate; the bbox filter at `src/lib/pano-index.js:182` has no supporting lat/lng index, so it is a partition scan. `src/app/api/debug/mapillary/route.js` is an open proxy that burns the project's Mapillary token on any caller-supplied bbox, with no `AbortSignal` timeout (unlike `src/lib/mapillary.js:267`), and `src/app/api/debug/pano/route.js:89` turns any image id into coordinates. The coordinate exposure is a decision already accepted; the *cost* dimension is the part worth revisiting. +Fix: gate `/api/debug/*` behind a shared-secret header or `process.env.VERCEL_ENV !== 'production'` in one `middleware.js`; add a lat/lng index if the coverage page stays public. Effort: S. + +**M3 — `/api/debug/mapillary` is dead weight contradicting a documented decision** +`src/lib/mapillary.js:228-234` records, with measurements, that `/images?bbox=` fails in every dense district and must not be reintroduced. `src/app/api/debug/mapillary/route.js` is that exact call, still shipped, still holding the only route that returns `error.message` to the client unconditionally (line 103) while every other route gates details on `NODE_ENV`. +Fix: delete the route and its `/debug/bbox` page, or move it behind M2's gate. Effort: S. + +**M4 — Client-supplied session id becomes a Redis key with no validation** +`src/app/api/new-game/route.js:55,101`: whatever `?sessionId=` contains is used verbatim as `session:`. `src/lib/player-id.js:462` validates the *cookie* id against a strict UUID pattern and documents precisely why ("a hand-crafted cookie must not smuggle a glob, a colon or an unbounded string into the keyspace") — the session id, which reaches the same keyspace from a plainer channel, gets none of that. +Fix: apply the same UUID test; mint a fresh id when it fails. Effort: S. + +**M5 — Per-round Redis command budget is ~27** +new-game: history GET, session SET, history GET+SET = 4. guess: session GET, DEL, `submitRoundScore` 3 levels × (ZSCORE, ZADD, ZREMRANGEBYRANK, ZREVRANK) = 12, `submitDistanceRecord` 3 × (ZADD, ZREMRANGEBYRANK, ZRANK) = 9 → 23. Against 500K commands/month that is ~18.5K rounds/month, and the two fan-outs are awaited sequentially at `src/app/api/guess/route.js:89-90`. +Fix: `zIncrBy` (M1) removes 3; trim probabilistically (1-in-N writes) instead of on every credit, removing up to 6; run the two fan-outs in one `Promise.all`; batch a level's calls through the SDK pipeline to cut HTTP round trips. Effort: M. + +**M6 — No backup or export for leaderboard data** +The migration/export scripts were deleted in `7212b67`; `.gitignore` still carries the `leaderboard-backup-*.json` rule for tooling that no longer exists. Upstash free has no scheduled backups, so an accidental flush or a lapsed database loses every board permanently. +Fix: a `scripts/export-leaderboards.mjs` using the existing `scanKeys` adapter, run manually or from a scheduled GitHub Action. Effort: S. + +**M7 — `three.js` ships in the game route's first load** +`src/app/components/GameClient.js:6` statically imports `PanoramaViewer`, which statically imports `@photo-sphere-viewer/core` (`PanoramaViewer.js:5`). The build produces a single 624 KB chunk containing three.js (`WebGLRenderer`, 55 `THREE` references) — the largest client chunk by 3×. Leaflet is already handled correctly via `dynamic()` in `GuessMapPanel.js:8`. +Verified non-issue while here: `@turf/turf` is imported namespace-wide by `src/lib/game.js`, which client components import, but it tree-shakes out — no turf markers in any client chunk. +Fix: `dynamic(() => import('./PanoramaViewer'), { ssr: false })` so the shell and the guess map paint before the viewer downloads. Effort: S. + +**M8 — Random draw is an `OFFSET` scan** +`src/lib/pano-index.js:123-143` picks with `ORDER BY id OFFSET random(0..total) LIMIT 1`. The `(province, id)` index (`scripts/lib/pano-schema.mjs:28`) makes it index-only, but it still walks ~113K entries on average for Ha Noi, per draw, up to 8 draws, on metered compute. +Fix: keyset draw — `WHERE province = $1 AND id > $2 ORDER BY id LIMIT 1` with a random id and wraparound — turns it into a log-n seek. Effort: M. + +### Low + +- **L1** `src/app/api/guess/route.js:93-102` logs username plus exact target and guess coordinates for every submission. Gameplay-identifiable data in Vercel logs, and log volume scales with play. Consider logging distance and region only. Effort: S. +- **L2** `src/app/api/debug/region-coverage/route.js:54` has no `try/catch`; a Neon failure becomes an unhandled rejection rather than the structured `{success:false}` every other route returns. Effort: S. +- **L3** `src/app/api/guess/route.js:31` dereferences `session.exactLocation` unguarded; a malformed session row is a 500 instead of a 400. Effort: S. +- **L4** `src/lib/mapillary.js:264` and `debug/mapillary/route.js:46` put the access token in the query string, where it lands in any intermediary's request logs. Mapillary accepts `Authorization: OAuth `. Effort: S. +- **L5** `src/lib/pano-index.js:46` counts the country by awaiting each province in a sequential loop; it is cached per process, but a cold start pays 5+ serial round trips. `Promise.all` is a one-line change. Effort: S. +- **L6** Dependency drift is small (`next` 16.3.3→16.3.5, `react` 19.2.8→19.3.0, `lucide-react` 1.37→1.47, `@playwright/test`, `@upstash/redis`, `globals`, `tailwind-merge` all one patch/minor behind). Majors available and intentionally held by `^`: `@vercel/analytics` 2.0.1, `@vercel/speed-insights` 2.0.0, `eslint` 10.11.0, `vitest` 5.0.1. Effort: S. +- **L7** `/api/skip` (`skip/route.js`) and `/api/leaderboard` have no route-level tests; `debug/pano` and `debug/mapillary` have none at all. The four tested routes are new-game, guess, region-coverage. Effort: S. + +## 3. Improvement ideas beyond bug fixes + +1. **CI that runs the gates.** There is no `.github/` at all, so `lint`, `test` and `build:check` only ever run when someone remembers. A single workflow on push/PR (node 24, `npm ci`, the three commands) is the cheapest durable quality win in the repo, and it is the natural home for a scheduled leaderboard export (M6). +2. **One `middleware.js` as the API edge.** Rate limiting (H2), debug gating (M2) and a `Cache-Control` policy for `/api/leaderboard` all want the same seam, and none of them exist today. Building it once is less work than three route-local versions. +3. **Observability with a budget lens.** The free-tier ceilings (Upstash commands, Neon compute-hours, Mapillary quota) are the real availability risk, and nothing counts them. A tiny counter — Redis commands per round, Mapillary failures, Neon draw latency — logged once per round in a parseable line would turn "the game broke" into "we crossed a quota on the 14th". +4. **Make the leaderboard model explicit.** H1 forces the question the code never answers: is the board cumulative career points, best-of-N, or a rolling season? A weekly/monthly reset key (`leaderboard:vietnam:2026-W38`) solves the closed-ratchet problem, gives returning players something to climb, and bounds key growth by design instead of by trimming. +5. **Anti-cheat proportional to the threat.** Today the honest defence is "the pano id is not in the response" while `/api/debug/pano` resolves any id to coordinates. Either close the debug surface (M2) or accept it and stop paying for the secrecy elsewhere — the middle state costs complexity without buying the property. +6. **Data pipeline freshness.** The index is a point-in-time snapshot (`pano_provinces.generated_at`); deleted Mapillary images surface only as round retries (`src/lib/mapillary.js:347`). A scheduled job that samples N random rows per province, counts 404s and reports a staleness ratio would tell you when to reseed instead of guessing. +7. **Component-level test coverage.** All 292 tests are lib/API level; `GameClient.js` is 609 lines of epoch/ref race handling verified only by Playwright stubs. A handful of React Testing Library tests over `applyRound`/epoch supersession would cover the part most likely to regress. +8. **Split `GameClient`.** At 609 lines it owns round loading, prefetch, audio, submission, the result dialog and navigation. The seams already exist in the refs (`roundEpochRef`, `appliedEpochRef`); extracting a `useRound()` hook would make the concurrency logic testable in isolation. + +## 4. Top 3 recommendations + +1. **Fix the leaderboard ratchet (H1) and decide the board's model.** It is the only finding that changes what players experience every day, and it silently caps the game's growth: with a full top 200, every new player's score is deleted after each round. +2. **Add `middleware.js` with rate limiting and a debug gate (H2 + M2).** Unmetered routes over three free tiers is the most likely cause of a real outage, and it is one file. +3. **Stand up CI (idea 1) and close the input-validation gaps it cannot see (H3, H4, M4).** The gates are green and the repo has no way to keep them that way; the three validation fixes are an hour's work and remove two 500-class failures and a Redis-key trust gap. + +## 5. Unresolved questions + +- Does the Mapillary `thumb_2048_url` served to the client embed the image id? If it does, `/api/debug/pano` turns the live round's image URL into the answer coordinates in one request, and the "pano id stays server-side" comment in `new-game/route.js:127` buys nothing. One round of manual inspection settles it. +- Is the top-200 trim intended as a display window or as real data retention? The fix for H1 depends on the answer. +- Are the three `/api/debug/*` routes meant to stay publicly reachable in production, or was that only ever true of the coverage page? +- What is the actual play volume? The 500K/month Upstash budget is ~18.5K rounds; whether M5 is urgent or theoretical depends on a number only the maintainer can see. +- Is `/api/skip` ever called by anything other than the client's unload path? It deletes any session id with no ownership check, which is fine for UUIDs but not if the id space ever changes (M4). diff --git a/plans/reports/research-260920-2201-geo-game-landscape.md b/plans/reports/research-260920-2201-geo-game-landscape.md new file mode 100644 index 0000000..f0c48c7 --- /dev/null +++ b/plans/reports/research-260920-2201-geo-game-landscape.md @@ -0,0 +1,259 @@ +# Geo-Guessing Game Landscape — Research for VNGeoGuessr Brainstorm + +Scope: what GeoGuessr/clones offer that VNGeoGuessr lacks, filtered to zero-cost +feasibility on the current stack (Next.js, Vercel-style hosting, Redis, Mapillary, +no accounts). + +## 1. Feature Matrix + +| Feature | GeoGuessr | WorldGuessr (OSS) | GeoHub (OSS) | VNGeoGuessr today | +|---|---|---|---|---| +| Single-round click-to-guess | Yes | Yes | Yes | Yes | +| Movable panorama (walk/pan sequences) | Yes | Yes (Google embed) | Yes (Google) | **No** — fixed single panorama, no Mapillary sequence traversal | +| Distance-based scoring | Yes (5000-pt curve) | Yes | Yes | Yes (0-5 ladder, absolute distance) | +| Daily Challenge (same seed for all players) | Yes, global leaderboard [Wikipedia][gg-wiki] | No | **Yes** [geohub-repo] | No | +| Streak counter (daily return) | Yes ("Daily Streak") [geoguessr-changelog] | Country-streak only (in-round, not daily) | Country-streak only | No | +| Duels (1v1 real-time, HP-based) | Yes, core competitive mode [shapes-modes] | No | No | No | +| Party/multiplayer lobby | Yes | Yes, real-time | Challenge-link async only | No | +| Shareable challenge link ("play same rounds") | Yes | No (README silent) | **Yes** — "create a challenge link...share with friends" [geohub-repo] | No | +| Shareable result card (Wordle-style text) | No native (screenshots shared informally) | No | No | No | +| Custom user-made maps | Yes | No (README silent) | Yes | No (region tree is fixed/authored) | +| User accounts / profiles | Yes | Optional | Yes (guest login too) | **No** (by design — cookie-only) | +| Leaderboards | Yes | Unclear | Yes | Yes (rollup: district/province/country) | +| Imagery source | Google Street View | Google Street View Embed API | Google Street View | Mapillary (CC BY-SA) | +| Hosting/API cost model | Paid product | Free (Google Embed API has no key cost) [worldguessr-repo] | Donations + user's own Google key ($200/mo free credit) [geohub-repo] | Free tier (Vercel + Neon + Redis + Mapillary) | + +Notes: WorldGuessr and GeoHub both lean on Google's **Street View Embed API** +(not the paid tile/SDK API) to stay free — a different cost trick than +Mapillary, not directly transferable. Neither is Mapillary-based; the closest +Mapillary-based prior art is MapillaryGeoGuessr and MapiGuesser, both small +single-purpose demos with no daily/duel/multiplayer features documented +[github-mapillarygeoguessr][mapiguesser]. + +## 2. Findings by Research Question + +### Q1 — Game modes and their backend needs + +- **Daily Challenge**: same N locations for every player in a UTC day, one + global leaderboard [gg-wiki]. GeoHub implements this as an actual mode + [geohub-repo]. **Needs no new backend** beyond what VNGeoGuessr already has: + derive a deterministic seed from `UTC date + region`, use it to pick N + panorama ids reproducibly from the existing Postgres index, and write scores + to a Redis sorted set keyed by date (`leaderboard:daily::`), + same pattern as current rollup boards. +- **Streak (daily return) counter**: GeoGuessr's mechanic is "play something + today to keep the streak" [geoguessr-changelog]. Client-only or + cookie+Redis (`vng_pid` → last-played-date, streak count) — trivial, no new + infra. +- **Country/region streak (in one sitting, lose on first miss)**: pure + client+existing session flow; no new infra. +- **Duels (1v1 real-time HP battle)**: core GeoGuessr competitive mode + [shapes-modes]. Requires low-latency bidirectional state (matchmaking, + simultaneous-round sync) — effectively WebSockets or a polling loop against + Redis pub/sub. Serverless HTTP functions (Vercel-style) do not hold + persistent connections, so this needs either a small persistent Node process + (defeats "free-tier serverless" hosting) or a third-party realtime free tier + (e.g. Pusher/Ably free plan, rate-capped). **Not zero-effort; feasible only + as a stretch item.** +- **Party/multiplayer lobbies**: same realtime constraint as Duels. WorldGuessr + implements this because it runs a persistent Node server, not + serverless [worldguessr-repo] — a hosting-model difference from VNGeoGuessr. +- **Shareable challenge link** ("play the exact rounds I played"): GeoGuessr + and GeoHub both offer it [gg-wiki][geohub-repo]. Async, no realtime — just + persist the round's panorama-id list under a short code in Redis (TTL is + fine) and let a link `?challenge=` replay it. **Cheap, feasible now.** +- **Shareable result card** (Wordle-style emoji/text grid): not a feature of + GeoGuessr itself but is the single highest-leverage viral mechanic in this + genre (see Q3). No backend need — pure client-side string generation from + the result already in hand. +- **Moving/walkable panorama**: GeoGuessr, WorldGuessr, and GeoHub all allow + moving between connected Street View panoramas [worldguessr-repo] + [geohub-repo]. VNGeoGuessr currently shows one fixed Mapillary image with no + sequence traversal (confirmed: no sequence/adjacency code in + `src/lib/mapillary.js` or `PanoramaViewer.js`). Mapillary's API does expose + sequence membership and neighboring image ids, so "move" is technically + buildable, but each move is an extra Mapillary API call per player action — + directly multiplies API usage against the 50k/day tile cap noted in + `docs/project-overview.md`. Feasibility is capped by that budget, not code + effort. + +### Q2 — Imagery cost/licensing across clones + +- **Google Street View Embed API** (WorldGuessr, GeoHub): free without a + billing-enabled key for the embed/photosphere viewer, which is why both + clones can be "zero cost" on imagery — but this is a Google product + decision, not something Mapillary offers, and pulling it into VNGeoGuessr + would mean adding a second imagery source and licensing regime, contradicting + the project's current single-source Mapillary + CC BY-SA design + (`docs/features.md` attribution section). Not recommended as a copy-paste + idea; noted only as "why they're free." +- **Mapillary Terms of Use** [mapillary-terms]: requires visible Mapillary + logo + link back to the Mapillary **homepage** (not per-image) when + displaying data derived from their API/vector tiles — VNGeoGuessr already + does this correctly per `docs/features.md`. ToU prohibits apps that "merely + redistribute Content or create applications that substantially replicate + the functionality of Mapillary Services" without materially supplementing + them — a geo-guessing game (a materially different use case) sits on the + safe side of that line, same conclusion the project has already reached. + ToU does **not** publish a numeric caching duration for image bytes; the + project's own prebuilt-index + on-demand `fetchPanoramaById` approach + (cache ids, not bytes, fetch fresh URLs at play time) already matches + observed community guidance that thumbnail URLs are TTL'd and should be + refetched rather than stored long-term [mapillary-forum-cache]. +- **Rate limit**: documented 50,000 requests/day, scope (per token vs per + account vs global) is disputed even in Mapillary's own community forum + [mapillary-forum-limit] — the project's own docs already treat this as a + hard budget for boundary/index rebuilds. Any new feature that adds + per-round Mapillary calls (e.g., movement, live search) competes with that + same budget and should be evaluated against it explicitly. +- **KartaView**: also CC BY-SA, community-run (now under Grab), similar + attribution model to Mapillary but far lower Vietnam coverage in practice; + a viable *secondary* source to backfill gaps, not a replacement. +- **Panoramax** (IGN France / OSM France): fully open-licensed pipeline (not + just the images) [tzovaras-panoramax], positioned as the ideological + alternative to Mapillary/KartaView's proprietary backends — but its public + instances are France-focused; **no meaningful Vietnam coverage today**, so + not usable for this project despite the cleaner license. +- **Wikimedia Commons panoramas**: no evidence found of any clone using + Commons as a panorama source at scale — Commons has scattered 360° + photospheres, not a queryable street-level network, so it's unfit as a + systematic input; could only ever supplement specific landmarks manually. + +### Q3 — Viral/retention mechanics + +- **Wordle's model** is the reference case: one puzzle/day (scarcity → + anticipation → shared daily moment), a spoiler-free emoji share grid that + works on any platform, and a streak counter that is visible but not + punishing (breaking it just resets a number) [hackernoon-wordle] + [historytools-wordle]. McKinsey-cited modeling shows >70% Wordle retention + at 10 months post-adoption vs 20-30% for typical social apps at the same + horizon [historytools-wordle] — the standout number in this space, though + it is one secondary citation of an unpublished model, not a primary source; + treat as directional, not precise. +- **TimeGuessr** extends the "daily, shareable, same-for-everyone" pattern + into geo-guessing specifically: 5 daily rounds, new photos added daily, + shareable rounds, friend leaderboard comparison [eraguessr-timeguessr] + [gigazine-timeguessr] — closest genre-analog to what a VNGeoGuessr daily + mode could look like. +- **Mechanism common to all three** (Wordle, GeoGuessr Daily, TimeGuessr): + same seed for every player + a compact copy-pasteable result. This is the + one mechanic in the whole research set that is (a) proven across three + unrelated products, (b) zero marginal backend cost, and (c) directly + portable to VNGeoGuessr's existing Redis leaderboard pattern. + +### Q4 — Vietnam-market specifics + +- **Zalo**: Vietnam's #2 social platform after Facebook, ahead of YouTube in + 2025 usage rankings [salesmartly-zalo]; run by VNG (the user's employer, + incidentally — no special access implied, just the market-share fact). Zalo + supports timeline sharing and has a "Mini Game" surface for + brands/interactive content [salesmartly-zalo], but that Mini App/Mini Game + platform requires developer registration with Zalo's OA (Official Account) + program — a real integration project, not a free `?share=` link. A **plain + web link shared into Zalo chat/timeline** (like any URL) needs no + integration at all and is the pragmatic zero-cost option; Zalo will render + Open Graph tags for a link preview same as Facebook/Twitter, so investing + in correct OG image/title tags for a result page is the actual lever, not a + Zalo SDK. +- **No evidence found** of a comparable general-audience Vietnamese + geo-guessing or geo-trivia web game (searched Vietnamese-language queries + directly). One informal community reference — "ViGuessr" — surfaced only as + a Facebook group post title, with no live product, GitHub repo, or further + detail found [viguessr-fb]; treat as an unverified community mention, not a + competitor to analyze. GeoGuessr itself ships an official "Vietnam" map + played on their platform [gg-vietnam-map], and Vietnamese gaming forums + (Tinhte) discuss playing GeoGuessr generally [tinhte-geoguessr] — this + establishes player demand/familiarity with the genre in Vietnam, not a + Vietnamese-made competing product. +- **Language**: no direct evidence gathered on Vietnamese-specific UI + copy conventions for this genre specifically (see Unresolved Questions). + +## 3. Feasible Feature Ideas — Ranked by Impact vs Effort (zero-cost constraint) + +| # | Feature | Impact | Effort | Why | +|---|---|---|---|---| +| 1 | **Daily Challenge** (fixed seed per UTC day, per region, own leaderboard) | High | Low | Direct precedent (GeoGuessr, TimeGuessr, GeoHub) [gg-wiki][eraguessr-timeguessr][geohub-repo]; reuses existing Redis sorted-set + Postgres index pattern; no new infra | +| 2 | **Shareable result text** (score + distance ladder as compact copy-paste, e.g. emoji per round) | High | Low | The single most evidence-backed retention mechanic in the genre [hackernoon-wordle]; pure client-side string build from data already returned by `submitRoundScore` | +| 3 | **Daily streak counter** (cookie/`vng_pid` + Redis, "played today" flag) | Medium-High | Low | Pairs naturally with #1; Wordle-style "visible but not punishing" pattern [historytools-wordle] | +| 4 | **Shareable challenge link** (replay the exact panorama sequence a friend played) | Medium | Low-Medium | GeoHub precedent [geohub-repo]; needs only a Redis-backed short code mapping to a panorama-id list, TTL'd | +| 5 | **Open Graph tags on result/share page** for Zalo/Facebook link previews | Medium | Low | Zalo has no special API needed for basic link sharing; OG tags are the actual lever [salesmartly-zalo] | +| 6 | **Country/region streak mode** (consecutive rounds, ends on first miss below a threshold) | Medium | Low | Client + existing session flow; WorldGuessr precedent [worldguessr-repo] | +| 7 | **Custom/curated challenge sets** (e.g. "Old Quarter Hanoi only", authored like today's region tree) | Medium | Medium | Extends existing authored-region pattern rather than open user-generated maps (which would need moderation — out of scope for a no-accounts hobby project) | +| 8 | **Movement between connected Mapillary images** (limited to sequences already in the index) | Medium | Medium-High | Matches core genre expectation, but each move is a Mapillary API call against the 50k/day cap already tracked in `docs/project-overview.md`; needs a hard per-round move cap to stay affordable | +| 9 | **Duels (1v1 realtime)** | High (engagement) | High | Needs realtime transport incompatible with pure serverless hosting; only worth it if a free realtime tier (Pusher/Ably) is acceptable and rate limits are checked against expected traffic | +| 10 | **Party/multiplayer lobby** | Medium | High | Same realtime constraint as #9; WorldGuessr's version assumes a persistent server, a hosting-model change [worldguessr-repo] | + +Ranking logic: #1-#6 all reuse existing Redis/Postgres/cookie infrastructure +with no new paid services and no realtime transport — they are the "do these +first" tier. #7-#8 are medium effort but still architecturally compatible. +#9-#10 are the only ideas that conflict with the "free-tier hosting, no +persistent server" constraint and should be treated as later-stage stretch +goals, not first picks. + +## 4. Licensing/ToU Cautions + +- Keep displaying the Mapillary logo linked to the **homepage**, not + per-image pages, for any new surface (daily-challenge result page, share + cards) that shows a panorama — current practice already does this + correctly [mapillary-terms]. +- Do not cache/store raw Mapillary image bytes long-term for reuse across + sessions (e.g. to build a "photo of the day" archive) — thumbnail URLs are + TTL'd and the ToU frames redistribution restrictively; re-fetching by id at + play time (current pattern) is the safer posture [mapillary-forum-cache] + [mapillary-terms]. +- Any feature that adds Mapillary calls per player action (movement, live + re-search) must be budgeted against the 50,000/day cap; that cap's exact + scope (per-token vs per-IP vs global) is contested even in Mapillary's own + forum [mapillary-forum-limit] — do not assume headroom without a small + load test. +- If Google Street View Embed API is ever considered (as WorldGuessr/GeoHub + use it) [worldguessr-repo][geohub-repo], that introduces a second imagery + license/attribution regime alongside Mapillary's CC BY-SA — a real + complexity increase, not just an extra credits-page line. Not recommended + given the project's existing single-source design. +- KartaView is a plausible secondary CC BY-SA source if Vietnam coverage + gaps need filling, but no Vietnam-specific coverage density was verified in + this research pass (see Unresolved Questions). + +## 5. Unresolved Questions + +- Actual KartaView panorama density/coverage in the nine covered Vietnamese + provinces — not measured; would need a direct API probe before treating it + as a real gap-filler. +- Exact scope of Mapillary's 50k/day rate limit (per client_id, per account, + or per IP) is disputed in Mapillary's own community forum + [mapillary-forum-limit] — worth a direct empirical test (burst requests, + watch for 429s) before budgeting a movement feature against it. +- No primary source found for Vietnamese-language UI/copy conventions + specific to trivia/geo games (e.g. tone, formality level expected by + Vietnamese players) — this pass found market-share and platform facts about + Zalo, not content/localization guidance. +- "ViGuessr" (Facebook mention) could not be verified as a live product — + worth a direct check (search Facebook group post, ask in Vietnamese gaming + communities) if competitive positioning against a same-country GeoGuessr + clone matters for the brainstorm. +- Zalo Mini App/Mini Game program's actual cost/eligibility (free tier vs + paid OA requirements) was not verified — flagged as "requires registration" + from secondary sources only; would need a direct look at Zalo's developer + docs before scoping a real Zalo-native integration (as opposed to plain + link sharing, which needs no integration at all). + +[gg-wiki]: https://en.wikipedia.org/wiki/GeoGuessr +[geoguessr-changelog]: https://geoguessr.canny.io/changelog +[shapes-modes]: https://shapes.inc/fandom/geoguessr/game-modes +[geohub-repo]: https://github.com/benlikescode/geohub +[worldguessr-repo]: https://github.com/codergautam/worldguessr +[github-mapillarygeoguessr]: https://github.com/gabrielrbarbosa/MapillaryGeoGuessr +[mapiguesser]: https://forum.mapillary.com/t/ghibli-style-street-view-images-in-a-geo-guessing-game-thanks-to-mapillary/9467 +[mapillary-terms]: https://www.mapillary.com/terms +[mapillary-forum-cache]: https://forum.mapillary.com/t/webapp-ai-fetch-issue-looking-for-help/9973 +[mapillary-forum-limit]: https://forum.mapillary.com/t/50-000-requests-day-rate-limit-scope/10644 +[tzovaras-panoramax]: https://tzovar.as/open-source-streetview/ +[hackernoon-wordle]: https://hackernoon.com/wordle-how-the-latest-internet-sensation-went-viral +[historytools-wordle]: https://www.historytools.org/docs/wordle-streak +[eraguessr-timeguessr]: https://eraguessr.ai/guides/guess-the-location-and-year-game +[gigazine-timeguessr]: https://gigazine.net/gsc_news/en/20230821-timeguessr/ +[salesmartly-zalo]: https://www.salesmartly.com/en/blog/docs/what-is-zalo +[viguessr-fb]: https://www.facebook.com/groups/j2team.community.official/posts/1775135976730367/ +[gg-vietnam-map]: https://www.geoguessr.com/maps/vietnam +[tinhte-geoguessr]: https://tinhte.vn/thread/ru-anh-em-choi-geoguessr-trau-doi-kien-thuc-dia-ly-de-nghien-gia-tre-lon-be-deu-choi-duoc.3748637/ diff --git a/plans/reports/synthesis-260920-2201-improvement-brainstorm.md b/plans/reports/synthesis-260920-2201-improvement-brainstorm.md new file mode 100644 index 0000000..87aecf5 --- /dev/null +++ b/plans/reports/synthesis-260920-2201-improvement-brainstorm.md @@ -0,0 +1,98 @@ +# Synthesis: how to improve VNGeoGuessr (2026-09-20) + +Four parallel brainstorms, read-only, at commit 5406b38. Source reports: + +- [Gameplay & retention](brainstorm-260920-2201-gameplay-and-retention.md) +- [UI/UX audit (code-only)](ui-ux-review-260920-2201-improvement-brainstorm.md) +- [Codebase health](code-review-260920-2201-codebase-health.md) +- [Geo-game landscape research](research-260920-2201-geo-game-landscape.md) + +Gates at time of scan: 292 tests pass, lint 0 errors / 20 warnings, build green. + +## Where the four agree + +1. **No unit of play.** Endless single rounds; nothing to finish, share, or return + to. Gameplay, UX and research all land on: 5-round run + summary → share text + → daily challenge + streak. Same machinery, in that order. +2. **Leaderboard model is undecided and it bites.** Score board trims below top + 200 and *discards* that player's total (leaderboard.js `creditScore`; the + comment acknowledges it). Once 200th place holds >5 points, no new player can + ever enter. Gameplay report independently flags all-time cumulative as + "unwinnable for late arrivals". Fix and product decision are the same + question: career total vs weekly/seasonal board vs display-window-only trim. +3. **Feedback gap for mediocre guesses.** A 2 km and a 400 km miss both read + "0 / Missed"; rank outside top 200 reads as a dead end; ladder hidden in a + collapsed details. UX (M2, M7-idea) and gameplay (ideas 4a, 7) converge on a + display-only closeness/region-hit readout that leaves the one-ladder decision + intact. +4. **Free-tier ceilings are the real availability risk.** ~27 Redis commands per + round on a 500K/month plan (~18.5K rounds), no rate limiting on any route, + three unauthenticated debug routes spending Neon and the Mapillary token. + Every retention idea adds commands, so budget hygiene comes first. +5. **Distribution before retention.** Gameplay report challenges the premise: + if DAU is single digits, shareable text + OG tags + Vietnamese UI beat any + loop. Research confirms plain link sharing with Open Graph tags is the + zero-cost Zalo/Facebook path; no Zalo integration needed. + +## Verified bugs worth fixing regardless of roadmap (all S except H1) + +| Finding | Where | Effort | +|---|---|---| +| Top-200 trim erases totals (ratchet) | src/lib/leaderboard.js creditScore | M + decision | +| NaN coordinates pass validation, session consumed, then 500 | src/app/api/guess/route.js ~L41 | S | +| Username validated client-side only; `:` corrupts distance member packing | UsernameModal.js, guess/route.js, leaderboard.js | S | +| `?sessionId=` used verbatim as Redis key | new-game/route.js | S | +| Read-modify-write on score → lost update; use ZINCRBY | leaderboard.js | S | +| Session-expired error shown as "guess could not be saved" | GameClient.js ~L224 | S | +| Game header overflows on ≤414px, clips mute/beer after round 1 | GameClient.js:428 + header cluster | S-M | +| Stale "per-board ladder" tooltips contradict features.md | GameClient.js:455, RoundResultDialog.js:400 | S | +| three.js in first-load chunk (624 KB); PanoramaViewer not dynamic | GameClient.js:6 | S | + +## Proposed sequence + +**Phase 0 — hygiene (1 short session).** NaN/username/sessionId validation, +ZINCRBY, dynamic PanoramaViewer, stale tooltips, expiry copy, header overflow. +Add a GitHub Actions workflow (lint/test/build:check) and a leaderboard export +script on a schedule. No product decisions needed. + +**Phase 1 — edge + budget.** One `middleware.js`: rate limit `/api/*` keyed on +`vng_pid` + IP via `@upstash/ratelimit`, gate or delete `/api/debug/*` in +production. Pipeline the guess fan-out. Log one per-round budget line. + +**Phase 2 — decide the board.** Pick: (a) trim is display-only, totals kept in +an unbounded key; (b) weekly boards with TTL alongside all-time; (c) status quo +accepted. Gameplay recommends (b) country-level only (+4 commands) if at all. + +**Phase 3 — unit of play.** 5-round run, summary screen, share text +(emoji squares, Web Share API, no coords/pano id/district in the text), OG tags +on `/game/{region}`. Display-only closeness readout + "explore this spot" link. + +**Phase 4 — return loop.** Daily challenge (`daily:YYYY-MM-DD`, 48h TTL, +dated board), streak counter, then async "challenge a friend" links on the same +seeded-set machinery. Passport (districts seen) in localStorage. + +**Later / not now.** Panorama movement (multiplies Mapillary calls), curated +landmarks pool (L, offline), realtime duels (needs persistent server, breaks +Vercel model), Vietnamese UI (do with a distribution push). + +## Decisions only the maintainer can make + +1. Leaderboard model (career / weekly / display-only trim). Blocks phase 2 and + any competitive feature. +2. Keep `/api/debug/*` public in production, or gate it? Coordinate exposure + was an accepted decision; the metered-cost side is new. +3. Identity: keep forgeable localStorage names forever, or lightweight accounts + before daily/duel boards? +4. Scoring feedback: display-only readout (4a, recommended, reversible) vs + continuous headline score (4b) vs board change (4c, reverses recorded decision). +5. Music default-on with a modal covering the mute button on first tap: keep, + delay first play until after the username modal, or start muted? + +## Unresolved facts to look up + +- Actual visitors/day and repeat rate (Vercel analytics `/game/{region}` rows). +- Current Upstash monthly command count. +- Whether Mapillary `thumb_2048_url` embeds the image id (if yes, `/api/debug/pano` + turns a live round's image URL into its answer). +- Whether Mapillary thumb URLs expire (decides daily-set caching). +- Header overflow thresholds and PSV keyboard behaviour need one real device pass. diff --git a/plans/reports/ui-ux-review-260920-2201-improvement-brainstorm.md b/plans/reports/ui-ux-review-260920-2201-improvement-brainstorm.md new file mode 100644 index 0000000..c25a4c0 --- /dev/null +++ b/plans/reports/ui-ux-review-260920-2201-improvement-brainstorm.md @@ -0,0 +1,113 @@ +# UI/UX review — improvement brainstorm + +Date: 2026-09-20 · Scope: home → picker → round → result → leaderboard, plus the audio, region-path-URL and 404 surfaces added since 2026-09-06. Method: source only (no browser on this host). Advisory; no code changed. + +Prior audits checked so nothing below repeats an applied fix: `ui-ux-review-260831-1853`, `260901-1624`, `260902-0210`, `260902-0923`, `260906-1522/1659/1751`, `synthesis-260831-1853`. + +## 1. What works well + +- Loading is split three ways (`GameClient.js:79-81`) and the next round is prefetched with its image while the result dialog is open (`:270-282`). Submit no longer tears the viewer down. +- Failure states stay honest: inline retry panel (`:500-516`), `failed: true` result instead of a fake miss (`:308-321`), leaderboard outage not shown as an empty board (`LeaderboardModal.js:132-138`), non-dismissable result dialog (`RoundResultDialog.js:249`). +- Hint rides in the panorama's flow top bar beside the Mapillary credit (`PanoramaViewer.js:375-404`); one z-index ladder with `isolate` (`globals.css:253-267`); expanded minimap has Escape + focus management (`GuessMapPanel.js:38-50`). +- Tokenised palette incl. semantic result colours (`globals.css:123-139`); 44px floor in `Button`; blue/green marker pair with a named legend (`RoundResultDialog.js:317-334`); scoring copy derived from `SCORE_BANDS` (`page.js:26-37`). +- Per-region titles and both 404s with a way out; audio gated on a gesture, two persisted switches, compact mute on phones, CC0 assets credited. + +## 2. Findings + +### HIGH + +**H1. Game header overflows on phones — controls get clipped once the tally appears.** +Evidence: `GameClient.js:432-491`. Right cluster = ThemeToggle 3×44+border (`ThemeToggle.js:180,193`) ≈134px + compact SoundToggle 46px + icon Beer ≈36px + two `gap-2` = ≈232px. Left: icon Back ≈36px. Centre: region `Badge` (`whitespace-nowrap shrink-0`, `badge.jsx:8`) "Ho Chi Minh" ≈81px, plus after round 1 the tally badge "3 rounds · 7 pts" ≈105px. Header padding 24px. Sum ≈389px without the tally, ≈500px with it. Root is `overflow-hidden` (`:428`), so at 360–414px the right cluster is cut at the viewport edge. +Impact: on most phones the Beer button (and on smaller ones the mute switch) disappears after the first round; the tally, meant as motivation, evicts controls. +Fix: below `sm` render ThemeToggle as one cycling button (or omit it from the game header — theme is set on home); `max-w-[28vw] truncate` on the region badge; render the tally as `3 · 7 pts` with a `title`, or move it into the action bar's free left edge on `lg`. Confirm widths on a 360px device (Q1). + +**H2. Core mechanic still pointer-only.** +Evidence: guess placement exists only in `map.on('click')` (`LeafletMap.js:421-439`); no Enter/Space-to-place. Panorama keyboard look-around: PSV's default `keyboard` option is `'fullscreen'`, and `PanoramaViewer.js:310-324` does not set it, so arrow keys do nothing on the desktop viewer unless fullscreen — and the fullscreen navbar is hidden below `lg` (`globals.css:239-243`). +Impact: WCAG 2.1.1 failure on the primary task; unchanged since the first audit. +Fix: when the Leaflet container has focus, Enter/Space drops the pin at map centre with a visible crosshair overlay (Leaflet already pans with arrows via `keyboard: true`); pass `keyboard: 'always'` to PSV and verify it ignores keystrokes inside the search `Input` (Q2). + +**H3. A 30-minute expiry still reads as a save failure.** +Evidence: `/api/guess` distinguishes `'Session not found or expired'` (`guess/route.js:26`) and `'Session already submitted or expired'` (`:83`); the client discards `data.error` and sets `{ failed: true }` (`GameClient.js:224-229, 311, 319`); the dialog copy is one generic sentence (`RoundResultDialog.js:277-284`). +Impact: the player who studies a hard panorama for half an hour is told "Your guess could not be saved" — reads like a server bug, blames the app. +Fix: store `reason: 'expired' | 'network' | 'server'` on the failed result (from status 404 vs fetch throw vs 500) and branch the copy: "This round expired after 30 minutes — the next one is ready." Optional: a 30-min client timer that flips Submit to "Round expired — New round". + +### MEDIUM + +**M1. Primary CTA is below the fold on phones.** +Evidence: `page.js:148-188` renders How to Play (4 steps + 6 scoring rows + note) before Where to Play; on `lg` the grid puts them side by side, below `lg` they stack. Header controls wrap to 2–3 rows (`page.js:104-136`: ThemeToggle 134 + SoundToggle 90 + name chip up to 176 + Leaderboard ≈120 + Beer ≈140 on a 328px row). Estimated ≈700px before the first Play row on a 667px viewport. +Fix: `order-first lg:order-none` on the Where to Play card, or wrap How to Play in `
` below `lg`; group Theme+Sound as one row and drop the Beer button to the footer below `sm`. + +**M2. The scoring ladder is hidden under a disclosure named for something else.** +Evidence: `RoundResultDialog.js:349-353` summary reads "Leaderboard results"; the ladder chips live inside it (`:358-383`). A 0-point country round shows "Missed" + "Nice try!" with no visible answer to "how close did I need to be?". +Fix: lift the one-row ladder strip above `
` (it is ≈32px), keep the board cards inside; or rename the summary "Scoring ladder & leaderboards". Deep-linked players never saw the home-page table, so this is their only explanation. + +**M3. Copy still claims per-board scoring ladders.** +Evidence: `GameClient.js:455` title "…leaderboards grade each board on its own scale"; `RoundResultDialog.js:400-403` title "Each board grades your distance on its own scale" plus the comment. `docs/features.md` and commit `b15d199` say the headline and every board agree. +Fix: delete both `title`s; since `entry.points` now equals the headline, render `+N` once or drop it. + +**M4. Usernames reject Vietnamese letters.** +Evidence: `UsernameModal.js:395-397` `/^[a-zA-Z0-9_-]+$/`; error says "letters" yet "Tiến" fails. Server only trims (`guess/route.js:89`), so the rule is client-only. +Fix: `/^[\p{L}\p{N}_-]+$/u`, count length in code points, mirror on the server. Note `layout.js:251-259` loads Geist `latin` only, so diacritics would render in the fallback font (prior F20) — pair with a Vietnamese-capable font if accepted. + +**M5. Leaderboard dialog is cramped below `sm`.** +Evidence: `LeaderboardModal.js:108-131` is `flex gap-3` with a `min-w-[104px]` type column at every width; `DialogContent` is `max-w-[calc(100%-2rem)] p-6`. At 360px the list gets ≈164px: rank `w-14` + gap + score badge leaves ≈40px for the name. +Fix: `flex-col sm:flex-row`; below `sm` render the type toggle as a horizontal segmented row (same pattern as `RegionSelect`'s level row) above the list. + +**M6. Initial load has no exit and no timeout.** +Evidence: full-screen spinner `GameClient.js:415-425` with no Back; `fetchNewRound` (`:42-54`) has no `AbortSignal`; `loadRound` only catches thrown errors, so a hung request spins forever. +Fix: `fetch(url, { signal: AbortSignal.timeout(15_000) })` — the timeout then lands in the existing error panel with Try again / Back; add a "Back to menu" link under the spinner. + +**M7. Dialog motion ignores `prefers-reduced-motion`.** +Evidence: `dialog.jsx` overlay/content use `animate-in zoom-in-95 fade-in-0`; the reduced-motion blocks (`globals.css:334-357`) cover accordion, select, fade-in-up and spin only. `GuessMapPanel.js:77` `transition-all duration-200` also unguarded. +Fix: add `[data-slot='dialog-content'], [data-slot='dialog-overlay']` to the `animation: none !important` block; `motion-safe:transition-all` on the panel. + +**M8. Music starts on the first tap, behind a modal scrim, with no prior signal.** +Evidence: capture-phase `pointerdown`/`keydown` unlock (`audio.js:270-280`); `MusicPlayer` starts on unlock (`:294-302`); on landing the first gesture is inside `UsernameModal` (`page.js:51-55`) while the only mute control sits behind the scrim (`:110`). `keydown` also means a keyboard user pressing Tab starts music. +Default-on is a recorded decision; options rather than a reversal: (a) start music on the Play click or when the welcome modal closes, not on the first pointerdown; (b) one-line "Music is on · Mute" link in the welcome modal footer; (c) unlock on `keydown` only for Enter/Space. + +### LOW + +- **L1** Result-map coordinate footer (`ResultMap.js:619-626`) is expert noise; the legend already names both dots. Drop or replace with "Guess → Actual". +- **L2** `focus-visible:-ring-offset-1` is not a utility (`LeaderboardModal.js:118`); still a no-op since 08-31. +- **L3** "score" / "distance" type buttons (`LeaderboardModal.js:112-127`) need a one-line explainer (total points vs best single guess). +- **L4** Minimap cover still lacks `aria-expanded` (`GuessMapPanel.js:141-150`). +- **L5** `themeColor` follows the OS only (`layout.js:273-276`); set `` inside `applyTheme` so a forced-dark user does not get a light address bar. +- **L6** `RegionPicker.js:118-119` docblock says the province row plays; in code the trigger expands and Play is inside content (`:173-174`), so a province is two taps. Fix the comment; consider a Play pill beside the chevron as a sibling (not nested) control. +- **L7** First effect has decode latency: `playSound` awaits fetch+decode, so the `click` on Play (`page.js:92`) lands after navigation starts. Warm `click`/`pin`/`submit` on unlock — that is a gesture, so the "nothing fetched before a gesture" rule holds. +- **L8** Music stop/start is abrupt (`MusicPlayer.js:257-262, 283`); a 300ms gain ramp reads as intentional. +- **L9** Skip is still a same-weight outline beside Submit (`GameClient.js:575-584`); `variant="ghost"` + wider gap matches consequence. +- **L10** The initial path never sets `roundLoading` (`GameClient.js:168-189`), so between spinner-off and texture-ready the pane shows PSV's unthemed "Loading…" text. Set it true there, or style `.psv-loader` with tokens. + +## 3. Forward-looking ideas + +| # | Idea | Effort | Why | +|---|---|---|---| +| 1 | **Region-hit feedback** on province/country rounds: server already resolves the panorama's district; resolve the guess with the same outlines and add "Right province, wrong district" under the score. Ladder untouched. | M | Gives Vietnam rounds a gradient without reopening the one-ladder decision | +| 2 | **Progressive texture**: load `thumb_1024_url` first, `viewer.setPanorama(thumb_2048)` when ready | M | Halves time-to-first-look on mobile data | +| 3 | **5-round set + summary** (rounds, points, best distance) seeded from the existing tally | M | Still the strongest open retention lever from the synthesis | +| 4 | **Share line**: copy "VNGeoGuessr · District 7 · 4/5 · 87 m · /game/tphcm-q7" from the result dialog | S | Region path URLs make results deep-linkable for free | +| 5 | **Self-rank row**: `ZREVRANK` for `currentUsername`, pinned "You · #143" under the list when off-screen | S | Top-200 boards hide most players from themselves | +| 6 | **Picker search**: reuse `searchRegions` from `lib/geo-search` above the accordion | S | 85 regions; alias-aware matcher already exists | +| 7 | **Haptic on pin drop**: `navigator.vibrate(10)` beside the `pin` sound, gated by the SFX pref | S | Confirms the tap when the phone is muted | +| 8 | **Daily challenge** (date-seeded 5-panorama set, daily board) | L | Synthesis H1; unchanged reasoning | + +## 4. Top 3 + +1. **H1** — a phone header that clips its own controls after round one is the most visible regression on the most common device; one breakpoint change. +2. **M1 + M2 bundle** — get the Play row above the fold and the ladder out from under "Leaderboard results"; together they fix first-session comprehension for both home and deep-link arrivals. Do **M3** in the same pass (copy contradicts the shipped scoring). +3. **H3 + M6** — carry the server's reason into the failed result and give the first fetch a timeout; small change, removes the two remaining "the app looks broken" moments. + +## 5. Unresolved questions (need a real device or browser) + +1. Exact header overflow thresholds at 360/375/390/414px with and without the tally badge (H1 math is from class widths, not measurement). +2. Does PSV `keyboard: 'always'` swallow arrow keys typed in `MapSearchBox`? Decides the shape of the H2 fix. +3. On iOS Safari, does music start on the first tap inside the welcome modal, and is the mute control discoverable at that moment? (M8) +4. Whether tw-animate's `animate-in` respects reduced motion on its own; M7 assumes it does not. +5. Leaderboard list legibility at 320px with 20-char names (M5). +6. If M4 is accepted: how Geist's latin-only subset renders Vietnamese names in chips, the result dialog and the leaderboard. + +--- + +Status: DONE +Summary: Read-only UI/UX audit of VNGeoGuessr; 3 high, 8 medium, 10 low findings with file:line evidence and fixes, 8 forward ideas, top-3 sequence. Report written to the requested path. +Concerns/Blockers: none — all width/overflow figures are derived from classes and need one device pass to confirm.