Dominic Szablewski, @phoboslab
— Sunday, August 4th 2024

Porting my JavaScript Game Engine to C for No Reason

high_impact

tl;dr: high_impact is small game engine for 2D action games. It's written in C, compiles to Windows, Mac and Linux as well as to WASM for the Web. It's “inspired by” my original Impact JavaScript game engine from 2010. The name high_impact is a nod to a time when C was considered a high level language.

MIT licensed, source on github: github.com/phoboslab/high_impact

Video from my tweet at Jul 5, 2024 showing Biolab Disaster gameplay

Ancient History

In April 2010 Steve Jobs published an open letter titled “Thoughts on Flash”, in which he outlined the decision to not ever support Flash on iOS.

Flash was a browser plugin that — until then — was so vital for the web that it was bundled with browsers and included in Windows updates. Websites like Newgrounds and Kongregate, devoted entirely to Flash Games and Animations, marked the epicenter of Internet Culture. The importance of Flash cannot be overstated: A web without Flash was a boring web.

While Android supported Flash, it was a total shit show and everybody knew it. Adobe, ever reluctant to do the right thing, made no effort to improve on its shortcomings on mobile. With Apple refusing to let this rotting, closed source code base run on iOS, it marked the beginning of the end.

No Flash meant no games in the browser. Or so was the thought.

At the time I was looking for a project for my bachelor thesis and stumbled upon the little used JavaScript Canvas2D API. Canvas2D allowed you to draw images and shapes into a <canvas> element on your website. It was invented and implemented by Apple/Safari (with no standards procedure) for the purpose of rendering desktop widgets: Weather forecasts, calendars, stock tickers and other mildly useful fluff.

Google and Mozilla soon followed with support for Canvas2D while Microsoft forgot that the Web existed – but that was ok, nobody cared about Internet Explorer anymore. Canvas2D was supported by all serious browsers.

So I set out to proof that you don't need Flash to make games for the Web. The result was Biolab Disaster.

Biolab Disaster Title Screen The Biolab Disaster Title Screen, with the the key art borrowed from the amazingly talented Arne Niklas Jansson.

I felt I had succeeded when famous Apple aficionado John Gruber published a two-sentence piece about the game with the purpose (as I perceived it) to redeem Steve Job's decision.

To make Biolab Disaster I had to create a game engine and level editor all while jumping through countless hoops that the early Canvas and Audio APIs demanded. Only with all the attention that the 2010 Web had to offer, I realized what I had.

I decided to polish my code, write extensive documentation for it and then released Impact. Not for free, but for a rather steep $99. My decision to sell it was met with a lot of backlash but was successful enough to launch me into a self-sustained career. I ended up selling more than 3000 licenses.

Many Web games were created with Impact and it even served as the basis for some commercial cross-platform titles like Cross Code, Eliot Quest and my own Nintendo Wii-U game XType Plus.

At the end of its life, I released Impact for free.

A few weeks ago I started to build Impact again from the ground up, but this time in C, instead of JavaScript.

Why C?

C is a fun little language. It's miles apart from all the things I write for money. It's very simple, yet extremely deep. It paralells everything I love in games: easy to learn, hard to master.

I slowly re-discovered my love for C – first when I ported my JavaScript MPEG1 decoder to the single header pl_mpeg library, then implementing VR in Quake for Oculus Rift, created the QOI image format & QOA audio format and lastly re-wrote wipEout.

Impact was quite simple; by no means comparable to Godot, Unreal or Unity. Still it proved to be a solid basis for a lot of different games.

Rewriting Impact in C should be a fun exercise.

Concept

As with most things I write for fun, I try to condense it down to its simplest form. Everything in high_impact is implemented as straight forward as possible, with the absolute minimum amount of code I could come up with. Achieving this with C is not always easy, but for me that's the most enjoyable part of this whole project.

The basic idea for high_impact is the same as for the original JavaScript game engine: you get the facilities for loading tile-maps and creating, updating and drawing game objects (“entities”). The game engine handles the physics and collision detection between entities and with the collision map. high_impact also provides the functionality for simple sprite-sheet animations, drawing text and playing sound effects and music.

high_impact is not a “library”, but rather a framework. It's an empty scaffold, that you can fill. You write your business logic inside the framework.

