<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Bob Song</title>
    <description>The latest articles on DEV Community by Bob Song (@songsiyao).</description>
    <link>https://dev.to/songsiyao</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4191712%2Fb961f1b7-43cf-4f63-a9aa-7f51fd4b4da7.jpg</url>
      <title>DEV Community: Bob Song</title>
      <link>https://dev.to/songsiyao</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/songsiyao"/>
    <language>en</language>
    <item>
      <title>I put a playable game inside a QR code — and it still scans</title>
      <dc:creator>Bob Song</dc:creator>
      <pubDate>Sun, 18 Oct 2026 09:00:35 +0000</pubDate>
      <link>https://dev.to/songsiyao/i-put-a-playable-game-inside-a-qr-code-and-it-still-scans-4lbp</link>
      <guid>https://dev.to/songsiyao/i-put-a-playable-game-inside-a-qr-code-and-it-still-scans-4lbp</guid>
      <description>&lt;p&gt;I wanted an animated QR code.&lt;/p&gt;

&lt;p&gt;Not a QR code with a logo in the middle. Not a video &lt;em&gt;of&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;That's the whole problem in one sentence: &lt;strong&gt;anything you draw on top of a QR code is damage.&lt;/strong&gt; And a QR code only tolerates so much damage.&lt;/p&gt;

&lt;p&gt;So the real question was never "how do I draw an animation." It was: &lt;em&gt;how do I animate inside the damage budget — or better, without spending any of it at all?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is the story of how I got to "without spending any of it."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwajlj5fbnqi3y27o3nzr.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwajlj5fbnqi3y27o3nzr.gif" alt="An animated QR code with a character walking around inside it, and it still scans" width="451" height="451"&gt;&lt;/a&gt;&lt;/p&gt;
Point your phone at that. There's a character walking around inside the QR code — and it still scans.



&lt;h2&gt;
  
  
  Why QR codes survive damage in the first place
&lt;/h2&gt;

&lt;p&gt;A QR code is just a grid of black and white squares (the spec calls them &lt;strong&gt;modules&lt;/strong&gt;). 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.&lt;/p&gt;

&lt;p&gt;That capacity comes in four levels. The strongest, &lt;strong&gt;Level H&lt;/strong&gt;, can recover roughly &lt;strong&gt;30%&lt;/strong&gt; of the code.&lt;/p&gt;

&lt;p&gt;Here's the detail that changes everything, and it's the detail most people miss:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error-correction budget is counted in &lt;em&gt;codewords&lt;/em&gt;, not modules. And one codeword is 8 modules.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1 codeword = 8 modules. The budget is counted in codewords.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RECOVERABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;L&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.07&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;M&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;H&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.30&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;damageBudget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalCodewords&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;H&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalCodewords&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;RECOVERABLE&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sit with that for a second, because it cuts both ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Damaging &lt;strong&gt;one&lt;/strong&gt; module inside a codeword costs the same as damaging &lt;strong&gt;all eight&lt;/strong&gt; of them. The decoder either reconstructs that codeword or it doesn't.&lt;/li&gt;
&lt;li&gt;Which means if you're going to damage a codeword anyway, you might as well damage it &lt;em&gt;completely&lt;/em&gt;. Half-damage is wasted budget.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That single fact is the difference between a code that falls apart and a code that holds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt 1: punch holes in the code
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;It worked. Sort of.&lt;/p&gt;

&lt;p&gt;Two problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every erased module can flip a codeword, and that spends budget.&lt;/strong&gt; I could only erase so much before the code stopped decoding. In practice I could connect barely half the floor before running out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some modules are not data at all.&lt;/strong&gt; 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 &lt;em&gt;find&lt;/em&gt; the code, let alone read it. They are permanently off-limits, no matter how much budget you have left.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shift: change the color, not the verdict
&lt;/h2&gt;

&lt;p&gt;Then I stopped thinking about which modules I could &lt;em&gt;erase&lt;/em&gt; and started thinking about what the decoder actually &lt;em&gt;sees&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So there are two completely different ways to put something in a "dark" module:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flip its verdict&lt;/strong&gt; (dark → light): that's damage. It costs budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Change only its color&lt;/strong&gt;, keeping it a "dark" module: the verdict is unchanged, so the codeword is unchanged, so it costs &lt;strong&gt;nothing&lt;/strong&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The decoder binarizes every module to "light" or "dark". Color is invisible to it.&lt;/span&gt;
&lt;span class="c1"&gt;// So the only thing that costs budget is changing the verdict — not changing the color.&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;isDark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;perceivedBrightness&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dark&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;light&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Erase a dark module        -&amp;gt; verdict flips -&amp;gt; codeword damaged -&amp;gt; costs budget.&lt;/span&gt;
&lt;span class="c1"&gt;// Repaint a dark module      -&amp;gt; verdict same  -&amp;gt; codeword intact  -&amp;gt; free.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So instead of punching holes, I &lt;em&gt;repaint&lt;/em&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;strong&gt;fully connected&lt;/strong&gt; — the character can reach every corner — while the code itself is untouched as far as any decoder is concerned.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Repainted modules are "free," so the walkable floor is no longer a compromise. Full connectivity, zero budget spent.&lt;/li&gt;
&lt;li&gt;The character runs real, continuous routes through that floor — no teleporting between disconnected islands.&lt;/li&gt;
&lt;li&gt;The code decodes. That was the point, so it's the thing I check on every single export.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two traps worth stealing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. A JavaScript decoder is stricter than a phone camera.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Lesson: don't let a JS decoder be the final judge of anything that involves overlays. It's great for validating the &lt;em&gt;bare&lt;/em&gt; code. It's not the truth about a bright sprite sitting on top of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Verify the code, not the frame.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There are two different questions and it's easy to conflate them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Does this QR code decode?&lt;/em&gt; — checkable, reliable, and the one that actually matters before you let someone download or print it.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;What percentage of animation frames decode?&lt;/em&gt; — noisy, scary-looking, and not the real answer, because the character and collectibles sit on top of the code in those frames.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boring parts that actually took the time
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Six character styles&lt;/strong&gt;, each with its own path through the maze, so it doesn't look like the same animation recolored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GIF size.&lt;/strong&gt; 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 &lt;strong&gt;260 KB&lt;/strong&gt; — 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never touching the structural modules.&lt;/strong&gt; Finder patterns, timing lines, format info, alignment patterns: read-only forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd tell someone building this
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Think in codewords, not pixels.&lt;/strong&gt; The budget is per codeword, and a codeword is 8 modules. This changes what's "expensive."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Free changes beat paid changes.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never touch function modules&lt;/strong&gt;, no matter how tempting the shape is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with a real camera&lt;/strong&gt;, and gate downloads on the bare code decoding.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I built this into &lt;strong&gt;&lt;a href="https://qracade.com/" rel="noopener noreferrer"&gt;QRacade&lt;/a&gt;&lt;/strong&gt;, a free QR generator where the code itself 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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>javascript</category>
      <category>canvas</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
