Dominic Szablewski, @phoboslab
— Thursday, September 20th 2018

Underrun – Making Of

I participated in this year's js13kGames, a JavaScript game development competition with a file size limit of 13kb, including code, assets and everything else. My entry was Underrun, a twin stick shooter using WebGL.

Underrun Play Underrun – A WebGL shooter in 13kb of JavaScript

For this competition I set out to produce something with a dense atmosphere – which is inherently difficult to do with so little space. In my experience a dense atmosphere is most easily achieved with two things: moody lighting and rich sound & music. The assets for sound & music usually take up a lot of space and lighting for 2D games requires more and bigger graphic assets. With only 13kb to space I had to look for different solutions for these things.

Graphic Assets

Since I was very limited with the amount of graphic assets I could fit in this game the decision to implement a 3D perspective came naturally: atmospheric lighting is easier to do in 3D than in 2D and requires less assets to look neat. In contrast, to produce interesting light effects in 2D typically requires you to implement a third dimension anyway. This can be done through normal maps as some 2D Pixel Art games do or by explicitly separating your environment into different layers as Teleglitch does for example.

My approach for the graphics was to render a simple 2D tilemap with a bunch of textured quads. All walls are rendered as cubes and entities (the player, enemies, projectiles, particles and health pickups) are just simple sprites. All textures for the game fit in a single tile sheet, 2.12kb in size.

Underrun Assets All graphic assets for the game, carefully tuned to 16 colors

I opted to render the player character as a sprite as well, even though it needs to be freely rotated. It was far easier to produce 8 different sprites – one for each direction – than to implement a loader and renderer for complex 3D models. This allowed me to omit any 3D vector or matrix math operations. The game never needs to rotate any geometry. Everything is rendered with axis aligned quads.

To make the player character rotation look convincing I build a 3D model and simply took screenshots for each of the different perspectives. I'm a total doofus when it comes to 3D modeling software, but that doesn't stop me from using the ingenious Wings3D. The result doesn't look like much, but scaled down to 16px, it doesn't matter.

title The player character model built in Wings3D

The final game features 3 different levels, each 64×64 tiles in size. When I started to work on the game I considered to use Run Length Encoding to compress the level maps. However, even as simple as RLE is, a decompressor for it would have taken up some valuable space. So my solution was to just let the browser handle the decompression by using PNG images.

title Underrun's levels are stored as PNG images

With the PNG compression, each level image is just about 300bytes in size. A naive approach would have needed 64×64 bytes = 4kb per level (assuming storage as raw binary, 1byte per tile).

When loading the level a random number generator (RNG) is used to determine exactly which tile graphic to use for each floor and wall. The RNG also decides where to place enemies and powerups, as these are not encoded in the level images. To make this consistent between playthroughs, so that each player plays exactly the same game, I implemented a tiny seedable RNG (source code) and re-seeded it with the same constant before loading each level.

I also hardcoded some of the wall tiles to be rendered with twice the normal height to make it look more interesting. You can see the complete level loading routines in the load_level() function.

Rendering

The renderer follows an extremely simple design: a single buffer is used to store all vertices, texture coordinates and normals. A second buffer is used to store all light sources (position, color, attenuation). The game can write to these buffers using the push_quad() and push_light() functions. The renderer clears these buffers at the beginning of each frame and draws the (filled) buffers at the end of the frame.

There's one small optimization to this. During level load, since all the level geometry is static, the game sets a reset mark for the geometry buffer after all the level geometry has been pushed. Before each frame the renderer will then reset the write position for the geometry buffer to this reset mark instead of to 0. This clears all the entities, but we don't have to re-push the level data.

Since the game's graphics are quite simple, there's no need for any occlusion culling or any other optimizations to reduce the amount of geometry being drawn. Underrun draws the whole level – all floor an ceiling tiles and all sprites – for every single frame in a single draw call.

The gist of the renderer looks like this:

var 
    num_verts = 0,
    level_num_verts = 0,
    max_verts = 1024 * 64,
    buffer_data = new Float32Array(max_verts * 8); // 8 floats per vert


// Push a new quad into the buffer at the current num_verts position.
function push_quad(x1, y1, z1, ...) {
    buffer_data.set([x1, y1, z1, ...], num_verts * 8);
    num_verts += 6;
}

// Load a level, push some quads.
function load_level(data) {
    num_verts = 0;

    for (var y = 0; y < 64; y++) {
        for (var x = 0; x < 64; x++) {
            // lots of push_quad()...
        }
    }

    level_num_verts = num_verts; // set reset pos to current write pos
}

// Resets the buffer write position to just after the level geometry.
function renderer_prepare_frame() {
    num_verts = level_num_verts;
}

// Hand over the buffer data, up to num_verts, to webgl.
function renderer_end_frame() {
    gl.bufferData(gl.ARRAY_BUFFER, buffer_data, gl.DYNAMIC_DRAW);
    gl.drawArrays(gl.TRIANGLES, 0, num_verts);
};

// Run a frame
function tick() {
    renderer_prepare_frame();

    for (var i = 0; i < entities.length; i++) {
        entities[i].update();
        entities[i].draw();
    }

    renderer_end_frame();

    requestAnimationFrame(tick);
}

For the lighting I opted for some very simple vertex lights. Since the game only renders relatively small quads, it doesn't matter much that the light computation is handled in the vertex shader instead of per-pixel in the fragment shader. This also allowed for many more light sources. The game currently allows for 32 lights, each of which considered for every single vertex. Current GPUs are so stupidly fast that no optimizations are needed for such simple cases.

