Vladislav Ivanov's blog logo

Hello!

QR code is a very cool thing. But what if I told you that you can also play them. Interesting? Then let's go!

Today I'm gonna talk about how I wrote a Flappy Bird web game and put it in a QR code. The task is not as simple as it seems at first glance. As a challenge, I want the whole game to be contained inside the QR code and in order to play user only need the QR code. That is, the option with a link to the game inside the QR code is not suitable for me. It is not interesting!

Most often, when you scan a QR code, the data from the decoder gets into the search bar of your default browser. Of course, there are other use cases, but for my game, this scenario seems to be the most suitable. So, the goal is to run the source code of my game from the browser search bar. But is it possible? Yes, it can be implemented with data URLs. You can test this feature by simply taking any valid HTML code, encode it in base64, prefix it with data:text/html;base64, and paste the resulting string into the browser search bar.

To reach my goal, I just need to encode the string obtained using the algorithm described above into a QR code. It sounds quite simple, but there is one not very pleasant circumstance here: it's impossible to encode an infinite amount of data into a QR code.

Limitations

According to the specification, there are 40 QR code versions (1 - 40). Each version has its own configuration and number of dots (modules) that make up the QR code. Version 1 contains 21x21 modules, version 40 has 177x177. From version to version, the size of the code increases by 4 modules per side.

QR code versions comparison

Each version corresponds to a certain capacity. It's easy to guess that the more information that needs to be encoded, the higher version of the code is needed.

QR codes support several encoding modes:

ModeCharactersCompression
Numeric0, 1, 2, 3, 4, 5, 6, 7, 8, 93 characters are represented by 10 bits
Alphanumeric0–9, A–Z (uppercase only), space, $, %, *, +, -, ., /, :2 characters are represented by 11 bits
ByteCharacters from the ISO/IEC 8859-1 character setEach characters are represented by 8 bits
KanjiCharacters from the Shift JIS system based on JIS X 02082 kanji are represented by 13 bits

Also, QR codes have a special mechanism for increasing the reliability of storing encrypted information. For codes created with the highest level of reliability, up to 30% of the surface can be damaged or overwritten, but they will retain information and be correctly read. The Reed-Solomon algorithm is used to correct errors.

The table below shows the maximum number of storable characters in each encoding mode and for each error correction level.

ModeL (~7%)M (~15%)Q (~25%)H (~30%)
Numeric7089559639933057
Alphanumeric4296339124201852
Byte2953233116631273
Kanji181714351024784

The base64 alphabet contains 64 characters and is divided into groups:

  • Uppercase letters (indices 0-25): A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z
  • Lowercase letters (indices 26-51): a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, y, z
  • Digits (indices 52-61): 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
  • Special symbols (indices 62-63): +, /

In addition to these characters, the equal sign (=) is used for padding.

The appropriate encoding mode for base64 encoded data is byte. As for the error correction level, I think the M level will be enough, so I can expect to be able to encode 2331 characters, which is a little over 2 kilobytes. However, it should be noted that the base64 encoded string is about 30% longer than the source one.

Tools

For the more efficient development, it's necessary to optimize the process of generating a QR code. Also, it's necessary to perform some preprocessing of the source HTML code, namely minification, since I have a strict size limit.

For these purposes, a special NodeJS script was written:

javascript
import { minify } from 'html-minifier';
import { readFileSync, writeFileSync } from 'fs';
import { resolve, join } from 'path';
import { toFile } from 'qrcode';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';

const kb = (bytes) => (bytes / 1024).toFixed(2);

const __dirname = fileURLToPath(new URL('.', import.meta.url));

const input = readFileSync(resolve(__dirname, '../index.html'), { encoding: 'utf-8' });

console.log('Original size is', kb(input.length), 'KB', `(${input.length} bytes)`);

const minified = minify(input, {
  collapseWhitespace: true,
  minifyCSS: true,
  minifyJS: {
    mangle: {
      // variables declared at the top level 
      // are not minified by default
      toplevel: true
    }
  }
});

