2025-187

22:14:10, atom feed.

I implemented basic code hot reloading. Say in your game, you are in the middle of playtesting some long, complicated level, and you find some undesirable behavior. You want to modify your game code to fix the behavior, but then you have to recompile your game and get back to where you found the issue in order to test your change--that or hack up a minimal reproducible example for this one case--to verify if your fix worked.

Another approach is to separate the code for the game logic from the platform code and then dynamically load the game logic into the running platform. This way, you can change the game logic while keeping the game with all of its game state alive and test changes much more quickly. I have to thank Handmade Hero for this idea. I'm currently developing on Ubuntu, so this post will cover how I achieved hot reloading on Linux.

First, a simple demonstration. The video below shows a running game instance where I have moved the player somewhere away from its origin. The tile the player currently occupies is initially red, but then I change the game logic to render it as green, recompile the game logic library, and the game instance detects the library change, reloads the library, and then the tile is rendered as green, all while keeping the game and its state alive.

Until now I kept all the game code in a single file, but I pulled some (rather arbitrary) code out into a separate file to have something to compile into the game logic library while I was standing up hot reloading. Once we do this, we need to add a layer of abstraction between the functions the game logic library provides and the callsites outside the library. Essentially, we replace direct calls into some code with calls through function pointers that we point to implementations of routines we want to call. The implementations exist somewhere in our game logic library which we will now be dynamically loading into our platform.

The gist is like this.

#include <dlfcn.h>

// ...

constexpr int64_t TARGET_FPS = 60;
constexpr int64_t GAME_LIB_CHECK_EVERY_N_FRAMES = TARGET_FPS; // Every second.

typedef int (*FooFn)(Foo*, Bar);

struct stat lib_stat = {0};
const char* lib_pathname = "./path/to/lib.so";
int stat_ret = stat(lib_pathname, &lib_stat);
assert(stat_ret == 0);
uint64_t lib_stat_frame_counter = 0;
void* lib_handle = dlopen(lib_pathname, RTLD_LAZY);
assert(lib_handle);
FooFn foo_fn = (FooFn)dlsym(lib_handle, "foo");
assert(foo_fn);

while (run_game_loop) {
  // ...
  // Maybe reload game logic library.
  // ...

  Foo f{};
  Bar b{};
  foo_fn(&f, b);
}

The typedef establishes an interface between the library to hot reload and the end user. Unlike what I show here, I'd put it in header files corresponding to the library to be reloaded. We then deal with stat(2) and stat(3) which we will be using to get at the file modification timestamp on our game logic library. stat needs to get at inode information and is thus a syscall, so it is "slow," but we also aren't going to be recompiling our library every frame, so we don't need to poll for the modification timestamp on every iteration through the game loop. That's where lib_stat_frame_counter comes in--we'll only poll every so many frames; in this case, once a second.

We next call dlopen which loads a library and gives us back a handle to it. We perform lazy symbol loading with RTLD_LAZY to avoid loading in all symbols our library might contain when I will only use a few of them on the platform side. To load symbols of interest, we call dlsym. We cast its return value to the type of the interface our requested symbol represents.

Not shown in the code above, but in the game loop, every GAME_LIB_CHECK_EVERY_N_FRAMES, we call stat again and check if the modification timestamp on our library has changed. If it has, we dlclose our current handle on our game logic library and then repeat the dlopen and dlsym work to get at the new implementation of our exported routines.

Finally, the game loop calls our routines through our function pointers; shown above is a call through foo_fn.

In a C++ context, the symbol name of the function foo will be mangled. To prevent this, we wrap our declaration of foo in our game logic side inside extern "C" { }. If you wanted to see symbol names in your library on Linux, you can do so with nm. Here are actual (truncated) examples from the test code I hoisted into my game logic library, first mangled and then not.

$ nm -D build/Debug/libgame.so 
000000000000120f T _Z17map_pixel_to_tileP7Tilemapm8Vector2f
000000000000130e T _Z24player_render_tile_underP6PlayerP7TilemapP12SDL_Renderer
00000000000011b9 T _Z5round8Vector2f
$ nm -D build/Debug/libgame.so 
000000000000120f T map_pixel_to_tile
000000000000130e T player_render_tile_under
00000000000011b9 T _Z5round8Vector2f

I don't need round on the platform side, so I leave its symbol name mangled. In this test, I do want map_pixel_to_tile and player_render_tile_under, though, so I needed to not mangle their names.

Code hot-reloading is for development and debugging only. Release builds will link the game library in statically.