The light calculation in Underrun's Vertex Shader looks somewhat like this:

// 7 values for each light:  position (x, y, z), color (r, g, b), attenuation
uniform float lights[7 * max_lights];
varying vec3 vertex_light;

void main(void){
    vertex_light = vec3(0.3, 0.3, 0.6); // ambient color

    // apply each light source to this vertex
    for(int i = 0; i < max_lights; i++ ) {
        vec3 light_position = vec3(lights[i*7], lights[i*7+1], lights[i*7+2]);
        vec3 light_color = vec3(lights[i*7+3], lights[i*7+4], lights[i*7+5]);
        vec3 distance = length(light_position - vertex_position);
        float attenuation= (1.0/(lights[i*7+6] * distance);

        vertex_light += 
            light_color
            * max(dot(normal, normalize(dist)), 0.0) // diffuse
            * attenuation;
    }

    // ...
}

For some extra scary atmosphere the fragment shader calculates a simple black fog based on the distance to the camera. An exception is made to the enemies' eyes: all full red texture pixels are always rendered unchanged – no light, no fog. This makes them essentially glow in the dark.

The fragment shader also reduces the final image to 256 colors. I experimented with some color tinting but ultimately settled for a uniform rgb palette. Rendering in 256 colors and a screen size of just 320×180 pixels not only gives the game an old-school vibe, but also hides a lot of the graphical shortcomings.

Have a look at the source code for the renderer.js and game.js - there's really not that much to it.

Music & Sounds

The sound and music for all my previous games made up the bulk of the data size. E.g. Xibalba has about 800kb of graphic assets (tile sheets, sprites, title screen etc.) but more than 4mb of music and 2.5mb of sound effects (actually 8mb and 5mb respectively, since all assets need to be provided as .ogg and .mp3). This was of course infeasible when you only have 13kb total to work with.

So for Underrun all music and sound effects are generated in JavaScript when the game starts. The game uses the brilliant Sonant-X library to render instruments from a bunch of parameters into sound waves.

My dear friend Andreas Lösch of no-fate.net produced the instruments and sequences for the music in the tracker Sonant-X Live. The sound effects were produced in Sonant-X Live as well; each effect being a single instrument.

Sonant-X Underrun's Music, produced in the Sonant-X Live tracker

To save some more space I began to remove all the parts from Sonant-X that were not needed for my game, leaving only the WebAudio generator. In doing so I noticed that the library was unnecessary slow, taking half a second to generate an empty buffer object. The culprit was the use of a single, interleaved Uint8 Buffer for the left and right audio channel, storing unsigned 16bit values with the implicit zero amplitude at 32767.

The library was spending a lot of time loading from and storing 16bit values into this 8bit buffer and later converting it to a signed float suitable for WebAudio. I believe this is an artifact from the original use case of this library: producing a WAV data URI.

I refactored the library to use two Float32Arrays (one for each channel) that can be directly used by the WebAudio API in the browser. This not only simplified the code and reduced the file size by 30% but also made the music generation twice as fast.

As an example, have a look at the original applyDelay() function and my modified one - granted, I also removed the pausing/re-scheduling functions here that originally prevented blocking the event loop with a long running computation, but it wasn't really needed anymore.

All in all, minified and gzipped, the music and sound for Underrun along with the Sonant-X library now occupy only about 2kb. That's a pretty big deal, considering all my previous games' biggest asset were sound and music. So even if your game is not size restricted as much, it may make sense to generate audio directly in your game, instead of adding big files to the download.

Minification

When I started this project I took great care to write as little code as possible and to make sure all my code could be minified effectively by UglifyJS. The source has quite an unusual style compared to my other JS projects. It's using a lot of global variables and functions, instead of abstracting it more neatly into classes. I also wrote the code in the more C-like snake_case style to force my brain into this different mode.

In hindsight, this was totally unnecessarily. Minifying and zipping would have gotten rid of most of the overhead of using more abstractions.

One trick that made a small difference is the "inlining" of all WebGL constants at build-time. E.g. gl.ONE_MINUS_SRC_ALPHA is replaced with just 771 - the constant's value. I also shortened all the WebGL function calls by producing aliases that just contained the first two letters and all subsequent upper-case letters of the function's original name:

for (var name in gl) {
    if (gl[name].length !== undefined) { // is function?
        gl[name.match(/(^..|[A-Z]|\d.|v$)/g).join('')] = gl[name];
    }
}

This allowed me to use gl.getUniformLocation() as just gl.geUL(). Note though that this approach is quite hack-ish and produces some name collisions. But for my game it worked out nicely.

I also took a lot of shortcuts with the game logic and especially collision detection. Collision detection between entities is just using two nested loops, checking each entity against all other entities. This quadratic function quickly explodes with a lot of entities in the level (e.g. 100 entities = 10.000 comparisons) - it's just amazing with what you can get away with these days.

Other than that I didn't do anything particularly clever or exciting to further minify the game.

The full source for Underrun is on github: github.com/phoboslab/underrun

Be sure to have a look at some of the other js13Games entries this year. Some of my favorites are The Chroma Incident, 1024 Moves, ISP, Wander and Envisonator.

© 2024 Dominic Szablewski – Imprint – powered by Pagenode (5ms) – made with <3