-
Notifications
You must be signed in to change notification settings - Fork 0
Home
Voxel Race is a tiny 3D racing game, written in Javascript, that I entered in the first edition of 2k Plus Jam in 2020. The minified code packs to a 2029-byte zip archive.
Most of the game engine, from the worldbuilding to the 3D voxel renderer, is reused from two demos I posted to the defunct js1k contest : Comanche (2013) and Maximum Overcast (2019). With a starting point fully functional at 1 kb, this left as much space to add the game logic, track generation, multiple environments and time challenge.
The world is stored as a tiled heightmap, built using a pseudorandom walk. The algorithm is unchanged since Comanche, only parameters have changed :
- Comanche uses a 256x256 map
- Maximum overcast uses a 512x512 map for the same world size, thus doubling the resolution and producing smoother graphics
- Voxel Race has a 1024x1024 map with the same resolution, hence a larger world, and an extra step for roadwork
First, a winding riverbed is dug into the heightmap. The landmark was inspired from the theme "spring" in js1k 2013, and kept afterwards.
A loop simulates an antwalk, one step at a time. At each iteration, the landscape surrounding the current point is raised uniformly, then the ant moves to a neighbouring area according to the formula : t->13*t+2+9*Math.cos(t)&-1 :
| Value of t&3 | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| Direction | south | east | north | west |
The heightmap is seen as a torus with coordinates being taken modulo 1024, thus it can be tiled ad infinitum with no visible seam.
- Comanche uses 500k steps and 6x6 squares and the result is quite hilly yet looks pretty much random.
- Maximum overcast uses 1M steps and 9x9 squares, the result is reused for clouds, and while it is hard to notice in the demo, the algorithm grows recurring patterns.
- Voxel race uses 5M steps for the ground and 2M for the clouds (in a different array). The patterns are clearly visible from the sky :
Finally, the ground is flattened to pave the road. Starting at (0,0), 4000 sections of road are drawn, each with pseudorandom length and curvature. On each step, the whole width of the road is levelled to the same height as its center. The array color, organized the same way as the heightmap, identifies the track with values 1 to 3. The last sections are marked as 4, and this value is directly tested during the race to detect that the player reached the end.
Voxel Race reuses the 3D rendering engine from Maximum Overcast, with a wider screen (1024 pixels instead of 512) and a limited banking angle.
Both share the same principle as the original Comanche, but instead of drawing directly to the 2D context using context.fillRect(), a buffer image is constructed offscreen, then blitted directly using context.putImageData().
The engine considers columns 4 pixels wide, and draws them from the bottom up using reverse painter's algorithm with y-buffer. It loops on distance, from near to far, converts (column d, distance e) to map coordinates (x, y) relative to the player location (l, m). It then retrieves the altitude b from the heightmap and projects it to vertical screen coordinates w. Pixels above the current y-buffer f are drawn with the current color. No interpolation nor smoothing is performed, giving the heightmap a pixelated look. This is similar to the algorithm from the project called Voxel Space which has a very neat step-by-step explanation.
The color drawn is the result of a complex operation involving :
- the nature of the ground stored in array
colorand interpreted as a colormap. 0 is for raw (grass / sand / rock), 1 for asphalt, 2 and 3 for roadside stripes. 4 (end of the race) is rendered as a checkerboard. - for a raw ground, the current level modulo 4 determines its material : grass, sand, marsh or rock. For all but sand, the color changes with altitude
- lighting is computed depending on slope along the Y axis, the current hour (and thus sun location) and influenced by cloud coverage (more ambiant and less direct lighting if the sky is overcast)
- finally, the color is blended with the horizon depending on distance.
Pixels remaining between the y-buffer and the top belong to the sky : the same projection is computed, without the altitude, the cloud map is used instead for transparency level. Blending is performed with both horizon and sun color.
Each level features a different racetrack, along with its own time of day and weather conditions. Because of the sheer amount of information that would be required to store the raw data, everything is instead derived from a few equations. Those allow for amazing compression rates (the game features unbounded levels) at the expense of fine control over the result. For example, tracks 6, 17 and 18 cross themselves, and I had to shorten the common length to avoid more loops.
The index of the track is given as a seed to the generating algorithm and featured in multiple parameters :
- environment : loops every 4 levels between grassland, desert, marsh and mountain
- track shape, the level influences that angle of each bend; location after each section determines the angle and length of the next section
- cloud cover, from clear sky to overcast (used as a threshold in the cloud map)
- current hour, from dawn to shortly after sunset, with headlights turned on at dusk
The starting point is always the same, and heightmap itself doesn't change, except for the area flattened by the road.
Audio is pretty much limited to the engine noise. It reuses a WebAudio-based module I originally devised for another racing game - Staccato, submitted to js13kgames in 2013. It consists in a short (one second) sample looped indefinitely, and adjusted for pitch to match the engine speed. Unlike the original one, there is a single channel and thus no waveform change during acceleration.
During splash screens, instead of interrupting the sound, which requires a few operations (stop() the sound, disconnect() the current source, create a new AudioBufferSourceNode and initialize it), the pitch is lowered to very low frequencies. It might still be audible with good speakers though.
The same trick is also used at the beginning of a race - the frequency is modified in short bursts of 0.4s to mark the countdown and give the start signal.
Several years competing in the successive editions of js1k conditioned me to think for 1024 bytes, meaning I had plenty of space with a 2k limit. The opportunity to submit a zip archive, as opposed to self-unpacking code as produced by JsExe or RegPack, netted a few extra hundred bytes.
To be honest, Voxel Race never actually came close to the limit. While the posted archive clocks at 2029 bytes, slightly under the 2048b limit, I made no conscious effort to keep the code small. For sure, the source contains many repeating sequences, that could have been written in a shorter, more straightforward way. But it would not have compressed so well (the minified, unpacked code is almost 5kb). Without realizing it, I had coded for the packer.
This could still be improved. Only variables that were kept from the initial code are properly minified to one letter. Those that were added for the new features still have explicit, longer names, and there are a few lines of dead code too (definition of dx and dn, remnants of a failed attempt to improve lighting). Cutting on these would probably free 100 or more bytes for extra stuff.
The voxel rendering engine will most likely see some future use in upcoming jams. Fellow contestants gave me some excellent ideas on game types that would benefit from a fast, small-footprint ground 3D, so expect to see it again, most likely improved, in the future.

