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!
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.
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.
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
.
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.
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.
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.
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!