Hello Jai! Link to heading

I was recently added to the Jai private beta which has apparently been steadily increasing the number of early users. Seeing some of the really cool and interesting tools and workflows being built with it for the sokoban game (Thekla’s currently-in-development next game) pushed my curiosity into overdrive and I’m really excited to have the opportunity to experience Jai at this stage.

If you haven’t heard of Jai, its a new programming language being created by Thekla and a small group of contributors. Thekla is the game studio founded by Jon Blow who is the original designer and creator of the language. There are quite a few blog posts and youtube videos talking about Jai and it’s on-going development. Many are quite interesting so be sure to look around if you’re interested in learning more.

It’s too early for me personally to have developed any strong opinions about the language itself. At the moment I’m enjoying it a lot though!

Note
Just a heads up. I’m going to be skipping over a lot of various Jai things in order to avoid writing an entire book about the language. Almost every line I start adding to this post has the potential to become a little rabbit hole since I’m still organizing a lot of my thoughts regarding the language and the experience of using the toolchain.

Why hot-reloading? Link to heading

The first time I saw this hot-reloading technique was by Casey Muratori as part of the Handmade Hero series. But it was the the examples developed by Karl Zylinski that recently motivated me to pursue the idea in Jai. Karl is someone I don’t know personally, but rather know of him from his time at Our Machinery before it all collapsed.

The Machinery was pretty interesting, and had a nice feature where your extensions/plugins/game-dll could be hot-reloaded. Inspired by the experience of this workflow, Karl has built some great templates for hot-reloadable games in Odin, such as this one.

Since I don’t really have a firm grip on Jai, I felt like building a similar example using Jai and SDL3 would be worthwhile. Having a firm destination is often the best way for me to explore new things.

Info
On the slim chance you’re not aware, Our Machinery was a small company in Sweden that created a game engine called “The Machinery”. It was written entirely in C and despite having disapeared entirely from the internet for undisclosed reasons, there is an archive of their blog where you read a lot of really interesting posts about a variety of topics.

Overview Link to heading

The essential components of a hot-reload system in a language like Jai, Odin or C are the following:

  • An app/exe that can load a game dll and hold pointers to memory allocated for the game state
    • The app watches for changes to the game dll and manages copies of the dll that it loads at runtime
  • A game dll that implements the game itself, and can be (re)initialized with previously allocated state
  • A build process that is flexible enough to build everything or individual components

That’s really about it I guess.

Tip

Things to keep in mind

There are certain details that need to be accounted for. One is that, while you can change the behavior of code, you cannot change any memory layouts.

Another is that, you’ll need to load a copy of the game dll rather than the dll itself because some systems will open the dll in an exclusive mode which will prevent you from replacing it with the latest build.

SDL3 and the “new” callback system Link to heading

Starting with SDL3 there is a new way to structure your app where instead of providing a ‘main’ function, you define a set of callbacks instead. These callbacks cover initialization, event handling, your update or tick implementation, and shutdown. Please see the docs for the official descriptions of everything.

This all seems cool and interesting (and if you’re targeting WASM then it’s even required) so for this example we’ll be using this approach.

Since Jai isn’t C, a main entrypoint must be provided because in SDL3 the former “SDL_main” is all implemented using the C preprocessor. Thankfully SDL3 does provide an API to account for this since it’s used from so many other languages and runtimes. In this case you use the function SDL_EnterAppMainCallbacks.

Tip
If you’re generating your own bindings to SDL3, remember that the callback API lives in SDL_main.h and requires you to define SDL_MAIN_USE_CALLBACKS when generating the bindings.

Keeping things simple Link to heading

The Machinery would find any dll that exported a specific symbol, and it would load that dll and then track any changes to it as you worked. This is a pretty standard approach for a plugin system going as far back as the 90s.

The example I’ve put together took a couple of days learning Jai as I went along. It avoids any complication like attempting to be a full plugin system. Instead it’s looking for a specifically named library living in the “bin” folder next to the executable itself. … actually that isn’t even entirely true because it really just assumes that the current working directory has a subdirectory called “bin” there … my shame is not great enough yet to motivate me to properly fix that however. :D

Building Jai programs Link to heading

For something as small as what I’ve created, I feel like there is just so freakin’ much to go over, because Jai is interesting. One of it’s central tenets is that programs should be built by bespoke programs written in the same language.