At the very bottom of this framework sits the platform backend. high_impact currently compiles with either of two platforms: SDL or Sokol.

Your game code sits in one or more “scenes” (think: “title_screen”, “menu”, “game”, ...), where a scene is just a struct with some function pointers. You initially call engine_set_scene(&scene_game) and the engine will set up the new scene, call scene_game.init() once and then scene_game.update() & scene_game.draw() for every frame.

Tile-maps and the initial entities can be loaded from a .json file (utilizing my pl_json library) or created on the fly. The reason I chose JSON for the level format was simply backwards compatibility with the original Impact.

To do some dogfooding, high_impact loads images in QOI and sounds/music in QOA format. The Makefile for the demo games is set up to automatically convert all assets to these formats (i.e. PNG to QOI and WAV to QOA). This means there's no need to include any other image/sound decoding libraries.

Future versions of high_impact may support different asset formats, but I quite like how the simplicity of these formats continue the whole theme of this project.

Entities

All entities (the dynamic objects in the game world) share the same entity_t struct that contains all properties that high_impact needs: position, velocity, size, etc. Each entity being the same byte size makes storage and management trivial.

To move your entities you set the velocity or acceleration and high_impact handles all the rest.

Through a macro, high_impact allows you to extend the basic entity struct with some custom per-entity properties. How you extend this struct is up to you. Biolab Disaster uses a union, with one struct per entity type, but there are good arguments to just have a “fat struct” instead. For what it's worth, Drop doesn't need to define any additional properties.

ENTITY_DEFINE(
    union {
        struct {
            float high_jump_time;
            float idle_time;
            bool flip;
            bool can_jump;
            bool is_idle;
        } player;

        struct {
            entity_list_t targets;
            float delay;
            float delay_time;
            bool can_fire;
        } trigger;

        // ...
    }
);

In your game, you can access a struct in this union like so:

static void update(entity_t *self) {
    self->player.idle_time += engine.tick;
}

Each of your entity types also needs to supply a entity_vtab_t that provides the function pointers used by this entity:

// Called for every frame
static void update(entity_t *self) {
    // Your own update logic here
    // ...

    // Update physics, handled by high_impact
    entity_base_update(self);
}

// Called when this entity overlaps another one where
// (self->check_against & other->group)
static void touch(entity_t *self, entity_t *other) {
    entity_damage(other, self, 10);
}

entity_vtab_t entity_vtab_blob = {
    .update = update,
    .touch = touch,
    // ...
};

All entries in this entity_vtab_t are optional. See entity.h for a list of all available functions.

Like for most other things in high_impact, there's a fixed size storage for all entities. By default you can have 1024 active entities, but this can be configured by defining ENTITIES_MAX. The engine easily handles up to 64k entities.

Video from my tweet at Jul 6, 2024, using 1000x particles

Whenever you want to hold a reference to an entity for more than one frame, you can get a entity_ref_t, which is just a struct { uint16_t id, index; }; – a unique id for that entity and the index into the entity storage array. This can be resolved (very cheaply) to a pointer again using entity_by_ref(). This ensures that the entity at the particular index still has the id that we expect and is not a different entity that happens to occupy the same storage address after the original died. Using an uint16_t for the index here is also the reason for the hard 64k maximum active entities. If you need more, change the source!

The entity system is the one part where working with C gets a bit awkward. What I wanted is simple OOP with classes and single inheritance, but that takes some fiddling with C. Still, high_impact tries to make this as ergonomic as possible.

A lot of people (not Jonathan Blow) believe that OOP (I use the term loosely here) is the wrong approach for entities and you should rather do some kind of composition (e.g. going full “Entity Component System” with FLECS or others). However, with all the games that I wrote, I found this “naive” OOP approach to just work. All logic for a particular entity type sits in a single place it's extremely easy to reason about.

Collision Detection/Response

The easy way to handle game world collisions is to check if an entity can move to a new position and if not: just stop it. This is usually good enough (it worked well for Q1k3 and Underrun), but can produce some weird behavior for fast moving objects: imagine in a 2D platformer the player is falling towards the ground. 16px above the ground, the next movement step will put the player inside the ground – so the player is stopped mid air. In the next frame, gravity is applied again and the player moves further to the ground. This looks like a "soft landing".

