Every character in Medieval Boom — the multiplayer Bomberman clone I’ve been writing about — used to ship as five copies of itself. Same sprite, five team colors: blue, red, yellow, purple, black. Seven classes times five colors is thirty-five art folders. Thirty-five folders for what is, mechanically, seven characters.
This is the story of why I deleted four-fifths of them, and why adding three more team colors afterward made the game smaller instead of bigger.

The tax
Here’s the bill, measured off the actual shipped art:
| files | on disk | pixels | |
|---|---|---|---|
| today: blue reference only | 33 | 363 KB | 8.7 MP |
| before, at 5 colors | 165 | ~1.8 MB | ~43 MP |
| the old way, at today’s 8 colors | 264 | ~2.8 MB | ~70 MP |
363 KB is not going to bankrupt anyone. The point is the multiplier. Every new team color, every new class, every new animation — all of it times five (soon times eight). I wanted more team colors. The math was not encouraging.
How we got here (the honest part)
The first five classes — warrior, archer, lancer, monk, pawn — rode a tiny sword asset pack that shipped each character in all five colors. Free. I did nothing. Five colors appeared and I took the credit.
Then I added the fighter and the rogue. Custom art. AI-generated sheets. No five-color pack exists for those. So to get five team colors I had to write a recolor script per class — a Python pipeline that unified the art onto the warrior’s blue palette, then swapped the team shades out one by one. The fighter got one script. The rogue got another, and the rogue needed a special case because its native blue was dark and steely and kept getting classified as “outline” instead of “armor.”
It worked. I was proud of it. And it was, in hindsight, a trap: every future class would need its own bespoke pipeline, and the client would still download five copies of every sprite regardless.
The false start
The obvious fix was to keep the scripts and just ship their output — generate the color variants at build time, commit the PNGs, done. Authoring solved. I could add classes forever, as long as I kept writing recolor pipelines for each one.
But that solves the wrong problem. The scripts made authoring cheaper; the client still downloaded and decoded all five variants at preload. The payload — the thing I actually wanted to cut — was untouched. I’d optimized the build and left the download exactly as fat.
(And no, before you ask: you can’t just tint the sprite. The color ramps are hand-tuned — red’s highlight is peach, not light-red; the black team is a desaturated blue-gray, not gray times a tint. A one-color tint flattens all of that into a single hue and visibly changes every character.)
The actual fix
Here’s the thing I’d been too close to see: the recolor script was already doing the right operation. It was an exact, pixel-for-pixel palette swap. There are a handful of specific “team shade” colors in each sprite, and every other pixel — skin, leather, the outline, the glint on a sword — is structural and never changes between teams.
So instead of running that swap offline and shipping the result five times, I run it once at load. Ship the blue reference. At preload, take each blue sprite, find its team-shade pixels, and swap them to whatever color this match needs. Structural pixels pass through untouched. The blue reference is the source of truth; every other color is derived from it.
The whole mechanism is a lookup table and a loop:
// lut: per-team map of blue-reference shade -> target color (0x485884 -> 0x9a3939, etc.)
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i + 3] === 0) continue; // transparent: skip
const packed = (pixels[i] << 16) | (pixels[i + 1] << 8) | pixels[i + 2];
const target = lut.get(packed);
if (target !== undefined) {
// a team shade: swap it
pixels[i] = (target >> 16) & 255;
pixels[i + 1] = (target >> 8) & 255;
pixels[i + 2] = target & 255;
}
// anything else is structural (skin, leather, outline): untouched
}
That’s the entire trick. The table is seven shade keys — the union of every team-tinted color across all seven classes — each mapping to its hand-tuned equivalent in every team:

Phaser takes the swapped canvas and slices it into a spritesheet; the Vue avatar component turns it into a Blob URL. Same swap, two consumers, a few lines of glue each. The render pipeline — animations, setTexture, all of it — doesn’t change. Only where the pixels come from changes.
The payoff
Go back to the table. Today I ship 363 KB of unit art instead of 1.8 MB. At preload, only the blue reference gets fetched and decoded as a PNG — every other team is generated from those already-decoded blue pixels, no extra download, no extra PNG decode. That’s the load-time win: roughly five times less to fetch and decode (eight, at the color count I run now).
GPU memory is about the same — every team still ends up as a texture, just generated instead of downloaded. I’m not going to pretend that’s a win; it isn’t. The win is what comes over the wire and what the CPU has to decode, not what sits in VRAM.
And then there’s the part that actually motivated all of this. Once adding a team color meant writing one column in a seven-row table — twenty-one hand-picked hex values — I added three. Green, orange, pink. Zero new art. Zero new scripts. The payload went down as the color count went up.
The takeaway
If your variants differ by a known, finite set of colors, don’t ship the variants. Ship one reference and swap the palette at load. The technique is older than I am — it’s how every 8-bit console drew “the same sprite, different palette” out of four kilobytes of VRAM — and it holds up just as well when your constraint is download size instead of cartridge space.
The art stays hand-tuned. The bytes stay small. And the next time I want a twelfth team color, it’s a column, not a commission.
This work is part of Medieval Boom, a multiplayer Bomberman clone I’ve been building. The full devlog series starts with devlog #1.