Flappy Millennial: An HTML5 Canvas Game

Meet Flappy Millennial. He’s an av­er­age guy in his mid 20′s and the fu­ture looks bleak for him. With the threats of cli­mate change, mass-ex­tinc­tions, ris­ing au­thor­i­tar­i­an­ism, eco­nomic de­pres­sion, un­prece­dented wild­fires and so much more ever-loom­ing, ni­hilism has be­come his back­ground mu­sic.

But worry not. For he has an an­ti­dote—well, not so much an an­ti­dote as a seda­tive. He has his phone! Come help this Flappy Millennial ig­nore re­al­ity while he scrolls through Instagram or some­thing. You should play this game not be­cause it’s good or any­thing, but sim­ply so you too can pre­tend that things are okay for a while.

Preview of the game

Play the game on­line at ni­rav.com.np/​flappy-mil­len­nial.

Browse the source code on GitHub at ni­rav­codes/​flappy-mil­len­nial.

How it came to be

I be­gan writ­ing a dif­fer­ent game for HTML5 can­vas a month or so ago. As I was pro­gram­ming the game, I re­alised that a lot of my code was game ag­nos­tic. The abstractions and mod­ules that had or­gan­i­cally emerged could be pack­aged up and used to make other games. So I sep­a­rated the game logic from the lower level man­age­ment code, and lo! I had cre­ated my first game en­gine es­sen­tially as a side ef­fect of refac­tor­ing.

Over time, I be­gan to no­tice some prob­lems with the de­sign of the game en­gine API, and to in­ves­ti­gate fur­ther, I asked some of my friends to try us­ing it to make a game and re­port is­sues. But ap­par­ently they had their own work to do and “ain’t no­body got time for fun and games, we’re do­ing Serious Work”. So like a ne­glected grandpa weav­ing his own straw mat while his grand­daugh­ters make TikTok videos, I be­gan mak­ing Flappy Millennial.

But why another Flappy Bird clone?

Because I’m an un­o­rig­i­nal fuck.

But also be­cause Flappy Bird has an en­gag­ing game­play with a re­mark­ably sim­ple game me­chanic. It’s a child’s play to write a clone and for some rea­son even the worst clones turn out to be fun. To prove the as­ser­tion I wrote yet an­other flappy bird clone right here. Turns out just 60 lines of code and about an hour or two is enough to make a fun flappy clone. Completely playable and still he square be­low to fo­cus the game.

Once you’ve played this to your heart’s de­light, check out the JavaScript it took to make it work:

<style>
  #c:focus {
    outline: dashed 4px #000;
  }
</style>
<link
  href="https://fonts.googleapis.com/css2?family=Poiret+One&display=swap"
  rel="stylesheet"
