← All posts

Half a million map tiles, zero tile bills

Last Control: Reclaim is a mobile strategy game built by our team together with a fleet of AI agents. Its world is the actual Earth: you claim sectors of real cities, and every sector has a character taken from the real place. That means the game needs a map, and a map turns out to be a surprisingly awkward thing to get hold of for a small mobile game.

Commercial tile APIs bill per request, which means you pay for as long as anyone plays. The public OpenStreetMap tile servers are run by volunteers, and their tile usage policy rules out shipping an app on top of them. And for Godot there is no maintained vector-tile renderer we could trust on phones. So we made the map a build artifact: render the tiles once, offline, and serve them like any other static file.

From planet extract to PNG

Everything starts from the daily Protomaps build of OpenStreetMap. We never download the whole planet. The pmtiles tool extracts regions over HTTP range requests, so we only pull the bytes we need: the world down to zoom 8 is 560 MB, each of our 20 featured-city archives is between 29 and 275 MB, and the metro areas together come to 2.8 GB.

The style is generated from the Protomaps basemaps package into a MapLibre style, then recoloured to a “burnt” palette: no green, no blue, monospace labels. It should look like the game, not like a tourist map. The style build has to be byte-identical on every run, because we want a re-render next year to differ only where the world changed.

tileserver-gl runs in Docker on a workstation and renders 512 px tiles at 2x as PNGs. The fetcher around it is threaded, resumable and deduplicated, so a crash halfway through costs minutes rather than hours. Raw PNGs are big, so every tile goes through pngquant at quality 60 to 90. The result is about 21% of the raw size: the first 93k tiles went from 1.9 GB to 407 MB. A dense zoom-14 tile weighs around 65 KB; open sea is under 1 KB.

The last step before the cloud is a gate. Every planned tile must be present and optimized, there must be no extras, and the total must stay at or under 24 GiB. Only then does aws s3 sync run. One detail that mattered: raising the sync concurrency to 64. At the default of 10 we saw roughly 17 objects per second, which is a long afternoon for half a million files.

Pipeline diagram: Protomaps OSM extract, MapLibre style, tileserver-gl render, pngquant, upload gate, S3 and CloudFront, Godot client.Pipeline diagram: Protomaps OSM extract, MapLibre style, tileserver-gl render, pngquant, upload gate, S3 and CloudFront, Godot client.
The whole tile pipeline. The top panel runs once, offline.

No tile server at runtime

The tiles sit in S3 behind CloudFront at /tiles/z/x/y.png. That is the entire serving story. There is no tile server to patch, scale or wake up at 3 a.m., and the client learns its endpoints from a config file. The same CloudFront distribution also carries /api and the web pages, and the whole backend (Python Lambdas on arm64 plus DynamoDB) lives inside a monthly budget of $30. A verify step fetches 32 sample paths through CloudFront after each upload, so a broken deploy shows up in a script and not in a player’s review.

Deciding which half-million

The plan came to 504,542 tiles. The world at zoom 0 to 8 is 87,381. The 20 featured cities add about 6,074 at zoom 9 to 14. 300 metros at zoom 12 to 14 add 87,363. And every district that the game splits into plots (we call them towns) is covered at zoom 12 and 13, which is 323,724 tiles.

The first pass rendered 93,455 tiles in about eight minutes with zero failures. The metros were slower: roughly 30 minutes to render 87k tiles, about 3.5 hours of pngquant, and 40 minutes of S3 sync, at an average of around 28 KB per tile.

What we skipped matters as much. Zoom 9 to 11 for the whole world would be an estimated 5.5 million tiles and 25 GB of what we started calling blurry ash: mostly empty land at a scale where nothing is playable. Instead the client overzooms the zoom-8 tiles up to zoom 11, and when a tile is missing it draws the matching crop of the nearest ancestor. Players do not notice, and we do not store 25 GB of grey.

Where it broke

The towns layer was the painful one. Our first attempt was a single pmtiles extract with 47,623 rectangles. It never finished; it did not even complete a --dry-run in 20 minutes. A coarse zoom-10 region would have worked but meant a 25 GB archive for a few hundred megabytes of useful tiles. The fix was boring: 27 chunks of at most 2,000 rectangles each, about 20 seconds and 0.7 GB apiece, each writing a done-marker when it finishes.

The optimize step then stalled for 25 minutes inside an xargs loop. The markers are the contract between steps, so the fix was to feed the loop only PNGs that had no marker yet. The lesson we keep relearning: make every long stage resumable by design, and make the resume state a file you can look at.

The grid comes from data too

The game grid is derived from geography rather than drawn by hand. A geohash-5 district is either one plot or 32 geohash-6 plots. It splits when the GHSL population is at least 20,000 or at least half the land is urban. That is one bit per district, 128 bytes per geohash-3 cell, and 541 KB for the whole planet, with 154,263 districts split. The file is immutable, lives at a versioned path with a sha256 manifest, and is bundled into the Lambdas so there is no network fetch at cold start.

Each plot also gets an archetype, one byte, computed offline from OSM land use via Overture Maps, terrain, Köppen climate and GHSL, using a first-match rules table. That is how you end up with a drowned port, a quarry pit, a rail yard or a frozen relay. The drowned port even runs on the real M2 tide period of 44,700 seconds as a game mechanic. When one city came out at 69 plots against our estimate of 90, we left it. Real geography beats tuning to a number we made up.

Location is flavour, never a lock

The game never asks for GPS and never shows a permission prompt. At onboarding you pick a featured city, take “near you” from the CDN’s coarse viewer location (which is never logged), or tap any cell on the map. The server only trusts the CDN location header when an origin-verify secret proves the request really came through the CDN. A code review caught that the raw API endpoint would otherwise accept spoofed headers. Some places, Hiroshima and Chernobyl among them, are sealed as “Unsurveyed”, because a strategy game is the wrong place to play at them.

On the client, we chose Godot 4 with typed GDScript partly because scenes and scripts are plain text that agents can diff, and because it exports unattended from the command line. One shader draws the whole sector overlay, so there is no node per cell.

The honest trade-offs

  • Raster tiles cannot be restyled at runtime. A palette change means a re-render, not a config tweak.
  • The data goes stale. We keep the extracts so we can re-render, but nothing updates itself.
  • The cost did not vanish. It moved from per-request tile bills to a one-time offline render and a storage line item we can predict.
  • Licensing stays visible: the app shows “© OpenStreetMap contributors · Protomaps”. OpenStreetMap data is ODbL, Protomaps basemaps are BSD-3 code with CC0 design, and GHSL is CC BY 4.0.

What we’d tell you

  • If the map is not interactive data, make it a build artifact. Static files behind a CDN have no runtime to operate.
  • Decide what not to render. Skipping zoom 9 to 11 saved 25 GB and cost nothing a player could see.
  • Every long stage needs a resume marker and a gate. Chunk big jobs, and let the markers, not the process, hold the state.
  • Let real data decide the game design, and accept the numbers it gives you, even when a city lands at 69 plots instead of 90.

None of this is exotic. It is a pile of small, boring decisions, each one removing a thing that could page us later. Half a million tiles sounds like a lot until you notice that, once they are rendered, they are just files.

More posts →Start a project →