Jai is fast. It’s like… way cray fast. This is great, because if you’re always building the program that builds your program it shouldn’t be slowing you down.

As you might have guessed, these are called “metaprograms”. There is a default metaprogram which provides a set of common options and features, and if you are only building a single target then this metaprogram is enough! In this case, I’m building an exe and a dll so we’ll have to provide a metaprogram to do that.

Jai constructs software in “workspaces”. You can define multiple workspaces, set various compiler options in it, and then “add sources” to start the compilation process. This is of course an oversimplification, but it captures the gist of things.

Info

Metaprograms can hook deeply into the Jai compiler and do a lot of really neat stuff, but for the purposes of this example, only the most basic functionality is needed.

I anticipate that the metaprogram and compiler intercepts in Jai will be discussed a lot once more people are able to use the toolchain.

Here is the fundamental bit of build.jai:

#run, stallable {
    build_game := false;
    build_app := false;
    build_content := false;

    // TODO: Create output directories (bin, content) if they don't exist!

    set_build_options_dc(.{do_output = false});
    args := get_build_options().compile_time_command_line;

    for arg : args {
        if arg == {
            case "-game"; build_game = true;
            case "-app"; build_app = true;
            case "-content"; build_content = true;
        }
    }

    // App is our primary application which sets up the context and loads the game dll
    if build_app {
        app := compiler_create_workspace("App");
        if !app {
            log_error("Workspace creation failed for smvc executable.\n");
            return;
        }

        options := init_options(app);
        options.output_type = .EXECUTABLE;
        options.output_executable_name = EXECUTABLE_NAME;
        set_build_options(options, app);

        add_build_file("source/app.jai", app);
    }

    // Our game dll is where the fun stuff happens and exists in order to support hot reloading
    if build_game {
        game_dll := compiler_create_workspace("Game DLL");
        if !game_dll {
            log_error("Workspace creation failed for game dll.\n");
            return;
        }

        game_dll_options := init_options(game_dll);
        game_dll_options.output_type = .DYNAMIC_LIBRARY;
        game_dll_options.output_executable_name = GAME_DLL_NAME;
        set_build_options(game_dll_options, game_dll);

        add_build_file("source/game.jai", game_dll);
    }
};

With this setup it’s possible to build the game dll and app independently of each other.

Info

In my debugger config before launching the game, I build both by using the command:

jai build.jai - -game -app

An app, a dll, and a handshake Link to heading

Let’s start by taking a look at our app. Our app entry point is very very simple:

main :: () {
    g_ctxt = context;
    g_api = New(Game_Api);
    defer free(g_api);

    SDL_EnterAppMainCallbacks(
        0, null,
        SDL_AppInit,
        SDL_AppIterate,
        SDL_AppEvent,
        SDL_AppQuit);
}

A global reference to a primary context is initialized, and the struct to hold pointers to game dll procedures is… allocated… for some reason but it did not need to be.

Note
The “context” in Jai is a concept where allocators, logging, thread id, and assertion failure handlers are configured. A pointer to the context is implicitly provided to every standard Jai function.

The reason for storing our primary context in a global reference is because our various SDL_* callbacks must be declared as #c_call functions so they can actually be called from SDL. Procedures in Jai that are defined a #c_call or #no_context will not receive the implicit context arg and will be required to push a context before using any standard Jai procedures.

In Jai, you can push a default constructed context using a push_context {} block. But if you have a specific context you’d like to push, then you can push that instead. In this example each of these callbacks pushes this primary context before doing anything else:

push_context g_ctxt {
    // ...
}

The Game_Api struct is how the various procedures provided by the game dll are accessed by the app.

That struct is defined as follows:

Game_Api :: struct {
    on_init: #type () -> SDL_AppResult;
    on_event: #type (event: *SDL_Event) -> SDL_AppResult;
    tick: #type (delta: float64) -> SDL_AppResult;
    draw: #type (delta: float64) -> ();
    on_shutdown: #type () -> ();
}

It is probably no surprise that these procedures line up pretty closely to the SDL callbacks. :D Our entrypoint is as bare as possible and we do essentially all of our work in the SDL callbacks.

SDL_AppInit Link to heading

In this example project, the init procedure is where most of the heavy lifting occurs. The essential outline is:

  • Initialize SDL
  • Load settings etc
  • Create our main window
  • Initialize global state
  • Load the game dll
  • Setup a file watcher to trigger reloads