/>
<canvas id="c" tabindex="1"></canvas>
<script>
  let snd = new Audio(
    "data:audio/wav;base64,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qJrx4KOQjahZxWbcZgztj2c49nKmkId44S71j0c8eV9yDK6uPRzx5X18eDvjvQ6yKo9ZSS6l//8elePK/Lf//IInrOF/FvDoADYAGBMGb7FtErm5MXMlmPAJQVgWta7Zx2go+8xJ0UiCb8LHHdftWyLJE0QIAIsI+UbXu67dZMjmgDGCGl1H+vpF4NSDckSIkk7Vd+sxEhBQMRU8j/12UIRhzSaUdQ+rQU5kGeFxm+hb1oh6pWWmv3uvmReDl0UnvtapVaIzo1jZbf/pD6ElLqSX+rUmOQNpJFa/r+sa4e/pBlAABoAAAAA3CUgShLdGIxsY7AUABPRrgCABdDuQ5GC7DqPQCgbbJUAoRSUj+NIEig0YfyWUho1VBBBA//uQZB4ABZx5zfMakeAAAAmwAAAAF5F3P0w9GtAAACfAAAAAwLhMDmAYWMgVEG1U0FIGCBgXBXAtfMH10000EEEEEECUBYln03TTTdNBDZopopYvrTTdNa325mImNg3TTPV9q3pmY0xoO6bv3r00y+IDGid/9aaaZTGMuj9mpu9Mpio1dXrr5HERTZSmqU36A3CumzN/9Robv/Xx4v9ijkSRSNLQhAWumap82WRSBUqXStV/YcS+XVLnSS+WLDroqArFkMEsAS+eWmrUzrO0oEmE40RlMZ5+ODIkAyKAGUwZ3mVKmcamcJnMW26MRPgUw6j+LkhyHGVGYjSUUKNpuJUQoOIAyDvEyG8S5yfK6dhZc0Tx1KI/gviKL6qvvFs1+bWtaz58uUNnryq6kt5RzOCkPWlVqVX2a/EEBUdU1KrXLf40GoiiFXK///qpoiDXrOgqDR38JB0bw7SoL+ZB9o1RCkQjQ2CBYZKd/+VJxZRRZlqSkKiws0WFxUyCwsKiMy7hUVFhIaCrNQsKkTIsLivwKKigsj8XYlwt/WKi2N4d//uQRCSAAjURNIHpMZBGYiaQPSYyAAABLAAAAAAAACWAAAAApUF/Mg+0aohSIRobBAsMlO//Kk4soosy1JSFRYWaLC4qZBYWFRGZdwqKiwkNBVmoWFSJkWFxX4FFRQWR+LsS4W/rFRb/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////VEFHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAU291bmRib3kuZGUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMjAwNGh0dHA6Ly93d3cuc291bmRib3kuZGUAAAAAAAAAACU="
  );
  let pnt = (r = 0.2) => {
    snd.playbackRate = r;
    snd.play();
  };
  let C = document.querySelector("#c");
  let W = (C.width = H = C.height = 350);
  let CC = C.getContext("2d");
  colH = S = P = 1;
  let pipe1, pipe2;
  {
    let rH = () => (Math.random() * 100 + 50) | 0;
    let pipe = (off) => {
      let x = off,
        h = rH();
      return (rst) => {
        if (rst) {
          x = off;
          colH = 0;
          return;
        }
        if ((x = x - 2) < -50) {
          x = W;
          h = rH();
        }
        x > 70 && x < 100 ? (colH = h) : 0;
        if (x == 20) {
          colH = 0;
          ++S;
          pnt(1);
        }
        CC.fillRect(x, 0, 50, h);
        CC.fillRect(x, H, 50, h - H + 140);
      };
    };
    pipe1 = pipe(W + 100);
    pipe2 = pipe(W + 300);
  }
  let y = (h = w = 30);
  (window.vy = 0), (ay = 0.4);
  let LP = (window.onload = () => {
    CC.fillStyle = "#235789";
    CC.fillRect(0, 0, W, H);
    if (P) {
      pt("Press any key to play", H / 2);
      pt("Score: " + S, H / 2 + 40);
      pipe1(1);
      pipe2(1);
      return;
    }
    CC.fillStyle = "#f1d302";
    y += vy += ay;
    CC.fillRect(70, y, w, h);
    if (colH && (y < colH || y + h > colH + 140)) {
      y = 30;
      P = 1;
      pnt();
    }
    y > H + h ? (P = 1) & (vy = 0) & (y = 30) & pnt() : 0;
    pipe1();
    pipe2();
    pt(S, 40);
    requestAnimationFrame(LP);
  });
  CC.font = "24px 'Poiret One'";
  var pt = (t, h) => {
    CC.fillStyle = "#fff";
    CC.fillText(t, (W - CC.measureText(t).width) / 2, h);
  };
  c.ontouchstart = c.onkeydown = (e) =>
    P ? (P = 0) & (S = 1) & LP() : (vy = -8);
</script>

That’s about 60 lines of javascript which makes a playable Flappy Bird game, com­plete with touch screen sup­port and sound ef­fects.

Now, that should have been the end of it. A game in 60 lines of code is good enough. But some­thing in me was beg­ging to take it even fur­ther. And as if to tempt me yet more, the power went out and with it the WiFi. What is a young man in this lock­down to do with­out the um­bil­i­cal cord of the Internet for­ever pump­ing numb­ness into his thoughts?

So then I went ahead and made it even smaller.

