Scoring, the Upstash key adapter, sessions and the leaderboards had no
coverage, and they carry the anti-cheat model: server-held coordinates,
session TTLs, rank arithmetic.
One suite runs against two backing stores. By default @upstash/redis is
mocked with an in-memory fake, so `npm test` needs no service and
finishes well under a second. With TEST_REDIS=real the mock steps aside
and the same files run against a real Redis behind SRH, the proxy
Upstash recommends for local work, since the SDK speaks HTTP REST rather
than RESP and a plain Redis container cannot serve it alone.
Running both is what keeps the fake honest: a semantic it gets wrong
shows up as a green unit run and a red integration run. Two tests skip
against real Redis, one asserting a response shape only an older SDK
produces and one fast-forwarding half an hour to watch a session expire.
The suite was checked by mutation rather than by passing alone. Breaking
a score band, the key prefix and the session TTL each failed a test;
reversing the global trim window did not, because only the city
leaderboard was asserted, so that test now covers both scopes.
The compose stack doubles as a local Redis for `npm run dev`, which had
no credentials to reach one before.
package.json also loses the eight unused runtime dependencies removed in
the cleanup that follows; they cannot be split from the same file.
Reviews the stack against 2026 alternatives and finds no swap worth
making: Street View is priced out, MapLibre would fight the panorama
viewer for the GPU, Pannellum trades away the gestures the game runs on.
The findings that matter are mobile playability, panorama weight, and
the Turf monolith reaching the browser through a client-imported module.
Also records an unverified Mapillary bbox constraint that four of five
cities would sit exactly on.
The phone layout stacked a 45dvh panorama above a 400px map and the
action row, so the page ran well past one viewport. Players scrolled
between looking and guessing, page scroll fought every drag, and Submit
often sat below the fold.
The screen is now a fixed h-dvh surface. The panorama fills it, and the
guess map floats over it as a corner minimap that expands to near
fullscreen on tap. A cover intercepts touches while collapsed so a stray
finger cannot drop a pin too small to see, and Leaflet is told to
remeasure after each transition because it caches container size.
touchmoveTwoFingers is off: one finger has to rotate the panorama, since
looking around is the core verb and the screen no longer scrolls. The
viewer navbar hides below lg, where the action bar would bury it and
gestures already cover what it offered.
viewport-fit=cover lets the layout own the display, so the header pads
past the notch and the action bar past the home indicator. The result
dialog caps to the viewport, scrolls its body and pins its actions.
Desktop keeps the two-column split unchanged.
The header donate button also moves to the beer emoji here, because it
shares an import line with the icons this layout adds.
The donation QR is now zlp.jpg, a 768x1024 portrait rather than the old
square, so the intrinsic dimensions and the container width move with it
to keep the aspect ratio right and the dialog inside the viewport.
Coffee becomes beer across the donate surfaces. The emoji replaces the
lucide icon and is hidden from assistive tech, which reads the button
text instead.
thumb_original_url returns the full-resolution equirectangular, commonly
4-8 MP and several megabytes. That download dominates round latency on
mobile data and strains WebGL texture memory on phones.
Request thumb_2048_url alongside it and prefer it, falling back when
Mapillary has no 2048 derivative for an image.
Every surface hardcoded its own grey and red, so the dark palette already
defined in globals.css could never take effect and muted text failed
contrast. Route colour through tokens instead: --brand and --surface join
the shadcn set, the Vietnamese flag red stays the brand, and a
prefers-color-scheme block supplies the dark values.
The dark: variant now matches both the media query and a .dark ancestor.
It previously matched only the class, so dark: utilities compiled to rules
that could never apply under OS-driven theming.
Contrast, measured against WCAG AA rather than eyeballed:
text-gray-400 on the page background was 2.41:1, the blue and purple rank
labels 2.34:1 and 2.46:1, white on amber-500 2.15:1. All twelve token
pairs now sit between 4.7:1 and 19.8:1 in both themes.
Also: emoji and text glyphs used as icons give way to lucide SVGs with
accessible names, score and rank stop relying on colour alone, loading and
result regions announce via role=status, interactive targets reach 44px,
100vh becomes dvh so mobile browser chrome stops clipping the game, the
leaderboard tab count follows the city list, and numeric columns use
tabular figures.
The POST handler read from gameSessions, an in-memory Map removed in
the Redis migration, so every call threw a ReferenceError and returned
an opaque 500. Read through getGameSession instead, and answer 400 on a
missing sessionId, 404 when the session is gone, 500 on a store error.
Parsing the body no longer throws out of the handler on malformed JSON.
Log the underlying Mapillary error before returning the generic
coverage message, so an API outage is not silently reported as a city
without street view.
Racing 8 windows per round made the retry budget correlated: a single
transient 5xx burst failed all 8 at once and burned 40% of it, so a few
seconds of Mapillary trouble exhausted the loop and surfaced to players
as "this city may not have sufficient coverage". Sequential attempts
had spread the same budget over ~20s and rode such blips out.
Count only genuine misses against the 20-window budget. Rounds that
lose windows to 5xx or network errors get their own smaller allowance
with a widening backoff, so a burst is not answered with another burst.
Restore the per-attempt error logging dropped in the previous commit:
a round losing windows to errors now logs the status and count instead
of the same benign "no panos yet" line an empty round produces.
Each Mapillary window query costs ~1-5s and most windows hold no
panoramas, so throwing them one at a time made /api/new-game wait for
the sum of every miss. Measured in production: median 17.8s, max 90.5s
over 18 samples.
Throw 8 windows per round instead, race them with Promise.any and abort
the losers once one yields panoramas. Wall time becomes one round-trip
in the common case. Same 20-window budget, same per-city delta, same
401 short-circuit.
After the change, over 10 samples: median 5.6s, max 20.3s.
Replace pnpm-lock.yaml with package-lock.json. The security overrides move to
package.json#overrides with their ranges intact, including the bounded
'>=x <y' pins whose upper bounds keep the next major out. allowBuilds becomes
package.json#allowScripts for the packages actually present in the tree.
Bump the minor-and-patch dependency group (react 19.2.7, radix-ui,
react-hook-form 7.81.0, tailwindcss 4.3.3, and others), pin eslint to
the Next-compatible 9.x range since eslint 10 breaks next lint, and add
pnpm overrides forcing js-yaml >=4.2.0 and postcss >=8.5.10 to clear
open Dependabot alerts.
The Upstash REST migration is verified in production; node-redis is no longer
needed and the one-shot migration script has done its job.
- Remove `redis` from package.json
- Delete scripts/migrate-upstash.js (recoverable from commit 53ee795)
- Add cleanup plan; close out migration plan Phase 3
Replaces adaptive shrink/grow bbox loop with viguessr's proven approach:
random point inside city bbox → fixed-size sub-bbox of side 2*delta → re-roll
position on empty/error. Small fixed bbox keeps Mapillary query cost under
their 'reduce data' cap; dart-throw eventually lands on a pano-rich window.
- src/lib/game.js: add per-city mapillaryDelta (HN=0.003, others=0.005)
and export cityDeltas alongside existing cityBboxes
- src/lib/mapillary.js: rewrite fetchMapillaryImages(bbox, delta) with
10-attempt position-only retry; revert to thumb_original_url; limit=3
- src/app/api/new-game/route.js: pass cityDeltas[cityCode] to the fetcher
Source: https://github.com/luuvanduc1999/viguessr (assets/js/game.js)
Replace node-redis with @upstash/redis REST client and route all keys through
a centralized adapter that prepends KEY_PREFIX (default 'vngeoguessr:'). The
new Upstash DB can now be safely shared with other Vercel projects without
key collisions.
- src/lib/upstash.js: new adapter with getJson/putJson/del + zAdd/zRange/
zScore/zRank/zRevRank/zRemRangeByRank helpers; reads UPSTASH_REDIS_REST_URL
or KV_REST_API_URL aliases
- src/lib/session.js, src/lib/leaderboard.js: use the adapter; public APIs
unchanged so api routes stay untouched
- src/lib/redis.js: removed
- scripts/migrate-upstash.js: one-shot copy from old TCP DB to new REST DB,
prepending the prefix; idempotent, with --dry-run flag
- docs/tech-stack.md: reflect the new SDK and prefix model
Swap dark animated gradient background for light solid bg, glassmorphism
cards for clean white cards, and remove pulse/glow animations. Keep
Vietnamese red as accent color.
Mapillary API rejects bboxes exceeding 0.01 sq degrees with HTTP 500.
City bboxes (e.g. TPHCM=0.016) exceeded this limit. Now picks random
sub-bboxes within the city area and retries up to 5 times to find coverage.
- Add Vietnamese-themed CSS with gradient backgrounds, animations, and brand color variables
- Redesign homepage with gradient hero, glassmorphism cards, and leaderboard component
- Improve game page loading fallback with spinner
- Enhance GameClient with compact header, polished result modal, and retry logic
- Extract LeaderboardList as reusable component
- Make DonateQRModal responsive instead of hardcoded dimensions
- Polish UsernameModal visuals