DEV Community

Cover image for Build a tiny game for JS13K with Kontra.js
Lee Reilly for GitHub

Posted on • Updated on

Build a tiny game for JS13K with Kontra.js

Do you think you can build a game using less than 13kB of JavaScript, CSS, and/or HTML in just 30 days? Do I have a challenge for you!

The 2021 JS13K competition organized by GitHub Star @end3r just kicked off with the announcement of the theme SPACE.

You can interpret that theme however you want - recreate classic Space Invaders or Asteroids-style games, make a game that's only controllable with the SPACE bar, build a game where you explore the space between two objects, or whatever else you can imagine. Just don't run out of space - you only have 13kB to work with ๐Ÿ˜‰

If you've never done anything like this, or even coded much JavaScript before, it can be a little intimidating. Here's a quick little tutorial how to build this suh-weeet game using Kontra.js (a tiny game library made just for JS13K) plus a few lines of code:

Play the game, view the source, or follow along with the steps and corresponding diffs below.

1. Generate your HTML template

If you're a regular reader of DEV then it's likely you won't need much help with this, but let's start off with a super-simple HTML template:

<!DOCTYPE html>
<html>
  <head>
  </head>
  <body>
    <canvas width="250" height="250" id="game" style="background-color: black;"></canvas>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Looking at that in your browser, you should see a โฌ› - our play area.

๐Ÿ’พ Source + diff for end of step 1

2. Include Kontra.js library

To keep things simple, we'll just pull the latest version of Kontra from a CDN and include the functions / helpers we know we'll be using after the </canvas> tag:

<script src="https://cdn.jsdelivr.net/npm/kontra@7.1.3/kontra.min.js"></script>
<script>
  let { GameLoop, Sprite, bindKeys, collides, init, initKeys, keyPressed, randInt } = kontra;

  let { canvas } = init();
</script>
Enter fullscreen mode Exit fullscreen mode

๐Ÿ’พ Source + diff for end of step 2

3. Ready player one!

First, let's define an image for player 1 after let { canvas } = init();. We'll use my GitHub avatar for quickness / ego boosting:

let image1 = new Image();
image1.src = 'https://avatars.githubusercontent.com/u/121322?v=4'
image1.width = 40;
image1.height = 40;
Enter fullscreen mode Exit fullscreen mode

Next, we'll create our sprite and position it on the top left of the screen:

let sprite1 = Sprite({
  x: 40,
  y: 40,
  anchor: {
    x: 0.5,
    y: 0.5
  },
  image: image1
});
Enter fullscreen mode Exit fullscreen mode

Now we'll define our game loop and start things ticking!

let loop = GameLoop({
  render: function() {
    sprite1.render();
  }
});
Enter fullscreen mode Exit fullscreen mode

If you view your game in the browser now, you should see my avatar in a big black square. Woo hoo - progress!

๐Ÿ’พ Source + diff for end of step 3

Wait! Where did that image URL come from? How can I use my own? You can grab that avatar URL easily from the GitHub API e.g.

$ curl -s https://api.github.com/users/leereilly | jq -r '.avatar_url' 
https://avatars.githubusercontent.com/u/121322?v=4
Enter fullscreen mode Exit fullscreen mode

or

$ curl -s https://api.github.com/users/leereilly | grep -i avatar_url
  "avatar_url": "https://avatars.githubusercontent.com/u/121322?v=4",
Enter fullscreen mode Exit fullscreen mode

Dunno about you, but here's what I feel like every time I run curl or jq commands against the GitHub API in a terminal:

Anyway, I digress. Looking at a static sprite on a black square isn't a whole heck of a lotta fun, so let's get moving!

4. Make player 1 move

Let's introduce an update() function within our game loop that responds to โ†‘ โ†“ โ† โ†’ and moves our sprite appropriately:

update: function() {
  if (keyPressed('left')) {
    sprite1.x = sprite1.x - 1;
  }

  if (keyPressed('right')) {
    sprite1.x = sprite1.x + 1;
  }

  if (keyPressed('up')) {
    sprite1.y = sprite1.y - 1;
  }

  if (keyPressed('down')) {
    sprite1.y = sprite1.y + 1;
  }
},
Enter fullscreen mode Exit fullscreen mode

We also need to add a call to initKeys(); just before loop.start();:

initKeys();

loop.start();
Enter fullscreen mode Exit fullscreen mode

You should now be able to move player 1 around the screen ๐Ÿ•น๏ธ

๐Ÿ’พ Source + diff for end of step 4

5. Introduce the enemy