<canvas id="c"/><script>C=document.querySelector('#c');W=C.width=H=C.height=350
colH=S=P=1;y=h=w=30;vy=0,ay=.4;for($ in CC=C.getContext('2d'))CC[$[0]+($[6]||'')]
=CC[$];rH=_=>Math.random()*99+50;pp=(off)=>{ let x=off,ph=rH();return r=>{r?(x=off)
&(colH=0):0;(x=x-2)<-50?(x=W)&(ph=rH()|0):0;(x>70&&x<100)?colH=ph:0;
x==20?(colH=0)&++S&B(400):0;CC.fc(x,0,50,ph);CC.fc(x,H,50,ph-H+140);}}
p1=pp(W+100);p2=pp(W+300);LP=onload=_=> {CC.fillStyle="#258";CC.fc(0,0,W,H)
if(P){pt("Score "+S,H/2);p1(1);p2(1);return};CC.fillStyle="#fd0"
y+=vy+=ay;CC.fc(70,y,w,h);colH&&(y<colH||y+h>colH+140)?(y=30)&
(P=1)&B():0;y>H+h?(P=1)&(vy=0)&(y=30)&(B()):0;p1();p2();pt(S,40);
requestAnimationFrame(LP)};CC.font="24px serif"
pt=(t,h)=>{CC.fillStyle="#fff";CC.fx(t,(W-CC.me(t).width)/2,h)}
c.ontouchstart=onkeydown=(e)=>P?(P=0)&(S=1)&LP():vy=-8;
A=new AudioContext;B=_=>{o=A.createOscillator();o.frequency.value=_||999
o.start();o.connect(A.destination);setTimeout(_=>o.stop(),99)}</script>

Yeah, that’s right. It is 15 lines of hand­writ­ten HTML and javascript code that will make a flappy bird game in your browser. Pretty cool, huh? But line count is not a valid met­ric for this code. Here’s a proper one: this code is less than 970 bytes. That’s about a 100 times smaller than the code size of flappy mil­len­nial. And it still works! Of course, at that point I was left won­der­ing why my ac­tual Flappy Millennial code is so huge.

How?

The main game logic is in the js/game.js file. It be­gins by im­port­ing a bunch of classes and func­tions from the li­brary, in­clud­ing Character, Spritesheet, Collider, and StaticText. These classes ab­stract the mech­a­nism of an­i­mat­ing on the can­vas and check­ing for col­li­sions among other things. These things are very help­ful be­cause the can­vas API, while flex­i­ble, is very low level. For ex­am­ple, if you wanted to play an an­i­ma­tion us­ing the bare can­vas API, you’d have to con­vert your GIF into a se­quence of im­ages and man­u­ally draw each im­age while also tak­ing care of the tim­ing and po­si­tion­ing. Things like that don’t scale well as the game gets big­ger. With my li­brary, you can sim­ply say Character.runAnimation() and the rest is taken care of for you. The game is grad­u­ally im­ple­mented us­ing these prim­i­tives and very sim­ple javascript. A casual skim of the code in js/game.js on GitHub should be enough to get most im­ple­men­ta­tion de­tails.

I made most of the art for the game my­self, in­clud­ing our tit­u­lar char­ac­ter Flappy Millennial. It was made us­ing Piskel which is a great pixel art an­i­ma­tion soft­ware. It is­n’t as fea­ture-rich as I’d like but works well right in the browser and I love it for that.

animation of character

This is the an­i­ma­tion for our Flappy Millennial. I la­bored over it for a cou­ple of hours and I feel like it’s turned out okay. But if I had to start again, I’d definitely make the wings not look so stiff and card­board-like. The beau­ti­ful back­ground art that I’m us­ing was made by Vicente Nitti.

Build system

I’ve ac­quired the habit of writ­ing ES6 javaScript by de­fault be­cause I do so much work with React, Vue and NodeJS. That’s gen­er­ally a good thing, but it also means that if I want my javascript code to work on most browsers, I’ll have to use ex­ter­nal tool­ing. That was the case with Flappy Millennial.