console.log('Minified size is', kb(minified.length), 'KB', `(${minified.length} bytes)`);

const outDir = resolve(__dirname, '../output');

execSync(`mkdir -p ${outDir}`);
writeFileSync(join(outDir, 'index.min.html'), minified);

const b64 = Buffer.from(minified).toString('base64');
const output = `data:text/html;base64,${b64}`;

console.log('b64 size is', kb(output.length), 'KB', `(${output.length} bytes)`);

writeFileSync(join(outDir, 'b64.txt'), output);
toFile(
  join(outDir, 'image.png'), 
  [{ data: output, mode: 'byte' }], // encoding mode
  { errorCorrectionLevel: 'm' } // errors correction level
);

console.log('Done!');

Thus, after running the script, I have a ready-to-use QR code at the output, as well as detailed information by code size, which will help me in further optimization.

Development

First, let's create a minimal valid HTML document:

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Flappy bird</title>
  </head>
  <body>
  </body>
</html>

I would like the game to have textures as similar to the original ones as possible. However, the game shouldn't depend on external resources, which means that there is no possibility to download any extras (images, styles, etc). So, I will have to program texture drawing. There are many ways to draw on the web: box-shadows, various gradients, SVGs, canvas, etc. I decided to choose canvas, because it's pretty easy to draw pixel by pixel and it's shorter in code than css or multiple divs:

html
...
  <body>
    <canvas id="c" width="360" height="640"></canvas>
  </body>
...

I created a DOM node and set id attribute. Now, in code, I can simply refer to it as window.c or just c and there is no need to use methods to find DOM elements in the document like querySelector or getElementById, whose names are actually quite long and cannot be minified. By the way, I would like to draw your attention to this. When using canvas, various methods of CanvasRenderingContext2D are used and it makes sense to assign these methods to variables, because minifier cannot minify the properties (and methods) of an object. But it must be remembered that to assign it's needed to call bind method to pass this context, which in this case is canvas rendering context. Here's what I'm talking about:

javascript
let ctx = c.getContext('2d');

ctx.fillRect(...)
ctx.fillRect(...)
ctx.fillRect(...)

After minification, it will turn into something like this (whitespace characters are retained for clarity):

javascript
let a = c.getContext('2d');

a.fillRect(...)
a.fillRect(...)
a.fillRect(...)

While this code:

javascript
let ctx = c.getContext('2d');

let fillRect = ctx.fillRect.bind(ctx);

fillRect(...)
fillRect(...)
fillRect(...)

Turns into something like this:

javascript
let a = c.getContext('2d'), b = a.fillRect.bind(a);

b(...)
b(...)
b(...)

Profit from such an assignment will be, but not always. For each individual case, it's needed to count the lengths of the strings. For example, for the fillRect method, such an assignment is useful if this function is called more than 2 times.

Of course, there are other ways to assign a function, for example:

javascript
let fillRect = (...args) => ctx.fillRect(...args);

But this option will be longer after minification compared to the code where bind is used. Therefore, I decided to use the code with bind, since I could not find an option that would be shorter.

By the way, let is used everywhere in the code, because it is 2 characters shorter than const.

Textures

I've already said that I would like to use textures as close to the original as possible for my game. For example, I would like to see such a bird in the game:

Bird texture

At the same time, there is no way to request pictures from anywhere, so I assumed that the easiest way would be to store images in string variables right in the source code. But how to fit pictures into 2 kilobytes, given that there is also a game logic code?

You can see that in order to draw the texture above, it's only needed 5 colors. While popular image formats such as JPEG or PNG allow millions of colors to be encoded, this is achieved by a fairly high color bit depth. For example, JPEG color images store 24 bits per pixel, and PNG up to 48 bits. 

I do not need such a color depth, which means that I can solve this problem by developing my own palette. Let's imagine that a letter of the Latin alphabet has been assigned to each color, then the image above will look like this:

text
uuuuuuaaaaaauuuuu
uuuuaacccabbauuuu
uuuaccccabbbbauuu
uuacccccabbbabauu
uaccccccabbbabauu
uaaaaacccabbbbauu
adddddacccaaaaaau
adddddaccaeeeeeea
uaaaaaccaeaaaaaau
uuaccccccaeeeeeau
uuuaacccccaaaaauu
uuuuuaaaaauuuuuuu

legend:
u - transparent
a - #000
b - #fff
c - #f71
d - #eec
e - #f20

The size of such a picture is 204 bytes. However, just looking at this picture shows a huge potential for compression! The first thing that comes to mind is RLE. Simply put, algorithm replaces repeated characters with a character + number of repetitions: `aaaaa` → `a5`. In the case of the sequence above, the compression works quite efficiently - 32.35%. The disadvantage of this approach is that for non-repeating sequences, the algorithm works the other way: `abcde` → `a1b1c1d1e1`, although this can be optimized: `abcdeeeee` → `-4abcd5e` or `abcdeee` → `abcd3e`.

Let's look at another way. I assumed that there would be no more than 16 colors in the palette, then I can achieve 50% compression for ANY data set without much effort. Each character must be assigned a code. And in the resulting string, write not the character itself, but the result of the expression:

javascript
char((code(color1) << 4) | code(color2))
// char - getting char by its code; code - getting code by char

Then in 8 bits I will store information about 2 pixels. But there is one caveat. Symbol codes must be offset by 2 (i.e. start at 0b10). Why? In the ASCII character table, control characters go up to code 32 (32 is space character). I haven't found a way to print them on a Mac, and I'm also not sure if they can be used in a JavaScript code. Most likely, it's possible, but escaping is required. In general, to solve this problem, it's enough to add an offset of 2, and then the result of the code function will always be a number greater than 32, and hence the printable character.

As a result, using the first algorithm (RLE with single character optimization), I got:

text
u6a6u9a2c3ab2au7ac4ab4au5ac5ab3abau3ac6ab3abau3a5c3ab4au2ad5ac3a6uad5ac2ae6aua5c2aea6u3ac6ae5au4a2c5a5u7a5u7

And as a result of the second:

text
ÌÌÌ333ÌÌÌÌÃ5U4CÌÌÌÃUU4DCÌÌÃUUSDCCÌÃUUU4D4<Ì335U4DCÌ6ff5U333ÃffcU7wwsÃ33U7333ÌÃUUU7ww<ÌÃ5UU33<ÌÌÌ33<ÌÌÌ

And the difference was only 6 bytes. Of course, I can still try to encode the result of the second algorithm with RLE, but next I have to decode all of this with the script code, which also takes some space, and I decided that RLE suits me perfectly.

By the way, one could try to encode the source string with the Huffman algorithm, and even calculate the Huffman tree in advance, but in this case it's rather difficult to control the non-appearance of control non-printable characters, and the decoder implementation code is quite cumbersome compared to the RLE decoder code or bit sequences.

Thus, textures have been created:

javascript
let birdTexture = `a6u8a2c3ab2au6ac4ab4au4ac5ab3abau2ac6ab3abau2ac7ab4au2ac8a6uac7ad6auac5ada6u2ac6ad5au3a2c5a5u6a5`;
let wing1Texture = `a4u2ae4auae5a2e5auae3au3a3`;
let wing2Texture = `a5uae5a2e5aua5u`;
let wing3Texture = `a5uae5a2e4auae3au3a4`;
let pipeTexture = 'g6h4g4h9h3g4h4g9g4f9f3';
let widePipeTexture = 'g3' + pipeTexture + 'f3';

After minification, the size of this code will be about 220 bytes, which is quite acceptable.

But for now, these are just variables of type string. To turn them into images on screen, It's needed to do certain actions:

javascript
let draw = (t, x, y, w, i = 0, pw = 2, ph = 2) => {
  while (t.length) {
    let c = t[0], times = +t[1]; 
    // I hope that there are no more than 9 repetitions
    // but if even more, then I just write a5a5 instead of a10
    fillStyle(c);
    t = t.slice(times !== times ? 1 : 2);

    if (c === 'u') i += (times || 1);
    else for (let until = (times || 1) + i; i < until; i++) fillRect(x + (i % w) * pw, y + ((i / w) | 0) * pw, pw, ph);
  }
}

So what's going on here? The function takes the texture (one of those strings that we discussed above) as the first argument. Next come the x and y coordinates - this is the drawing offset relative to the upper left corner of the canvas. Since the texture is represented by a string, it's needed to specify w (width), this sets the amount of pixels to be drawn before moving to the "next line". The i argument is used for the purpose of optimization - if the texture starts with empty pixels 'u', then they can be omitted, and the i counter value is started from the number of such pixels. It's like getting rid of trailing zeros in a number. pw, py - pixel width and height, respectively. Since most of the pipe looks like this:

I can call draw just once and specify ph = height of the pipe, instead of N draw calls, where N is the height of the pipe in pixels / ph. Made for performance.

Code

The whole game (like any other AFAIK) will be in an infinite loop:

javascript
let tick = 0;

let setup = () => {
  vSpeed = flyUpSpeed; // at the moment the game starts, the bird flies up
  score = playing = 0;
  y = 308; // place the bird vertically in the center
  pipes = [[100, gate()], [788, gate()]]; 
  // initialize the first two pipes,
  // more about pipes above
}

let render = () => {
  ...

  tick++;
  requestAnimationFrame(render);
}

setup();
render();

The first step, of course, is to draw the background. I just fill it with #0ac color. Next comes the drawing of pipes:

javascript
pipes.map(pipe => {
  pipe[0] -= hSpeed; // move pipes to the left every render

  let [px, py] = pipe;

  draw('a2' + pipeTexture + 'a2', px, 0, 64, 0, 1, height);
  fillStyle('a');
  fillRect(px - 3, py - 32, 69, 284);
  draw(widePipeTexture, px - 1, py - 30, 66, 0, 1, 28);
  draw(widePipeTexture, px - 1, py + 222, 66, 0, 1, 28);
  fillStyle('i');
  fillRect(px - 3, py, 69, 220);

  ...
})

The memory contains information about only two pipes, because it simply won't fit on the screen anymore. The game is designed so that when the pipe with index = 0 goes off the screen, it is removed, and the player gets a point:

javascript
if (px < -64) {
  score++;
  bestScore = score > bestScore ? score : bestScore; // ternary operator is shorter than Math.max
  pipes = [...pipes.slice(1), [pipes[1][0] + 284, gate()]];
}

An information about pipes is stored as an array of [x, y] tuples, where x is the distance from the left edge of the canvas to the beginning of the pipe, y is the distance from the top of the screen to the end of the top pipe (then comes gap, and then the bottom pipe starts). The y for each pipe is generated at the time of creation by the gate function:

javascript
let gate = () => (Math.random() * 292 + 64) | 0;

The distances are calculated in such a way that the player cannot crash into a pipe with an index other than 0, so the collision check is carried out for only one pipe (index = 0):

javascript
let [[px, py]] = pipes; // take coordinates of pipe with index = 0

if ((px < 198 && px > 98 && (y < py || py + 188 < y)) || (y < 0 || y > 616)) {
  // game over
  setup();
}

By the way, as you have already noticed, all the constants are calculated in advance, because this way the code takes up less space.

After the pipes, a bird is drawn. It always has the same x, but y changes every render:

javascript
let gravityAcceleration = .5;
let flyUpSpeed = -12;

vSpeed += gravityAcceleration;
y += vSpeed;

...

draw(birdTexture, 164, y, 16, 5);

// draw animated wings above the bird
let wingsPhase = ((tick / 4) | 0) % 4;

wingsPhase % 2 
  ? draw(wing2Texture, 162, y + 10, 7, 1) 
  : !wingsPhase
  ? draw(wing1Texture, 162, y + 6, 7, 1) 
  : draw(wing3Texture, 162, y + 12, 7, 1);

