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>
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>
💾 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;
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
});
Now we'll define our game loop and start things ticking!
let loop = GameLoop({
render: function() {
sprite1.render();
}
});
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
or
$ curl -s https://api.github.com/users/leereilly | grep -i avatar_url
"avatar_url": "https://avatars.githubusercontent.com/u/121322?v=4",
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;
}
},
We also need to add a call to initKeys();
just before loop.start();
:
initKeys();
loop.start();
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
});
💾 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);
}
💾 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
Since we're setting the image to 4 times that in the code, the browser will attempt to resize it making it look pixelated.
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()
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>
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
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:
- Kontra.js tutorials
- Micro game engines and boilerplates
- Sound and music
- Artwork and fonts
- Minification
- Misc. tools
- Tutorials
- Post-mortems
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.