high_impact instead traces the entity's box against the tile-map and calculates the exact point of impact. This is a bit more involved than a simple yes/no check but produces far better results. high_impact can also handle sloped tiles, which complicates this tracing a fair bit. See trace.c for the details.

When an entity hits a tile, we may also have to do a second trace with the remaining velocity. E.g. if you hit the ground at an angle the entity's vel.y is set to 0, but we don't want to stop the entity at the exact hit point. So we do the second trace with the remaining vel.x to slide along the ground.

Collisions between entities is handled separately. Each entity defines how it wants to collide with other entities. E.g. particles may want to collide with the tile map, but not collide with other entities at all. Moving platforms collide with other entities, but should not move in a collision response.

Video from my tweet at Jul 13, 2024 demonstrating slopes and dynamic collision detection

The broad phase collision detection sorts all entities by their pos.x, which is cheap with an insertion sort, as the entities are already mostly sorted from the last frame. With the sorted entities, we only have to go from left to right, checking each entity against all entities that lie between pos.x and pos.x + size.x.

This "sweep and prune" is fast as long as we don't have too many entities overlapping at similar x positions. I.e. a big tower of stacked boxes is this worst case for this approach. Some games (e.g. vertical shooters) may also want to change the sweep axis. This can be done with #define ENTITY_SWEEP_AXIS y.

Rendering

high_impact currently comes with two renderers: OpenGL and an (incomplete) software renderer. Since all rendering goes through a very slim API and the actual draw calls are using a single function, implementing different backends is quite straight forward. The functions an additional rendering backend needs to support are:

void render_backend_init(void);
void render_backend_cleanup(void);

void render_set_screen(vec2i_t size);

void render_frame_prepare(void);
void render_frame_end(void);

void render_draw_quad(quadverts_t *quad, texture_t texture_handle);

Plus three more to handle textures:

texture_mark_t textures_mark(void);
void textures_reset(texture_mark_t mark);

texture_t texture_create(vec2i_t size, rgba_t *pixels);

Of course this is fairly simplistic: you can only draw quads and can't use any shader effects, but for the purpose of this game engine it's enough.

The software renderer is just 140 lines of code (see render_software.c) though I cheated a bit by only supporting axis aligned quads.

The OpenGL renderer (see render_gl.c) is a bit more involved, as it tries to fit the rendering for a whole frame into a single OpenGL draw call. This is achieved in two ways:

  1. all quads to be drawn are collected into a big buffer and handed over to OpenGL with glDrawElements() at once
  2. all textures are combined into a single texture atlas. We never have to rebind any textures.

Texture atlases are quite oldschool and have their own drawbacks. Strictly speaking, they are not needed anymore with bindless textures, but these are not supported everywhere.

While high_impact only supports a single texture atlas, the atlas size is configurable through a #define. Mobile GPUs typically support 8k×8k textures while modern Desktop GPUs seem to max out at 32k×32k. Good enough for the purpose of this engine. For what it's worth, Biolab Disaster and Drop use a 512×512 atlas.

Sound

Sound output is handled by SDL2 or Sokol, so we only need to handle loading, decoding and mixing of multiple sounds. The sound system is split into a sound_source_t holding the underlying samples, and a sound_t that represents a currently playing sound using one of the sources.

The system is an adaptation of the one I wrote for wipEout and can decompress QOA on demand.

Again, everything is statically allocated. There's a fixed number of sources you can load and a fixed number of sounds you can play at a time. Sounds are automatically disposed when they finished playing, so that they can be reused. The whole system is set up in way that you don't have to think about it much.

As an example from Drop, here's how the bounce sound of the player entity is loaded/played:

static sound_source_t *sound_bounce;

static void load(void) {
    sound_bounce = sound_source("assets/bounce.qoa");
}

static void collide(entity_t *self, vec2_t normal, trace_t *trace) {
    if (normal.y == -1 && self->vel.y > 32) {
        sound_play(sound_bounce);
    }
}

Sounds can change volume, be panned (shifted left or right) and change pitch (the playback speed). Setting pitch to a negative value plays it backwards. The “resampling” needed for the variable pitch is pretty low quality: just a nearest neighbor interpolation.

See sound.h for the documentation and sound.c for the implementation details.

Memory Management