...

c.onclick = () => { 
  // fly up on click
  vSpeed = flyUpSpeed;
}

Lastly, text is drawn to tell the player what he is playing at all, how many points he has scored and show the game over screen:

javascript
fillStyle('b');
fillText(`Score: ${score} | Best: ${bestScore}`, 26);

if (!playing) {          
  fillText(played ? 'Game over' : 'Flappy bird', 210, 32);
  fillText('Click to play' + (played ? ' again' : ''), 430);

  !played && fillText('QR code edition', 230, 12, 246);
}

I showed you almost all the code, however, I still omitted some things, but you can find the full code of the project and even play around on the GitHub project page.

Building

After code is completed, I can start building. Thanks to the fact that I took care of the tools in advance, now I only need to run:

bash
npm i && npm run build

The script itself will create the output folder and put the result there, namely:

  1. Minified HTML;
  2. Data URL;
  3. QR code PNG image.

Also script prints size details:

text
Original size is 3.82 KB (3907 bytes)
Minified size is 1.53 KB (1569 bytes)
b64 size is 2.06 KB (2114 bytes)
Done!

And you can scan the QR code right now:

or copy+paste the data URL into your browser search bar:

text
data:text/html;base64,PGh0bWw+PGhlYWQ+PHRpdGxlPkZsYXBweSBCaXJkPC90aXRsZT48bWV0YSBuYW1lPSJ2aWV3cG9ydCIgY29udGVudD0id2lkdGg9ZGV2aWNlLXdpZHRoLGluaXRpYWwtc2NhbGU9MSxtYXhpbXVtLXNjYWxlPTEsdXNlci1zY2FsYWJsZT0wIj48L2hlYWQ+PGJvZHkgc3R5bGU9ImJhY2tncm91bmQ6IzAwMCI+PGNhbnZhcyBzdHlsZT0iaGVpZ2h0Ojk2JTtwb3NpdGlvbjpmaXhlZDt0b3A6NTAlO2xlZnQ6NTAlO3RyYW5zZm9ybTp0cmFuc2xhdGUzZCgtNTAlLC01MCUsMCkiIGlkPSJjIiB3aWR0aD0iMzYwIiBoZWlnaHQ9IjY0MCI+PC9jYW52YXM+PHNjcmlwdD5sZXQgaT1jLmdldENvbnRleHQoIjJkIiksdT0oaS50ZXh0QWxpZ249ImNlbnRlciIsYy5oZWlnaHQpLGU9e2E6IjAwMCIsYjoiZmZmIixjOiJmNzEiLGQ6ImYyMCIsZToiZWVjIixmOiIxNzAiLGc6IjFhMCIsaDoiMWQwIixpOiIwYWMifSxnPWkuZmlsbFJlY3QuYmluZChpKSx0PShhLGUsYz0xNix1PTE4MCk9PntpLmZvbnQ9YysicHggY291cmllciIsaS5maWxsVGV4dChhLHUsZSl9LG49YT0+aS5maWxsU3R5bGU9IiMiK2VbYV0sbD0iYTZ1OGEyYzNhYjJhdTZhYzRhYjRhdTRhYzVhYjNhYmF1MmFjNmFiM2FiYXUyYWM3YWI0YXUyYWM4YTZ1YWM3YWQ2YXVhYzVhZGE2dTJhYzZhZDVhdTNhMmM1YTV1NmE1IixmPSJhNHUyYWU0YXVhZTVhMmU1YXVhZTNhdTNhMyIscj0iYTV1YWU1YTJlNWF1YTV1IixvPSJhNXVhZTVhMmU0YXVhZTNhdTNhNCIsYj0iZzZoNGc0aDloM2c0aDRnOWc0ZjlmMyIsZD0iZzMiK2IrImYzIixoLG0scCxzLHYseD0wLHk9MCxrPTQsQT0uNSxhPS0xMixDPTAsRj0oKT0+MjkyKk1hdGgucmFuZG9tKCkrNjR8MCxSPShhLGUsYyx1LGk9MCx0PTIsbD0yKT0+e2Zvcig7YS5sZW5ndGg7KXt2YXIgZj1hWzBdLHI9K2FbMV07aWYobihmKSxhPWEuc2xpY2UociE9cj8xOjIpLCJ1Ij09PWYpaSs9cnx8MTtlbHNlIGZvcih2YXIgbz0ocnx8MSkraTtpPG87aSsrKWcoZStpJXUqdCxjKyhpL3V8MCkqdCx0LGwpfX0sUz0oKT0+e209YSxwPXY9MCxoPTMwOCxzPVtbNTA0LEYoKV0sWzc4OCxGKCldXX0scT0oKT0+e24oImkiKSxnKDAsMCwzNjAsNjQwKSx2JiYobSs9QSxoKz1tLHMubWFwKGE9PnthWzBdLT1rO3ZhclthLGVdPWE7UigiYTIiK2IrImEyIixhLDAsNjQsMCwxLHUpLG4oImEiKSxnKGEtMyxlLTMyLDY5LDI4NCksUihkLGEtMSxlLTMwLDY2LDAsMSwyOCksUihkLGEtMSxlKzIyMiw2NiwwLDEsMjgpLG4oImkiKSxnKGEtMyxlLDY5LDIyMCksYTwtNjQmJihwKyssQz1wPkM/cDpDLHM9Wy4uLnMuc2xpY2UoMSksW3NbMV1bMF0rMjg0LEYoKV1dKX0pLFtbZSxhXV09cyxlPDE5OCYmOTg8ZSYmKGg8YXx8YSsxODg8aCl8fGg8MHx8NjE2PGgpJiZTKCksUihsLDE2NCxoLDE2LDUpO3ZhciBhLGU9KHkvNHwwKSU0O2UlMj9SKHIsMTYyLGgrMTAsNywxKTplP1IobywxNjIsaCsxMiw3LDEpOlIoZiwxNjIsaCs2LDcsMSksbigiYiIpLHQoYFNjb3JlOiAke3B9IHwgQmVzdDogYCtDLDI2KSx2fHwodCh4PyJHYW1lIG92ZXIiOiJGbGFwcHkgYmlyZCIsMjEwLDMyKSx0KCJDbGljayB0byBwbGF5IisoeD8iIGFnYWluIjoiIiksNDMwKSx4KXx8dCgiUVIgY29kZSBlZGl0aW9uIiwyMzAsMTIsMjQ2KSx5KysscmVxdWVzdEFuaW1hdGlvbkZyYW1lKHEpfTtTKCkscSgpLGMub25jbGljaz0oKT0+e209YSx2PXg9MX08L3NjcmlwdD48L2JvZHk+PC9odG1sPg==

And play Flappy Bird QR code edititon! And the coolest thing is that the game does not need anything other than a QR code (well, device with a browser, of course).

And here's a screenshot of the resulting game. However, I recommend you to play, it's funny!

Thank you for reading!

⚠️ Alas, the standard iPhone camera does not understand the data URLs. It recognizes the QR code but says "no usable data found". I managed to recognize the code with the first application from the App Store, but in general there should be no problems with any other application.

🤓 At the very beginning, I told you about the versions of QR codes. So, resulting QR code version is 39.

References

  1. qrcode, viewed 12 Jan 2023 <https://www.npmjs.com/package/qrcode>
  2. QR Code Specification, viewed 12 Jan 2023, <https://www.labeljoy.com/qr-code/qr-code-specification/>
  3. QR code, viewed 12 Jan 2023, <https://en.wikipedia.org/wiki/QR_code>
  4. Data URLS, MDN, viewed 12 Jan 2023, <https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URLs>
  5. Base64 Characters, viewed 12 Jan 2023, <https://base64.guru/learn/base64-characters> 
Markdown supported

No comments yet