How Globdrop is built: six coordinate systems, one image and 19 KB of JavaScript
Globdrop is a geography game: the game names a place, you click where you think it is, and you score by distance and speed. It is written in TypeScript and built with Vite. There is no UI framework and no runtime dependency: the package.json lists only TypeScript, Vite and Vitest, all as dev dependencies.
This post describes how it’s put together, starting with the part that makes it work on very different maps.
Six coordinate systems in one file
Every map in Globdrop has its own way of turning a real position into a pixel. The world maps use the Miller projection, Europe uses Lambert azimuthal equal-area, the United States uses Albers, Catalonia uses UTM metres, the ancient wonders use plain latitude and longitude, and the fictional maps use an invented grid. All of that lives in one file, src/engine/coordinate-math.ts, behind two functions: coordToPixel and pixelToCoord.
| System | Used by | What the code does |
|---|---|---|
| Miller | World capitals, volcanoes, peaks, skyscrapers, stadiums | Longitude is linear; latitude goes through 1.25 · ln(tan(π/4 + 0.4φ)) |
| LAEA | Capitals of Europe | Projects to Lambert azimuthal equal-area centred on 52° N, 10° E |
| AlbersUSA | US States | Projects to an Albers equal-area conic with standard parallels 29.5° and 45.5° |
| WGS84 | Ancient wonders | Longitude is linear; latitude goes through the Mercator formula |
| UTM | Municipalities of Catalonia | Coordinates are already metres on a plane, so the mapping is linear |
| Linear | Middle-earth, Westeros, One Piece | Coordinates are arbitrary units, mapped linearly |
That makes five code paths for six systems, because UTM and Linear share the last branch. The idea is the same in every branch: project the point and the two corners of the map’s bounding box into the same plane, then interpolate. This is the Miller branch of pixelToCoord:
} else if (system === 'Miller') {
const topY = latToMillerY(topLeft.y);
const bottomY = latToMillerY(bottomRight.y);
const rangeY = bottomY - topY;
const millY = (pixel.py / height) * rangeY + topY;
const y = millerYToLat(millY);
return { x, y };
}
The maths of each projection has its own post, Six coordinate systems in one geography game. What matters for the architecture is that adding a map never means adding code, unless it needs a projection we don’t have yet.
A scenario is a JSON file
Each of the 12 map scenarios is a file in public/data/scenarios/. It holds the image path, the coordinate system, the bounding box, the unit (km or mi), mapRealWidth (how wide the image is in that unit), an optional maxDistance (the distance at which a click scores zero, 50 by default) and the list of places, each with its coordinates and a name in six languages. The four flag scenarios are lists of flags in public/data/flags/. Across all of them there are 1,380 places and 182 flags.
The game only fetches the file for the scenario you pick. The largest one is Catalonia, with 946 municipalities: 244 KB of JSON, or 34 KB gzipped.
The map is an image with pins
Globdrop doesn’t draw maps. The playing screen has one <img> with the map, two absolutely positioned pins and one SVG <line> for the distance, and that’s all. The 12 map scenarios use eight image files, because the five world scenarios share one.
The image is shown with object-fit: contain, so on most screens it has empty bands on two sides. getRenderedImageInfo in src/components/ui-renderer.ts works out the size of the visible picture and its offset inside the element. The click handler subtracts that offset, and the game passes the rendered width and height, not the file’s, to pixelToCoord. Because every calculation uses the size as drawn, the same bounding box works at any window size.
We explain why we chose one image over drawing paths in How we make a Globdrop map.
The game loop
A round goes through the states listed in src/types/scenario.ts: IDLE, SETUP, COUNTDOWN, PLAYING, FEEDBACK and RESULTS. The countdown is a small class in src/engine/timer.ts. It schedules itself with requestAnimationFrame and computes the time left from performance.now(), so it doesn’t drift. On each frame it updates the number and the ring around it, and that is the only work done every frame during a round.
When you click, the game converts your pixel to a coordinate, measures the distance and scores it in src/engine/scoring.ts:
const timeMultiplier = (remainingSeconds + 1) / 2;
// Precision as a percentage of the allowed distance (0 to 1)
const precisionFactor = Math.max(0, (maxDistance - distanceKm) / maxDistance);
const precisionScore = precisionFactor * 100; // Standardized score up to 100
A comment in the same file says this is the formula of the older game Globdrop grew out of, generalised. The earlier version only knew Catalonia, and its 50 km constant is still the default threshold.
What we measured
We ran a fresh vite build on 24 September 2026, after that day’s changes to the game (great-circle distances, aliases for flag names, right-to-left Arabic). Before them the JavaScript was 68.7 KB, or 18.4 KB gzipped.
| Output | Minified | Gzipped |
|---|---|---|
| JavaScript (all code, all six languages) | 71.7 KB | 19.4 KB |
| CSS | 35.0 KB | 6.6 KB |
index.html | 7.5 KB | 2.7 KB |
Most of the JavaScript is text: src/engine/i18n.ts, with the interface strings for all six languages, is 19.5 KB of source on its own.
The map images are much bigger than the code. These are the file sizes in the repo, plus the transfer size for the SVGs as production serves them (Brotli-compressed, measured with curl the same day):
| Map | Format | File | Transferred |
|---|---|---|---|
| United States | SVG | 77 KB | 21 KB |
| Westeros | WebP | 127 KB | same |
| Mediterranean | SVG | 280 KB | 74 KB |
| Catalonia | WebP, lossless | 677 KB | same |
| Middle-earth | JPEG | 1.09 MB | same |
| One Piece | PNG | 1.66 MB | same |
| World | SVG | 1.84 MB | 754 KB |
| Europe | SVG | 2.54 MB | 553 KB |
So a round of World Capitals downloads roughly forty times as much map as code. We haven’t measured load times on real devices, so we don’t publish any.
Offline and caching
public/sw.js is a hand-written service worker. On install it caches five files (the page, the manifest, the favicon and the logo). After that, pages and anything under /data/ are fetched network-first and saved as they come in, and everything else (scripts, styles, maps) is served cache-first. A map you’ve played once is loaded from the cache the next time.
Languages
The interface is in Catalan, Spanish, English, Chinese, Arabic and Japanese. The chosen language is kept in localStorage (together with the theme and the last tab), and switching language reloads the page. Place names come from the scenario files. Flag names exist only in Catalan, Spanish and English, which has consequences we cover in Type-to-answer in six languages.
Tests, and what’s missing
There are 17 Vitest tests in three files: coordinate maths and scoring, the flag engine, and the share messages. The coordinate tests only exercise the Linear system. There is no CI: the repo has no workflow files, so the tests run when someone runs npm test.
Some things the game doesn’t have: zoom and pan on the map, a high-score table, or accounts. It does carry AdSense ads on its screens, and the page loads Google Tag Manager.
If you want to see all of this in one place, play a round. The bounding boxes and thresholds are explained in Calibrating a map image.