I won’t go into each of these steps in detail, most of them are fairly standard things.

Global state Link to heading

The global state isn’t complex and while it’s set as the userdata in the SDL sense and as part of the file watcher config, it really could be a regular global instead.

Global_State :: struct {
    // This is the pointer we maintain a copy of in order to
    // give it to a newly loaded game dll. For the first run
    // it will naturally be null.
    game_state: *void;

    // This is our handle to the currently loaded game dll.
    game_dll: *SDL_SharedObject;
    // The api version is a simple counter to make a unique copy
    // of the game dll before loading it.
    api_version: u32;
}

Loading the game dll Link to heading

Loading the game dll is defined as a simple procedure:

load_game :: (state: *Global_State) -> bool {
    // ...
}

Everytime this is called it will:

  • Create a copy of the game dll,
  • Load the “versioned” copy
  • Find the handshake procedure and call it
  • Call on_init in the updated game api struct
  • And finally unload the previous game dll

This is implemented so that simply calling “load_game” at any point you trigger a reload of the game. This gives you the flexibility of using a file watcher, but also allows you to trigger a reload by keystroke if you preferred it that way.

Create a copy of the game dll Link to heading

src_dll_path := tprint("bin/%", GAME_DLL_NAME);
dst_dll_path := tprint("bin/%_%", state.api_version, GAME_DLL_NAME);

log("- Copying % => %", src_dll_path, dst_dll_path);
if !copy_file(src_dll_path, dst_dll_path) {
    log_error("Failed to copy game dll to versioned target.");
    return false;
}

Load the dll Link to heading

Odin has a really cool package for loading libraries and resolving symbols in them (docs). Jai does not (yet). But Jai does indeed have all the platform specific library loading APIs available which you can use.

Since we’re using SDL we don’t have to get mired in those details thankfully. There has a simple API we can use: SDL_LoadObject, SDL_LoadFunction, and SDL_UnloadObject. This does mean though that we have to use #c_call functions for our handshake.

game_dll := SDL_LoadObject(to_c_string(dst_dll_path));
if !game_dll {
    log_error("Failed to load %.\n%", dst_dll_path, to_string(SDL_GetError()));
    return false;
}

Performing the handshake Link to heading

The handshake stage is an important piece of the puzzle to enabling hot-reloading. In this example the handshake does two things. The first is to assign procedures to the provided Game_Api struct so that as the app continues it’ll be using the currently loaded game dll implementations. The second comes in two phases, on the first run the game dll will provide a pointer to it’s internal state in the form of a *void, while on subsequent reloads that previously allocated memory will be returned to the game dll just getting loaded. During the handshake, the game dll knows whether it’s being reloaded or being loaded for the first time based on whether state is a null pointer. As mentioned above, this shold hopefully make it clear why your memory layout cannot change when hot-reloading.

The handshake procedure is declared as:

handshake_fn :: #type (api: *Game_Api, state: **void = null) -> void #c_call #foreign;

When the game dll is loaded by the app, a pointer to a function named “do_handshake” is loaded and because SDL_LoadFunction returns a function pointer (typedef void (*SDL_FunctionPointer)(void);) it must be cast to handshake_fn in order to use it.

Here is how it is implemented in the app:

do_handshake := cast(handshake_fn) SDL_LoadFunction(game_dll, "do_handshake");
if !do_handshake {
    log_error(
        "Unable to locate handshake function! %",
        to_string(SDL_GetError()));
    return false;
}
log("- Performing handshake with game");
do_handshake(g_api, *state.game_state);

And here is the implementation of the handshake procedure in the game dll:

#program_export
do_handshake :: (api: *Game_Api, state: **void) #c_call {
    push_context {
        log("Initializing game API");
        api.on_init = on_init;
        api.on_event = on_event;
        api.tick = tick;
        api.draw = draw;
        api.on_shutdown = on_shutdown;

        // Was the game hot-reloaded?
        if state.* != null {
            log("Previous game state found.");
            g_state = cast(*Game_State) state.*;
        }
        else {
            log("Initializing game state.");
            g_state = New(Game_State);
            state.*  = cast(*void) g_state;
        }
    }
}

Let the game dll initialize itself Link to heading

if g_api.on_init() != SDL_APP_CONTINUE {
    log_error("Game initialization has failed.");
    exit(1);
}