So I had to put to­gether a set of tools to con­vert my mod­ern javascript di­alect to stan­dard javascript un­der­stood by most browsers. Without this process, my code would­n’t work on cur­rent mo­bile browsers and slightly out­dated or ex­tended sup­port edi­tions of desk­top browsers. It is­n’t a par­tic­u­larly dif­fi­cult thing to do, be­cause these tool mak­ers have al­ready put in all the ef­fort.

First we pass our javascript through Babel with plu­g­ins that con­vert to ES5 javascript. Then out­put is then passed to Browser­ify, which reads all the imports and requires, and puts all the code to­gether in a sin­gle, huge file. That file is fi­nally passed to Google clo­sure com­piler, which mini­fies the code and in our case, cre­ates a file that’s half the size of the un­mini­fied code but with the ex­act be­hav­ior.

I could have used some­thing like Gulp to cre­ate this pipeline, but this time it is­n’t too com­pli­cated so a sim­ple bash script did the trick.

#! /bin/bash
echo "Rebuilding Game"
rm -rf dist
mkdir -p dist/js
cp index_dist.html dist/index.html
cp favicon.ico dist/favicon.ico
echo "Copying Assets"
cp -r assets dist/
echo "Transpiling with Babel"
npx babel js/library -d dist/js/library
npx babel js/game.js -o dist/js/game.js
npx babel js/LOGSCORE.js -o dist/js/LOGSCORE.js
echo "Browserifying"
npx browserify dist/js/game.js -o dist/js/game_browserify.js
echo "Cleaning up"
rm -rf dist/js/library
echo "Minifying"
npx google-closure-compiler --js=dist/js/game_browserify.js --js_output_file=dist/js/game.js --jscomp_off=checkVars
rm dist/js/LOGSCORE.js
# rm dist/js/game_browserify.js
echo "Done! (ignore the warnings)"

Azure and Analytics

One of the tan­gen­tial goals of this pro­ject was to set up a sim­ple an­a­lyt­ics sys­tem that logs what score you make and when you make it. With that data I can look for in­ter­est­ing pat­terns like how many times the av­er­age player plays the game be­fore rage quit­ting, or the global high score, or if play­ers come back to play a sec­ond time.

Side note: You don’t have to worry about pri­vacy. The data I col­lect is to­tally anony­mous and ridicu­lously harm­less. I’ll even­tu­ally pub­lish in­ter­est­ing find­ings from the data once I have enough of it.

So any­way, to make that sys­tem, I chose to use Azure Functions. Azure Function (and its equiv­a­lent from other cloud providers) is an awe­some ser­vice that makes back­end de­vel­op­ment, de­ploy­ment and scal­ing so much eas­ier. I’ve fallen in love with it and all the pos­si­bil­i­ties it rep­re­sents.

A cloud func­tion is es­sen­tially some code that trig­gers when some event hap­pens (say a HTTP re­quest, or a data­base mod­i­fi­ca­tion), does some pro­cess­ing, and cre­ates some out­put (a HTTP re­sponse, a data­base en­try mod­i­fi­ca­tion). In my case, every time the player dies, his score is sent to a cloud func­tion that does some ver­i­fi­ca­tion and records the score in a CosmosDB data­base. All in all, it took about 10 lines of code and some ba­sic con­fig­u­ra­tion. To do the same thing with a vir­tual ma­chine, I’d have to in­stan­ti­ate a new VM and a new disk to go with it, con­fig­ure the fire­wall, in­stall all the soft­ware, scp lo­cal files to the VM, npm install and wait, pro­vi­sion https cer­tifi­cates, and just so much more.

But was it a smooth ride? Nope.

I ac­tu­ally have a lot of bad things to say about Azure’s user ex­pe­ri­ence and I’m very tempted to say them. In fact there are a bunch of para­graphs just sit­ting here in the drafts wait­ing to be un­leashed. But they’ll have to wait a lot longer. I need to ex­plore Azure a lot more to ei­ther ar­tic­u­late my opin­ions prop­erly or to be proven wrong.

First few hours

This is the data col­lected in the first few hours af­ter I pub­lished the game on GitHub. So far the high­score is 27. Think you can do bet­ter? Go play the game at ni­rav.com.np/​flappy-mil­len­nial and prove it. I won’t know who you are, but I’ll know you’re a badass.