We can definitely make this game more fun. Let's add our enemy player - my buddy @mishmanners* - somewhere randomly, but not outside the bounds of the screen.

* this has nothing to do with Michelle kicking my butt at Fornite, Magic The Gathering, and snake building / battling amongst other things.

We'll start by defining the maximum X and Y values for our sprite (basically the canvas dimensions) and then make use of Kontra's randInt() helper to set the sprite's location:

let maxX = 250;
let maxY = 250;

let image2 = new Image();
image2.src = 'https://avatars.githubusercontent.com/u/36594527?v=4'
image2.width = 40;
image2.height = 40;

let sprite2 = Sprite({
  x: randInt(0, maxX),
  y: randInt(0, maxY),
  anchor: {
    x: 0.5,
    y: 0.5
  },
  image: image2
});
Enter fullscreen mode Exit fullscreen mode

๐Ÿ’พ Source + diff for end of step 5

6. Add some collision detection

This is where your university-level math knowledge will come in handy.

Just kidding. This sounds pretty intimidating, but thankfully Kontra does all of the hard work for us with the collides() helper. Let's just move the player 2 sprite to a random position once there's a collision by adding the following at the end of the update() function:

if (collides(sprite1, sprite2)) {
  sprite2.x = randInt(41, maxX - 40);
  sprite2.y = randInt(41, maxY - 40);
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿ’พ Source + diff for end of step 6

7. Make it pixelated/8-bit with this one neat trick!

This tip hack to make your sprites look pixelated is pretty easy. Since we're using the GitHub Avatar URL, we can change the query param from v=4 to s=10 to request a 10x10 pixel version.

- https://avatars.githubusercontent.com/u/121322?v=4
+ https://avatars.githubusercontent.com/u/121322?s=10
Enter fullscreen mode Exit fullscreen mode

Since we're setting the image to 4 times that in the code, the browser will attempt to resize it making it look pixelated.

Before and after

MAGIC

Note: There are definitely more sophisticated techniques, and using images this big is a horrendous idea for JS13K. It's better to use something like Aseprite or Piskel to create your own pixel art.

๐Ÿ’พ Source + diff for end of step 7

8. Add some sounds effects

There isn't much room for OGGs and MP3s in JS13K. Thankfully, people smarter than I have developed some neat libraries and editors where you can create your sound effects and background music to include with just a few lines of code.

Taking @xem's MiniSoundEditor as just one example, I can select from some predefined sounds and just copy and paste the JavaScript.

I'll do just that and copy and paste this at the end of the if (collides(sprite1, sprite2)) block:

f = function(i){
  var n=2e4;
  if (i > n) return null;
  var q = t(i,n);
  i=i*0.7;
  return (Math.pow(i*50,0.8)&66)?q:-q;
}

t=(i,n)=>(n-i)/n;
A=new AudioContext()
m=A.createBuffer(1,96e3,48e3)
b=m.getChannelData(0)
for(i=96e3;i--;)b[i]=f(i)
s=A.createBufferSource()
s.buffer=m
s.connect(A.destination)
s.start()
Enter fullscreen mode Exit fullscreen mode

I literally have no idea what that does, but I feel smarter having copied and pasted it. You will too. Try copying and pasting that (or your own sound) at the end of the collision detection code.

โš ๏ธ Obviously don't blindly copy, paste and use code blindly off the Internet if you don't know what it does. Thankfully, this is harmless.

By now, your code should look a little something like this:

<!DOCTYPE html>
<html>
  <head>
  </head>
  <body>
    <canvas width="250" height="250" id="game" style="background-color: black;"></canvas>
  </body>
  <script src="https://cdn.jsdelivr.net/npm/kontra@7.1.3/kontra.min.js"></script>
  <script>
    let { GameLoop, Sprite, bindKeys, collides, init, initKeys, keyPressed, randInt } = kontra;

    let { canvas } = init();

    let maxX = 250;
    let maxY = 250;

    let image1 = new Image();
    image1.src = 'https://avatars.githubusercontent.com/u/121322?s=10'
    image1.width = 40;
    image1.height = 40;

    let sprite1 = Sprite({
      x: 40,
      y: 40,
      anchor: {
        x: 0.5,
        y: 0.5
      },
      image: image1
    });

    let image2 = new Image();
    image2.src = 'https://avatars.githubusercontent.com/u/36594527?s=10'
    image2.width = 40;
    image2.height = 40;

    let sprite2 = Sprite({
      x: randInt(0, maxX),
      y: randInt(0, maxY),
      anchor: {
        x: 0.5,
        y: 0.5
      },
      image: image2
    });

    let loop = GameLoop({
      update: function() {
        if (keyPressed('left')) {
          sprite1.x = sprite1.x - 1;
        }

        if (keyPressed('right')) {
          sprite1.x = sprite1.x + 1;
        }

        if (keyPressed('up')) {
          sprite1.y = sprite1.y - 1;
        }

        if (keyPressed('down')) {
          sprite1.y = sprite1.y + 1;
        }

        if (collides(sprite1, sprite2)) {
          sprite2.x = randInt(41, maxX - 40);
          sprite2.y = randInt(41, maxY - 40);

          f = function(i) {
            var n = 1e4;
            var c = n / 3;
            if (i > n) return null;
            var q = Math.pow(t(i, n), 2.1);
            return (Math.pow(i, 3) & (i < c ? 16 : 99)) ? q : -q;
          }

          t = (i, n) => (n - i) / n;
          A = new AudioContext()
          m = A.createBuffer(1, 96e3, 48e3)
          b = m.getChannelData(0)
          for (i = 96e3; i--;) b[i] = f(i)
          s = A.createBufferSource()
          s.buffer = m
          s.connect(A.destination)
          s.start()
        }
      },
      render: function() {
        sprite1.render();
        sprite2.render();
      }
    });

    initKeys();

    loop.start();
  </script>
</html>
Enter fullscreen mode Exit fullscreen mode

And it should look a little like this in your browser:

The sound in this GIF doesn't seem to be working, but you should hear a beep every time the sprites touch.

And there you have it. A game that will provide hours minutes of fun. Keep your eye out on Steam for the full release.

๐Ÿ’พ Source + diff for end of step 8

A step further

If you look at the file sizes, you'll see this is weighing a bit more than 13kB:

$ ls -lth
total 88
-rw-r--r--@ 1 leereilly  staff    28K Aug 13 09:50 kontra.min.js
-rw-r--r--@ 1 leereilly  staff   674B Aug 13 09:49 mishmanners.jpeg
-rw-r--r--@ 1 leereilly  staff   679B Aug 13 09:48 leereilly.jpeg
-rw-r--r--@ 1 leereilly  staff   2.2K Aug 13 08:07 index.html
Enter fullscreen mode Exit fullscreen mode

We're using the minified version of Kontra, but that still includes a few things we don't need. See the Kontra website for details on reducing the file size even further

Join JS13K!!!

Please feel free to fork and expand upon this on for your own JS13K entry. There are lots of things you could improve...

  • Make it a two-player game (player 2 could respond to W A S D )?
  • Add support for high scores?
  • Introduce some more some sound effects?
  • Add some actual gameplay LOL

Better yet, start from scratch and have some fun. Here are some other resources that might be useful:

Good luck and have fun! Would love to see your entries in the comments below <3

Troubleshooting

Did you encounter some bugs along the way following this tutorial? If you've never used it before, Chrome's Developer Console is your friend.

Press โŒ˜ + Option + J (macOS) or Control + Shift + J (Windows, Linux, Chrome OS) to jump straight into the console panel. From there you'll see what isn't working correctly...

If you felt like a L337 H4X0R running curl or jq commands, you'll feel like you're in the matrix now with the things you can do in there.

You can also look in this repo to see the full source code. If you look at the commit history, you'll see the diffs/code for each of the steps above.

Latest comments (9)

Collapse
 
mishmanners profile image
Michelle Mannering

Thanks for the mention Lee โค๏ธ great tutorial.

Collapse
 
siddharthshyniben profile image
Siddharth

Player 1 fell out of the world!
Collapse
 
mishmanners profile image
Michelle Mannering

Hahaha! Love it.

Collapse
 
leereilly profile image
Lee Reilly

That player 1 guy is pretty sneaky. Just be careful...

Collapse
 
jonrandy profile image
Jon Randy ๐ŸŽ–๏ธ

Not mine - but a little game in 1K of JS - js1k.com/2017-magic/demo/2846

Collapse
 
jonrandy profile image
Jon Randy ๐ŸŽ–๏ธ

Or perhaps Pacman in 1K - js1k.com/2019-x/demo/4122

Collapse
 
jonrandy profile image
Jon Randy ๐ŸŽ–๏ธ

Or Tetris in less than 512 bytes? Yup - veu.github.io/mini-tetris/dist/tet...

Thread Thread
 
leereilly profile image
Lee Reilly

Or a game in a Tweet ๐Ÿคฏ
theverge.com/2018/8/3/17648784/cod...

Collapse
 
end3r profile image
Andrzej Mazur

Great tutorial, thanks for publishing! :D