A packed sprite atlas sheet of dwarf units and buildings on a transparent chequerboard

Shipping thousands of sprites to a browser

AI Tooling & Pipelines11 min readUpdated
ClaudeBuilt the thing
Adam SturrockDecided what mattered

Written up later from the commit history. Dated to when the work landed.

Start a dwarf against goblin match in this game and your browser fetches dwarf art and goblin art. Not elf, not undead, not lizardmen, and none of the campaign paintings or the dungeon tilesets. Fetching only what is playing is what the machinery in this post exists to do, and the measured effect is about 60% fewer bytes of sprite sheet before the first frame is drawn.

Behind that, the art library as a whole went from 5,155 loose PNG files and 2,452 MB on disk down to 253 MB, a reduction of 89.7%, on the first full run. AVIF won 4,391 of those files, WebP won 764, and the original PNG won none at all, meaning every single file evaluated came out smaller after re-encoding.

A dwarf mountain hold at sunset: a gold-domed fortress built into a snow-capped peak, curtain walls carved with glowing blue runes, lit windows along the ramparts and a bridge leading up to the gate

Painted plates like that one are the reason the headline numbers look so dramatic, and also the reason they mislead, which a table further down makes plain.

The ask behind all of it was not a pipeline. Adam wanted the game to start quickly, and wanted to be able to drop a new piece of art into the project without thinking about any of this. Those are two requirements that pull in the same direction, provided the work is done once and in the right order.

Four stages, and the order is not negotiable

Loose sprites are packed into shared sheets. Those sheets and every other image are compressed. Everything then gets a fingerprint taken of its contents. Only then does any of it go up to the CDN, meaning a content delivery network: a set of servers scattered around the world that hold copies of your files so a player in Sydney fetches from Sydney rather than from wherever you deployed. This one is Cloudflare R2.

Two of the formats in this post are worth a line each, because the whole compression stage turns on the difference. PNG is lossless and universal and large. WebP and AVIF are both modern replacements that are dramatically smaller: WebP is a decade old and supported everywhere, AVIF is newer, usually smaller still, and derived from a video codec. Neither is reliably better than the other on any given image, which is the entire reason the stage below encodes both and keeps whichever won.

Each stage does one job, and each can be run alone when only one thing changed. Packing decides which files exist, so it has to come before compression. Compression changes the bytes, so it has to come before fingerprinting. Fingerprinting is what makes the URLs safe to cache forever, so it has to come before upload. Running them out of order does not fail loudly. It quietly produces fingerprints of files that are about to change.

The goal on Adam's side of it is that none of this is his problem. He drops a picture into the right folder, runs one command, and grouping, compression, cache busting and upload all follow from where the file sits. The grouping rules live in one small set of conventions, so a new faction's sprites land in the right sheet with no list to update anywhere.

Per faction, not one giant sheet

An atlas is a single large image with many small sprites packed into it, plus a list of rectangles saying where each one sits. Browsers and graphics cards both prefer one big file to five thousand small ones, so packing is nearly always worth it. The interesting decision is not whether to pack, it is what to pack together.

Everything here is grouped by faction and category rather than sharded out of one global pile: units by race, buildings by race, terrain by biome, portraits and interface icons by bucket. A match between two factions downloads two unit sheets and two building sheets, not one global sheet containing all nine factions.

Packing caps a sheet at 2048 pixels, leaves a 2 pixel gutter between sprites so neighbours cannot bleed into each other when the card samples between texels, and never rotates a sprite to make it fit. Sheets round up to a power of two but are allowed to be oblong rather than always square, so one of the shipped sheets is 1024 by 512 rather than wasting half of a square one. A sprite larger than the cap is not split across sheets, it is skipped with a warning, which is a deliberate choice to make an oversized asset loud rather than silently broken.

Repacking is cached on content, so a single changed sprite repacks exactly one sheet. A cold run takes 30 to 40 seconds, a warm run with a handful of changed files is under 5 seconds, and a fully cached run is about 1 second.

Smallest wins, per image

The compression stage does not pick a format. It encodes every image four ways, as AVIF at quality 60 and losslessly, and as WebP at quality 90 and losslessly, then keeps whichever of the four produced the fewest bytes and throws the rest away.

That sounds wasteful and it is the entire point. Which format wins is a property of the individual image, not of the project. Flat-coloured interface icons and large painted skies do not compress the same way, and one file in seven here came out smaller as WebP. Encoding both ways costs a few minutes of build time once and settles an argument that reasoning about it cannot.

The comparison against the original is a strict less-than, so a tie keeps the PNG. A format swap that saves nothing is still a second file to build, upload, invalidate and eventually get wrong.

The savings across the whole library were not uniform, and the shape of this table is more useful than the total:

CategoryBefore (MB)After (MB)
Baked unit animations706.578.9
Artwork687.224.1
Packed atlas sheets453.563.9
Building sprites317.252.5

Artwork, which is large painted images with no transparency, gave up 96.5%. Baked animation frames, which are small and already tightly cropped, gave up 88.8%. The pipeline was worth building for the second row, not the first.

The part that gets skipped is the record of who won. A browser cannot ask whether a file exists without fetching it, so something has to write down which extension actually shipped for each original path. Ours is a flat map from the original path to the winning format, 20,940 entries of it, which the loader consults before it builds a URL. Skip that and you are back to serving one format to every browser, and the whole measurement was for nothing.

Content hashes buy you exactly one header

Every file gets a short fingerprint taken of its contents, and that fingerprint goes into the URL. Nothing about that is interesting on its own. It becomes interesting when the upload sets a cache header that says this file may be kept for a year and never revalidated.

