I wanted an animated QR code.
Not a QR code with a logo in the middle. Not a video of a QR code. An actual, printable code that you point a phone at, where a little character walks around inside the grid — and it still scans.
That's the whole problem in one sentence: anything you draw on top of a QR code is damage. And a QR code only tolerates so much damage.
So the real question was never "how do I draw an animation." It was: how do I animate inside the damage budget — or better, without spending any of it at all?
This is the story of how I got to "without spending any of it."
Why QR codes survive damage in the first place
A QR code is just a grid of black and white squares (the spec calls them modules). On top of your data, every QR code carries its own backup: Reed–Solomon error correction. Even if part of the code is dirty, torn, or covered, the decoder can reconstruct what was there.
That capacity comes in four levels. The strongest, Level H, can recover roughly 30% of the code.
Here's the detail that changes everything, and it's the detail most people miss:
Error-correction budget is counted in codewords, not modules. And one codeword is 8 modules.
// 1 codeword = 8 modules. The budget is counted in codewords.
const RECOVERABLE = { L: 0.07, M: 0.15, Q: 0.25, H: 0.30 };
function damageBudget(totalCodewords, level = 'H') {
return Math.floor(totalCodewords * RECOVERABLE[level]);
}
Sit with that for a second, because it cuts both ways:
- Damaging one module inside a codeword costs the same as damaging all eight of them. The decoder either reconstructs that codeword or it doesn't.
- Which means if you're going to damage a codeword anyway, you might as well damage it completely. Half-damage is wasted budget.
That single fact is the difference between a code that falls apart and a code that holds.
Attempt 1: punch holes in the code
My first approach was the obvious one. The light modules of a QR code already look like tiny white hallways. If I erase a few dark modules, I can connect those hallways into a maze the character can actually walk through.
It worked. Sort of.
Two problems:
- Every erased module can flip a codeword, and that spends budget. I could only erase so much before the code stopped decoding. In practice I could connect barely half the floor before running out.
- Some modules are not data at all. The three big square markers in the corners, the dotted timing lines, the format information — those are structural. Touch them and the decoder can't even find the code, let alone read it. They are permanently off-limits, no matter how much budget you have left.
So "erase dark modules" gives you a maze that's expensive, half-connected, and hemmed in by a skeleton you can't touch. Not great.
The shift: change the color, not the verdict
Then I stopped thinking about which modules I could erase and started thinking about what the decoder actually sees.
A QR decoder does not see color. It binarizes: for each module, is it closer to light or closer to dark? That's the entire input. Hue is irrelevant.
So there are two completely different ways to put something in a "dark" module:
- Flip its verdict (dark → light): that's damage. It costs budget.
- Change only its color, keeping it a "dark" module: the verdict is unchanged, so the codeword is unchanged, so it costs nothing.
// The decoder binarizes every module to "light" or "dark". Color is invisible to it.
// So the only thing that costs budget is changing the verdict — not changing the color.
const verdict = isDark(perceivedBrightness(color)) ? 'dark' : 'light';
// Erase a dark module -> verdict flips -> codeword damaged -> costs budget.
// Repaint a dark module -> verdict same -> codeword intact -> free.
So instead of punching holes, I repaint the dark modules I want to walk on. They stay "dark" to the decoder. To a human they now read as a slightly shaded walkable cell instead of a solid wall.
And because it's free, I stopped rationing. Every dark module I repaint is one more walkable cell, at no cost. The floor ends up fully connected — the character can reach every corner — while the code itself is untouched as far as any decoder is concerned.
I'm deliberately leaving out the exact brightness value. It's renderer-specific, and there's a real cliff: a shade that's still comfortably "dark", and one notch further and the binarizer flips it into genuine damage. You have to measure that line on your own output — I did, and the gap is narrower than you'd expect.
The result
- Repainted modules are "free," so the walkable floor is no longer a compromise. Full connectivity, zero budget spent.
- The character runs real, continuous routes through that floor — no teleporting between disconnected islands.
- The code decodes. That was the point, so it's the thing I check on every single export.
Two traps worth stealing
1. A JavaScript decoder is stricter than a phone camera.
I nearly threw away good designs because a JS decoding library said "no." The binarizer in these libraries works on a fixed pixel window, so a small bright overlay that a real camera handles fine gets misread at the library level. I only found out because I scanned the same codes with an actual phone — and they worked.
Lesson: don't let a JS decoder be the final judge of anything that involves overlays. It's great for validating the bare code. It's not the truth about a bright sprite sitting on top of it.
2. Verify the code, not the frame.
There are two different questions and it's easy to conflate them:
- Does this QR code decode? — checkable, reliable, and the one that actually matters before you let someone download or print it.
- What percentage of animation frames decode? — noisy, scary-looking, and not the real answer, because the character and collectibles sit on top of the code in those frames.
So my export gate is: decode-verify the base code (characters and effects removed). If that passes, the design is sound. The per-frame numbers are a diagnostic, not a verdict.
The boring parts that actually took the time
- Six character styles, each with its own path through the maze, so it doesn't look like the same animation recolored.
- GIF size. Encoding 260 frames naively gave me a ~3.5 MB file. Switching to incremental frames (each frame only carries the pixels that changed, everything else transparent) plus a single global palette brought it down to about 260 KB — and the rendered result is pixel-identical. One gotcha: the encoder writes a local color table whenever you hand it a palette, so you pass the palette only on the first frame.
- Never touching the structural modules. Finder patterns, timing lines, format info, alignment patterns: read-only forever.
What I'd tell someone building this
- Think in codewords, not pixels. The budget is per codeword, and a codeword is 8 modules. This changes what's "expensive."
- Free changes beat paid changes. Keeping the decoder's verdict and changing only appearance is free. Flipping the verdict is not. Design around that distinction and you can be far more ambitious.
- Never touch function modules, no matter how tempting the shape is.
- Test with a real camera, and gate downloads on the bare code decoding.
Animated QR codes are inherently less forgiving than plain ones — more moving parts, more ways to get it wrong — so if you build something like this, verify it on the devices and printers people will actually use.
I built this into QRacade, a free QR generator where every design is decode-verified before you're allowed to download it, and where you can drop a moving character inside the code. If you want to poke at the result rather than the theory, that's the place.
Scores, thresholds, and library behavior in this post are from my own testing; your renderer and your decoder may draw the line somewhere else. Measure it.

Top comments (0)