EarthGuessr is a small team's product. We did not have the headcount to build a custom backend, a bespoke deployment pipeline, or a homegrown map rendering engine. Every choice in our stack was made under that constraint: pick the most boring thing that works, and only build what is genuinely missing from the off-the-shelf options. This post is a guided tour of what we ended up with and why.
The frontend: Vite, React, TypeScript
The application is a single-page React app, written in TypeScript, bundled by Vite. There is nothing exotic here. We considered Next.js but did not need server-side rendering for the app itself — gameplay is fundamentally a client-side experience — and Vite's development experience is hard to beat for a pure SPA. Hot module replacement makes the inner loop fast, and the build output is straightforward to deploy as static files.
React Router handles all navigation. We picked the modern Routes/Route API and have not regretted it. Most pages are lazy-loaded, which keeps the initial bundle small. The home page sees the most traffic and gets the most aggressive bundle-budget pressure, so we have been careful not to import heavy dependencies into shared layout components.
Styling is a mix of Tailwind CSS for utility classes and inline style props for one-off layout. We tried to keep CSS-in-JS to a minimum because runtime CSS generation is a measurable cost on lower-end devices, and the home page has a 3D globe that already consumes most of the GPU budget. Tailwind's purge step keeps the production CSS bundle small.
The 3D globe: globe.gl, three.js, three-globe
The interactive globe on the home page is rendered with globe.gl, which is a higher-level wrapper around three.js and three-globe. We could have used Cesium or one of the WebGL globe libraries that have proper geographic coordinate systems, but globe.gl is dramatically simpler to integrate and the rendering quality is more than enough for a marketing surface.
Performance was the main challenge. A naive globe with full Earth imagery and a few hundred location markers can drop frame rate on mid-range devices. We resolved this by using lower-resolution textures for the globe surface, batching markers into a single mesh, and pausing the rendering loop entirely when the page is not in the foreground. Three.js handles the heavy lifting, but the integration code is what keeps frame rates stable.
The in-game map: MapLibre and Mapbox
The gameplay map — where you place your guess after looking at a satellite frame — uses Mapbox GL via react-map-gl. We also pull in MapLibre GL for cases where we want to avoid Mapbox's pricing tier; MapLibre is a fork of the older open-source Mapbox GL JS and is essentially API-compatible for the features we use.
Map tiles are the meaningful cost line for a geography game, because every game generates 5–40 map loads depending on the mode. We picked the lowest-priced tier that still meets our quality bar for satellite imagery. The satellite-imagery view for "look at this frame and guess" needs to be high enough resolution to be playable but not so high that we are paying for unused detail.
The backend: Supabase
The single biggest decision in our stack was using Supabase for almost everything backend-related. Supabase gives us: a Postgres database, row-level security, an auto-generated REST API, real-time channels for multiplayer, authentication, file storage, and edge functions if we ever need them. Each of these would be a multi-week build internally. Together they are a managed service we pay a small monthly fee for.
Game logic is implemented as Postgres functions written in PL/pgSQL. When you start a game, your client calls an RPC named start_game. That function picks five random locations, creates a session record, pre-allocates the round records, and returns the first location to your client. When you guess, the client calls submit_guess with your coordinates. The function calculates the distance using a haversine formula, applies our scoring curve, writes the result, and returns the score plus the next location.
Doing scoring inside Postgres is unusual for a web app, but it has been the right call for us. It keeps the game logic in one place — the database itself — which makes anti-cheat enforcement trivial. The client has no way to compute a score because the calculation never runs in the browser. There is also a nice symmetry between game state and persistence: the row in the database is both the durable record and the source of truth for the current round.
Realtime multiplayer
Multiplayer is built on Supabase Realtime, which is a managed WebSocket gateway on top of Postgres logical replication. Each lobby has two real-time channels — one for the player list and one for game state. When someone joins, leaves, or the host starts the round, the message is broadcast to every connected client through that lobby's channels.
We did not write a custom WebSocket server and we did not try to roll our own peer-to-peer connection. The whole real-time piece is a thin layer on top of the same database we use for everything else. That has made multiplayer remarkably stable — and the failure modes are well understood, because they are the failure modes of Postgres replication.
Deployment: Vercel, plus an unusual prerender step
The application is hosted on Vercel. It is configured as a pure static site — the build emits the dist directory and Vercel serves it through its CDN. There is a single rewrite rule that sends every path to index.html so client-side routing works on direct hits.
The interesting part of our deployment is the prerender step. Marketing and blog routes are statically rendered at build time using Playwright. We start a local preview server, spin up a headless Chromium, navigate to each route, wait for the page to settle, capture the rendered HTML, fix up the canonical and meta tags for that route, and write the file to dist/{route}/index.html. This means search-engine crawlers see fully rendered HTML for the blog and the marketing pages, while the actual gameplay routes remain a normal SPA.
The wrinkle: Vercel's build sandbox does not have the NSS or NSPR system libraries that Playwright's bundled Chromium needs to launch. The workaround is to detect that we are running in a Vercel build and switch to @sparticuz/chromium, which is a serverless-tuned Chromium build that ships its own static binaries. Locally we use the regular playwright package; in production we swap in playwright-core plus sparticuz. This kind of cross-cutting "works locally, broken in CI" bug is the most expensive part of any build pipeline, and we lost a day to it before landing the fix.
Analytics and feedback
PostHog handles product analytics. We instrument the key events — game started, guess submitted, mode selected, blog post viewed — and use the resulting funnels to figure out where players drop off. We deliberately do not track anything more sensitive than session-level activity. There is no user-level fingerprinting beyond what the SDK gives us by default.
Vercel Speed Insights gives us the Core Web Vitals numbers for the marketing pages. We watch LCP especially carefully on the home page because the 3D globe is a heavy first-paint element and a regression there is immediately visible in the metric.
Testing
End-to-end testing is Playwright, the same tool we use for prerendering. We have a small but meaningful suite that exercises the critical paths: anonymous user starts a game, signs up, plays a multiplayer round, claims a daily leaderboard slot. We do not have unit tests in any deep sense — the application is short on pure logic to test in isolation, since most behaviour is either UI or database side effects.
TypeScript provides the cheapest layer of safety we have. The Supabase client is typed against the database schema (via the generated types), which means a typo in a column name fails at compile time. This catches more bugs than a unit test would.
What we deliberately did not do
- We did not write a custom backend in Node or Go. Supabase is good enough and saves us months.
- We did not pick a framework with built-in SSR for the app. The gameplay surface does not need it, and the marketing surface gets static prerendering via Playwright.
- We did not introduce a state-management library like Redux. React Context plus useState plus the database itself as the source of truth has been sufficient.
- We did not build our own image hosting. Unsplash for marketing imagery, Mapbox for satellite tiles.
- We did not write our own auth. Supabase's built-in email-and-OAuth flow does the job.
The principle
The whole stack is an exercise in picking battles. Every team has a finite amount of attention to spend on engineering. Spending that attention on infrastructure — building your own database, your own auth, your own analytics — leaves nothing for the parts of the product that actually differentiate you. Spending it on the game itself, the satellite imagery curation, the scoring tuning, the UX of the result screen, is what actually moves the needle.
A stack is good when it is mostly invisible and mostly forgettable. Ours is close to that. The interesting code is the gameplay code; the infrastructure underneath is intentionally boring, and that is a feature.