Luckily, the N64 homebrew scene has picked up a lot of steam in the last few years and we have a very capable alternative now: Libdragon.
Libdragon is basically SDL for the N64. It provides facilities for drawing sprites and triangles, sound output, controller input and much more.
It took me only a few evenings to build a new platform backend for high_impact on top of libdragon. I tested this with Biolab Disaster. The game code remained unmodified; performance was meh, but I was using the N64 hardware in the most naive way possible.
Libdragon provides the compilers and everything else that’s necessary to build a ROM file for the N64. The installation instructions and all other documentation are comprehensive and well-written. The library comes with many examples to get you started.
In general, it was a pleasure to work with Libdragon. Just a heads up: you
probably want to use the preview branch as the “stable” trunk branch has
hopelessly fallen behind.
For testing, a good emulator is invaluable. For the longest time, N64 emulation was extremely inaccurate. Lackluster emulation of the RSP and RDP coprocessors, in particular, was the cause of most problems.
Most emulators just emulated Nintendo’s platform library, libultra. They emulated the intent to draw a triangle, not what the hardware would actually do. While inaccurate, this made emulation possible at all in the early days. Famously, UltraHLE (“Ultra High Level Emulator”) was released well within the lifetime of the N64 and caused a lot of headaches and subsequent lawsuits.
These days the N64 core in Ares is much closer to the actual hardware – the RDP and RSP are fully emulated, including accurate timing for the RSP. The infamous slow memory bandwidth of the N64, however, can still only be tested on real hardware (which recently caused me some disappointment).
So you need a real N64 and a cartridge that lets you play arbitrary .z64 ROM files.
The open-source SummerCart64
is excellent and available from many different manufacturers. Be aware:
some manufacturers (especially on AliExpress) cheap out on the components of
the board.
SummerCart64 has the usual SD card slot to store your ROMs, but what makes it great for development is its USB-C port: you can directly connect it to your PC and upload a ROM as part of your build process using sc64deployer.
I ended up with the N64 next to my PC, connected via USB, and used a cheap $10
USB analog capture card to display its video output in a window on my desktop.
On Linux, it took some fiddling with mpv to get low-latency output; here’s the
script I used.
With this setup, iterating on real hardware was just a matter of compiling and pushing the N64 reset button.
I originally made Xibalba as a demo for my JavaScript game engine in 2014. WebGL was still the hot new thing back then; a 3D game in a browser was quite a novelty. The game was very short, featuring only a handful of levels, weapons and enemy types.
In contrast, I wanted Xibalba 64 to be a real game, not just a demo. So I not only needed to port the game to C and high_impact, but also expand on it with more levels, more enemy types and more weapons.
high_impact is a 2D game engine, but Xibalba 64 is clearly 3D. Well, not quite.
Since the game has no elevation, it can be mostly treated as 2D. You could
conceptually play Xibalba 64 from a 2D top-down perspective. Of course, that
wouldn’t be as exciting, but all the physics, movement and shooting would work the
same way. In this regard, the game is very similar to Wolfenstein 3D.
Many of high_impact’s physics functions expect a vec2_t argument with .x
and .y components. But for drawing, I absolutely needed a 3D position, so I
came up with this definition for a vec3_t type and changed the entity_t type:
`typedef struct { float x, y; } vec2_t;
typedef union { vec2_t xy; struct { float x, y, z; }; } vec3_t;
typedef struct {
// …
vec3_t pos;
vec3_t vel;
// …
} entity_t;Now, whenever I need to call a function that accepts avec2_t, I can “convert” from vec3_t` for free:
trace_t res = trace(collision_map, entity->pos.xy, entity->vel.xy);
Since the inner vec3_t struct is “anonymous”, I can still access all values
directly; i.e., entity->pos.z works just fine.
The initial port of the existing levels and enemy types went quite smoothly and was finished in about two weeks. I then spent another few months on extending the game and optimizing the renderer.
Most of Libdragon’s functions fit naturally into a new platform and rendering backend, though I had to change some other parts of high_impact to bypass its mixer (Libdragon has its own, accelerated by the RSP) and image loader.
Throughout the whole process, I retained the ability to build the game with the SDL2 or Sokol backends. This was great for playtesting game logic and enemy behavior. To make levels, I also implemented a simple hot-reload mechanism triggered whenever a level file changed.
The level editor, bundled with high_impact, is a single self-contained HTML file. I ended up extending it quite a bit to add better support for lightmaps, display actual sprites for entities (instead of just boxes), add descriptions for entity settings and provide other small features. The single source of truth is still the C source code – the level editor reads it and extracts the entity types and supported settings automatically.
Since the level editor still works with JSON files, I built a small map compiler that reads the JSON and emits binary data. While loading JSON on the N64 is of course possible, it added some unnecessary ~100 ms of load time. So during the build process, each JSON level file is converted into a struct that essentially looks like this:
`typedef struct { uint16_t magic; uint16_t entities_len; uint16_t map_width; uint16_t map_height;
struct { uint16_t type_id; uint16_t x; uint16_t y; uint16_t settings_len; struct { uint16_t setting_type; // such as “name”, “target”, “size”, … union { float16_t float_value; int16_t int_value; struct { int16_t string_len; char string_value; }; } value; } settings[settings_len]; } entities[entities_len];
uint16_t collision_map[map_width * map_height]; uint16_t floor_map[map_width * map_height]; uint16_t wall_map[map_width * map_height]; uint16_t ceiling_map[map_width * map_height]; uint16_t light_map[map_width * map_height]; } level_t;` The level compiler writes those values in big-endian format for the N64 and little-endian format for x86 (SDL2, Sokol, WASM), so we can easily read everything on all platforms without byte swapping.
Libdragon itself has a function for drawing triangles: rdpq_triangle() inserts a
single triangle draw call into the RDP queue. While this works, what you really
want to do is submit your draw calls to the RSP, have some custom microcode to
perform transformations, lighting, depth calculations, etc., and then let the RSP
instruct the RDP to ultimately draw the triangle.
The intricacies of the RDP and RSP were still new to me, but luckily another outstanding open-source library, Tiny3D, handles all this and more with a simple-to-use API. Getting something on the screen was the easy part; making it performant was a whole other endeavor.
The N64 infamously only has 4 KB of texture memory. The largest textures you can upload are just a meager 64×64 pixels. Even worse, the memory latency for a texture upload is atrocious. One solution, used by Mario 64 and many other titles, is to render untextured polygons whenever you can.
This wouldn’t really fly with the style of my game, so instead I had to be really careful with the draw order of level tiles to minimize texture uploads. On top of that, Tiny3D can load and submit up to 17 quads at once, and it would be wasteful not to use that. So I ended up collecting batches of triangles with the same texture in 64-bit draw calls:
typedef union render_call { uint64_t packed; uint32_t hashable; uint64_t ident : 46; struct { uint64_t translucent : 1; uint64_t texture_index : 9; uint64_t x : 10; uint64_t y : 10; uint64_t w : 8; uint64_t h : 8; uint64_t vbi : 14; uint64_t len : 4; }; } render_call_t;
Here vbi is the accompanying vertex buffer index, and len is the number of quads
in this call. Since every call is just 64 bits wide, we can efficiently sort them
at the end of the frame and issue them to Tiny3D.
But before I could do all this, I had to first figure out which parts of a level are actually visible. The original JavaScript Xibalba used a portal system, dividing each level into sectors and precomputing which sectors were visible from the current one. This worked fine, but produced a bit more overdraw than I would have liked.
So I opted for another approach: raycasting. Yes, the game is just casting 320 rays into the scene, covering the whole field of view. Each ray marks traversed tiles in a bitmap for submission to the renderer.
Later, I optimized the raycasting a bit more by recursively dividing the 320-pixel field of view until two rays hit the same tile. In this process, I also check whether any of the traversed tiles are missing a ceiling – if so, we have to draw a skybox.
Fun fact: the skybox in Xibalba 64 is just a single 32×32-pixel texture that is beautifully smeared across the horizon.
As another optimization, I arranged each tile sheet that wouldn’t fit in a single upload into a single column and tried to use just 4-bit indexed colors wherever possible. Using fewer colors allowed more pixels to fit into texture memory, and the column layout ensured that each tile could be uploaded as a single, continuous chunk of memory.
With all of this, the game runs at a stable 60 FPS. Something not many other N64 games can claim!
The four-player split-screen mode doesn’t quite hit the 60 FPS mark at all times, but still remains fluid. In contrast, GoldenEye 007 infamously often dropped into single-digit frame rates here.
As with all my other games, my good friend Andreas Lösch produced some outstanding music. You can listen to the whole Xibalba 64 Soundtrack on Bandcamp.
The game itself also has a built-in music player that you can unlock in the single-player campaign.
Cartridge space is tight, and even compressed audio is typically either quite large or too expensive to decode.