This is the fun part. Memory management in C is often regarded as some sort of black magic and many tutorials and libraries over-complicate things a lot. For games in particular, we can make it much simpler. In fact, in high_impact you don't have to think about memory much at all.

For games that don't have any user created assets, you know exactly how much memory you need. Either the largest scene fits or it doesn't. So high_impact statically allocates a single array of bytes, called the “hunk”. This is the only memory high_impact uses. The size of the hunk is configurable through #define ALLOC_SIZE. From that hunk, you can allocate memory using two ways:

  1. At the beginning of the hunk, growing upwards, is a bump allocator (also known as an “arena”) that holds all assets and other data needed for the game, entities and the current scene.
  2. At the end of the hunk, growing downwards, is a temp allocator that behaves like malloc() and free(). This is meant as temporary storage when loading assets e.g. to decompress an image before handing the pixels over to the GPU.

The bump allocator has multiple “high water marks” and resets automatically to these at certain times. This means you never have to explicitly free() some bump allocated memory. It behaves a bit like autorelease pools in the Apple ecosystem. Conceptually it looks like this:

game {
    scene {
        frame {}
    }
}
  1. Everything you allocate before setting the first scene will only be freed when the program ends
  2. Everything you allocate during scene.load() will be freed when the scene ends
  3. Everything you allocate while a scene is running will be freed at the end of the frame

One simplification here is that we call load() for each entity type at stage 1, since we don't know ahead of time which entities might be used in a scene. Level data and everything else that is only needed for the current scene is at stage 2, and things you only need for the logic of a current frame at stage 3.

You can also specifically wrap your code in an additional allocation context:

alloc_pool() {
    void *result = memory_intensive_computation();
    do_something(result);
}

Which is just a shorthand for:

bump_mark_t mark = bump_mark();
void *result = memory_intensive_computation();
do_something(result);
bump_reset(mark);

There's some more documentation of the system in alloc.h.

The Level Editor

The original Impact came with a level editor called “Weltmeister”. I decided to make this part of high_impact as well. It's still written in JavaScript and uses much of the original source, but was updated for some modern browser features.

Video from my tweet at Jul 8, 2024, showing the new Weltmeister

Weltmeister is completely self-contained. You can just double-click the weltmeister.html and start building levels. In the old days Weltmeister needed a backend API (written in PHP or NodeJS) to load and save files. Now with the FileSystemAPI we can just ask for access to a certain folder. Sadly, this is not yet fully supported by Safari or Firefox (particularly the showDirectoryPicker() function), so you need a Chrome-ish browser.

I hope Mozilla will get their shit together at some point (or Ladybird can fill the gap). This is such a cool way to deliver cross platform apps that can read/write the filesystem.

Weltmeister reads your C source files and collects all entity types from it. high_impact comes with some macros that do nothing, but are understood by Weltmeister to change the appearance and behavior of the entities in the editor.

#define EDITOR_SIZE(X, Y)     // Size in the editor. Default (8, 8)
#define EDITOR_RESIZE(RESIZE) // Whether the entity is resizable in the editor
#define EDITOR_COLOR(R, G, B) // The box color in the editor. Default (128, 255, 128)
#define EDITOR_IGNORE(IGNORE) // Whether this entity can be created in the editor

E.g. the trigger entity in Biolab Disaster is configured with this

EDITOR_SIZE(8, 8);
EDITOR_RESIZE(true);
EDITOR_COLOR(255, 229, 14);

Demo Games

To make sure high_impact actually works as a game engine I ported two of my original Impact games to C.

This was honestly quite a mundane task. It's just a “transliteration” of the original JS source and re-uses all existing assets. I'll view the lack of challenges here as a testament to high_impact working as intended.

Biolab Disaster

The original launch title for Impact. A side scrolling Jump'n'Gun.

Drop

A super simple arcade game.

Extensibility

high_impact works as a traditional game engine, where all game specific code is additive. I.e. you don't need to change the source code of the engine itself, but I hope it is simple enough that you can change the engine source as needed. This is not an ivory tower. Go wild!

The platform and renderer of high_impact are meant to be extensible without necessitating changes to the rest of the code. If anyone cares about high_impact at all, I'd hope to see support for lots of different systems and I welcome pull requests for Vulkan, DirectX & Metal and maybe even platform backends for PSX, N64, Dreamcast and whatever else you can think of.

It's C. It should run everywhere.

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