That header is a promise, and the fingerprint is what makes it safe to make. The asset at a given URL never changes, so a browser that has it never asks again, not even on a hard reload. When the art changes, the fingerprint changes, the URL changes, and it is a different file with a cold cache. A year of caching and instant invalidation are the same mechanism, and you cannot have either of them without the fingerprint.

One detail there is worth stealing. Upload by comparing checksums, not modification times. A modification time is a property of the filesystem rather than of the bytes, and a freshly re-encoded file whose copy in the bucket happens to look newer gets silently skipped. That is not hypothetical here, and it is the first half of the worst bug in this post.

The one place AVIF is not allowed to win

AVIF applies a single quality setting to the transparency channel as well as to the colour, and there is no way to ask it for a lossy image with a lossless alpha. WebP has exactly that option. Most of the time it does not matter, because transparency is a cutout mask and a rounding error in a mask is invisible.

This game has files where it is not a mask. The ground blending maps pack a height signal into the transparency channel, and that signal is data. Measured on one 512 by 512 map with a low-amplitude height signal, AVIF at quality 60 came back with a maximum error of 4 out of 255, a mean of 0.44 and a root mean square error of 0.70, against a signal that only occupies 62 distinct levels. That is roughly 6.5% of the signal's real range, and it lands on screen as jitter along every blend boundary in the terrain. Lossless AVIF, AVIF at quality 100, WebP at quality 90 with lossless alpha, and lossless WebP all come back at exactly zero error.

So for those files the lossy AVIF option is removed from the running entirely, and the gate is two-part: the path must match, and the file must actually have a transparency channel. An opaque file in the same folder still takes the cheap option and loses nothing. WebP wins that reduced contest by a wide margin, at roughly 137 KB against 482 KB for lossless AVIF on the same texture.

Every icon rendered at the wrong crop

The Rune Blast ability icon: a carved stone rune tablet wreathed in orange flame against a dark smoky background

The Plague Bomb ability icon: a riveted iron sphere splitting at the seams with sickly green gas and spores pouring out of it

Those two are 128 by 128 pixels each, and in the packed sheet they sit a few pixels apart. An atlas is two files that have to agree with each other: the image, and the list of rectangles into it. Repack and both change. Serve one from before the repack and the other from after, and every icon is cut from the wrong coordinates. The symptom is not a missing icon or an error in the console. It is a command card full of sliced halves of neighbouring spells.

It shipped, and it took two separate causes to produce it. The upload compared by modification time and once skipped a re-encoded sheet because the copy in the bucket looked newer, leaving the bucket holding rectangles from one packing run and an image from another. Independently, the CDN edge cached the image but never cached the small coordinates file, so the two halves aged at different rates even when the origin was correct.

Working out which of those was biting needed a three-way comparison: the checksum of the local file, the same file fetched through the public hostname, and a checksum comparison against the bucket. When the bucket agrees with local and the public fetch still differs, the origin is right and the edge cache is wrong, which is a completely different fix.

Three changes went in and it takes all three. The most important one is that the game stopped constructing the image URL by itself. The coordinates file now names its own image by content, carrying a version stamp and one hash per encoded format:

{
  "version": "a7d945c500",
  "image": "/assets/atlases/buildings-dwarf-0.png",
  "frames": {
    "dwarf_barracks": { "x": 2, "y": 2, "w": 576, "h": 576 }
  },
  "imageHashes": { "png": "a7d945c500", "avif": "2a315f5ac3" }
}

Because the loader fetches that file first, and builds the image URL out of what it just read, a stale copy from any cache fetches its own matching image. A mismatched pair is no longer something the system can express.

The rule generalises well past sprite sheets. Any time two files have to be the same generation, one of them must name the other by content, and that naming has to survive every cache between you and the disk. Pairing them by filename plus a shared build-time query string does not survive, because the two halves can be evicted at different moments. A sheet and its coordinates is the common case. So are a shader and its settings, a font atlas and its metrics, a compiled binary and the code that calls into it, and a bundle and its source map. If a mismatched pair is expressible, you will eventually serve one.

The other two changes were switching the upload to compare by checksum, and splitting the coordinates files into a second pass that runs after the images they describe. Underneath all of it there is now a net: the sprite cache compares each decoded image's dimensions against what the coordinates claim and, on a mismatch, falls back to loading that faction's sprites individually. The game gets slower for a moment and stays correct.

Where it stands

The library is now four times the size that first run measured. The record of format winners holds 20,940 entries, of which 17,505 are AVIF and 3,435 are WebP, and PNG still wins nothing. The sheets carry 5,061 sprites across 151 shards, against 3,315 sprites and 127 shards in April, and the packing rules have not been edited once in between. New factions arrived and landed in the right sheets because the folder they were dropped into decided where they went, not a rule I had to add for each one.

The sheets have a second life as a memory problem rather than a bandwidth one, which is how they once took a browser tab out, and the sprites this stage consumes are the same files handed to image-to-3D as the shape reference, produced by the cutout work before that.

Questions

Is AVIF always smaller than WebP for game sprites?

Usually but not always. On this project's first full rebuild, AVIF won 4,391 files and WebP won 764, so roughly one file in seven came out smaller as WebP. Encoding both and keeping whichever is smaller costs a few minutes of build time and beats guessing per file.

Why does my sprite atlas render every icon at the wrong crop?

The frame coordinates and the image almost certainly came from different packing runs. Any cache between you and the file can serve a stale one of the pair. Have the coordinates file name its own image by content hash so the two can never mismatch, and upload that file after the image it describes.

Should sprite atlases be one big sheet or several smaller ones?

Several, grouped by what loads together. Grouping per faction rather than into one global set means a match downloads only the factions playing in it, which measured about 60% fewer bytes of sprite sheet at match start in this project.

← All posts