This step is important because we must “remap” the Jai context in the dll. Jai and Odin approach the context quite differently. In the Jai case it isn’t enough to push a context before calling a procedure from the dll because in Jai it’s possible for the actual type and definition of the context to be very different between the dll and the app. I will avoid trying to explain more here until I’ve spent more time fully understanding it. But the purpose of remapping is to copy important parts of the calling context of the app into the definition of the context in the dll.

There is a module provided by the Jai toolchain called Remap_Context. Using this I store a remapped context as a global in the dll, and at each procedure provided as a part of the Game_Api struct I push that context before doing anything else. In the examples provided with Jai, the context gets remapped for every call into a procedure defined by the dll. I felt like remapping one time and just pushing that instead would be less overhead, but when I read through the Remap_Context module I realized that it already does that in essence. Measuring all of this is on my todo list.

on_init:: () -> SDL_AppResult {
    using SDL_AppResult;

    log("Initializing game...");

    log("- Remapping context");
    g_ctxt = remap_context();

    // If we were hot-reloaded then the renderer should already be initialized.
    if g_state.renderer == null {
        log("- Creating renderer");
        g_state.renderer = SDL_CreateRenderer(g_ctxt.window, null);
        if !g_state.renderer {
            log_error("Could not initialize renderer: %\n", to_string(SDL_GetError()));
            return SDL_APP_FAILURE;
        }
    }
    else {
        log("- Renderer already initialized!");
    }

    return SDL_APP_CONTINUE;
}

Unload the previous game dll Link to heading

Finally, once a new game dll is loaded we unload the previous one and remove it’s “versioned” copy. I take this extra step, and I know some plugin systems and some hot-reloading templates do not. There may perhaps be good reasons for that…

state.game_dll = game_dll;
if previous_dll { // The first time we load a game dll this will be 'null'.
    log("- Unloading previous library");
    SDL_UnloadObject(previous_dll);
    remove_game_dll_version(previous_version);
}

Setup a file watcher Link to heading

With this basic setup, you can already reload on demand if you wanted to. I think in Karl’s examples he just compared the modification time of the game dll to the modification time when it was last loaded. But for this example I wanted to try out Jai’s File_Watcher module

A global is used for the watcher instance:

g_watcher: FW.File_Watcher(Global_State);

And once the game dll is loaded the first time the watcher is initialized:

// When the game dll is rebuilt, my observation is that the MOVED event was the primary indicator we can reload it.
if !(FW.init(*g_watcher, file_change_callback, state, events_to_watch = .MOVED, watch_recursively = false)) {
    log_error("Could not initialize file watcher");
    return SDL_APP_FAILURE;
}

if !FW.add_directories(*g_watcher, "bin") {
    log_error("Failed to add 'bin' to file watcher directories.");
    return SDL_APP_FAILURE;
}

The procedure file_change_callback is defined as:

file_change_callback :: (watcher: *FW.File_Watcher(Global_State), change: *FW.File_Change, state: *Global_State) {
    push_context g_ctxt {
        path := parse_path(change.full_path);
        if path_contains(path, .[GAME_DLL_NAME]) && (change.events & .MOVED) {
            log("Re-loading game dll");
            load_game(state);
        }
    }
}

The Jai File_Watcher has to be polled in the SDL_AppIterate callback:

changed, needs_wait, wait_seconds := FW.process_changes(*g_watcher);
reset_temporary_storage();

With this, now as the game runs, you can use jai build.jai - -game and the game dll will get hot-reloaded.

The game dll Link to heading

There isn’t a lot to say about what goes into the game dll here. There should or will be an entire post just about ensuring that memory allocated and used by the game is guided through the reload process correctly. That would include how the allocators and contexts are configured. But for this example everything is very simplified in order to focus on the key requirements of reloading.

Summary Link to heading

The code for everything can be found here. It’s essentially just a straight copy of the repo I was noodling around in so I can’t promise that it’s the most well organized or cleanest thing. But it demonstrates the concept perfectly well.

If you build and run everything, the easiest way to test is to run bin/scrl and then update the draw procedure in game.jai to change the clear color. As you rebuild the game dll with jai build.jai - -game the file watcher should notice and reload everything.

If you have any feedback or suggestions you can let me know on Mastodon or the Jai Discord and I’d be grateful to hear from you!