Let me show you how to set up an example of hot-reloading, using one of my favourite game engine/libraries: Raylib.
What is hot-reloading? It is when you can change your code and configuration data while your software/game is running and then see and interact with those changes during runtime.
Basic Project Structure
Before we begin, take a look at this basic project I have already set up, this project currently does not support hot-reloading and I will take you through the steps to add this feature. The basic project has just a little game loop with a moving ball and also reads a number from a configuration file, as it's pretty common to store game configuration data in easily editable text files.
If you're following along you will need the zig compiler (version 0.11.0) and Raylib (version 5.0). I will also be working from a windows machine.
// main.zig (no hot-reloading)
const std = @import("std");
const c = @cImport({
@cInclude("raylib.h");
});
// The game state is allocated when the program starts
// and persists for the entire lifetime of the program.
const GameState = struct {
allocator: std.mem.Allocator,
time: f32 = 0,
radius: f32 = 0,
};
const screen_w = 400;
const screen_h = 200;
const config_filepath = "config/radius.txt";
pub fn main() !void {
var game_state = gameInit();
c.InitWindow(screen_w, screen_h, "Zig Hot-Reload");
c.SetTargetFPS(60);
// WindowShouldClose will return true if the user presses ESC.
while (!c.WindowShouldClose()) {
gameTick(game_state);
c.BeginDrawing();
gameDraw(game_state);
c.EndDrawing();
}
c.CloseWindow();
// I don't bother to free game_state here.
// The program is quitting and that memory will be freed by the OS anyway.
}
fn gameInit() *GameState {
// The c allocator is available to use because we linked lib-c in build.zig.
var allocator = std.heap.c_allocator;
var game_state = allocator.create(GameState) catch @panic("Out of memory.");
game_state.* = GameState{
.allocator = allocator,
.radius = readRadiusConfig(allocator),
};
return game_state;
}
fn readRadiusConfig(allocator: std.mem.Allocator) f32 {
const default_value: f32 = 10.0;
// Read the text data from a file, if that fails, early-out with a default value.
const config_data = std.fs.cwd().readFileAlloc(allocator, config_filepath, 1024*1024) catch {
std.debug.print("Failed to read {s}\n", .{ config_filepath });
return default_value;
};
// Attempt to parse that text data into a float and return it, if that fails,
// return a default value.
return std.fmt.parseFloat(f32, config_data) catch {
std.debug.print("Failed to parse {s}\n", .{ config_filepath });
return default_value;
};
}
fn gameTick(game_state: *GameState) void {
game_state.time += c.GetFrameTime();
}
fn gameDraw(game_state: *GameState) void {
c.ClearBackground(c.RAYWHITE);
// Create zero terminated string with the time and radius.
var buf: [256]u8 = undefined;
const slice = std.fmt.bufPrintZ(
&buf,
"time: {d:.02}, radius: {d:.02}",
.{ game_state.time, game_state.radius },
) catch "error";
c.DrawText(slice, 10, 10, 20, c.BLACK);
// Draw a circle moving across the screen with the config radius.
const circle_x: f32 = @mod(game_state.time * 50.0, screen_w);
c.DrawCircleV(
.{ .x = circle_x, .y = screen_h/2 },
game_state.radius,
c.BLUE
);
}
Then we'll need a build.zig file to build our game.
// build.zig (no hot-reloading)
const std = @import("std");
// Although this function looks imperative, note that its job is to
// declaratively construct a build graph that will be executed by an external
// runner.
pub fn build(b: *std.Build) void {
// Standard target options allows the person running `zig build` to choose
// what target to build for. Here we do not override the defaults, which
// means any target is allowed, and the default is native. Other options
// for restricting supported target set are available.
const target = b.standardTargetOptions(.{});
// Standard optimization options allow the person running `zig build` to select
// between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not
// set a preferred release mode, allowing the user to decide how to optimize.
const optimize = b.standardOptimizeOption(.{});
// This example only supports the windows, but you can configure raylib
// to build for other platforms, I'll just leave that as an exercise for the reader ;)
if (target.getOsTag() != .windows) {
@panic("Unsupported OS");
}
const exe = b.addExecutable(.{
.name = "no_hotreload",
// In this case the main source file is merely a path, however, in more
// complicated build scripts, this could be a generated file.
.root_source_file = .{ .path = "src_basic/main.zig" },
.target = target,
.optimize = optimize,
});
// Link to the Raylib and it's required dependencies for windows.
exe.linkSystemLibrary("raylib");
exe.linkSystemLibrary("winmm");
exe.linkSystemLibrary("gdi32");
// Raylib is library written in C, so also link lib-c.
exe.linkLibC();
// This declares intent for the executable to be installed into the
// standard location when the user invokes the "install" step (the default
// step when running `zig build`).
b.installArtifact(exe);
// This *creates* a Run step in the build graph, to be executed when another
// step is evaluated that depends on it. The next line below will establish
// such a dependency.
const run_cmd = b.addRunArtifact(exe);
// By making the run step depend on the install step, it will be run from the
// installation directory rather than directly from within the cache directory.
// This is not necessary, however, if the application depends on other installed
// files, this ensures they will be present and in the expected location.
run_cmd.step.dependOn(b.getInstallStep());
// This allows the user to pass arguments to the application in the build
// command itself, like this: `zig build run -- arg1 arg2 etc`
if (b.args) |args| {
run_cmd.addArgs(args);
}
// This creates a build step. It will be visible in the `zig build --help` menu,
// and can be selected like this: `zig build run`
// This will evaluate the `run` step rather than the default, which is "install".
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step);
}
The First Build
Before I can build my project, I need to build Raylib. Raylib handily comes with it's own build.zig, so just navigate to it's directory in the console and run:
zig build -Dshared=false
That -Dshared
argument is something we'll return back to for when we add hot-reloading. Right now Raylib is configured to build a static library, which means the raylib code will be embedded inside our game executable.
Then for my own project I run:
zig build run --search-prefix C:/raylib/zig-out
I add the --search-prefix
argument so the build program knows where to find Raylib on my machine.
Main and the Game
To make the project suitable for hot-reloading I'll need to split apart the game logic from the main loop and I'll do this by putting the game logic into a dynamic library (AKA a DLL).
The Game DLL will have the gameInit
, gameTick
, and gameDraw
functions and so will be responsible for mutating the game state and drawing it. The main executable will be responsible for creating the window, handling the main loop, and most importantly, loading the Game DLL and the function pointers to gameInit
etc. The main loop will also listen out for a keyboard button press, say F5, and if pressed will recompile and reload the DLL. If all goes accordingly I'll be able to edit the game code, hit F5, and have the game update on the fly.
So game logic is stripped out of main.zig and the dll reloading function is added:
// main.zig (hot-reloading)
const std = @import("std");
const c = @cImport({
@cInclude("raylib.h");
});
const screen_w = 400;
const screen_h = 200;
// The main exe doesn't know anything about the GameState structure
// because that information exists inside the DLL, but it doesn't
// need to care. All main cares about is where it exists in memory
// so *anyopaque is just a pointer to a place in memory.
const GameStatePtr = *anyopaque;
// TODO: point these the relevant functions inside the game DLL.
var gameInit: *const fn() GameStatePtr = undefined;
var gameReload: *const fn(GameStatePtr) void = undefined;
var gameTick: *const fn(GameStatePtr) void = undefined;
var gameDraw: *const fn(GameStatePtr) void = undefined;
pub fn main() !void {
loadGameDll();
var game_state = gameInit();
c.InitWindow(screen_w, screen_h, "Zig Hot-Reload");
c.SetTargetFPS(60);
while (!c.WindowShouldClose()) {
if (c.IsKeyPressed(c.F5)) {
unloadGameDll();
recompileGameDll();
loadGameDll();
gameReload();
}
gameTick(game_state);
c.BeginDrawing();
gameDraw(game_state);
c.EndDrawing();
}
c.CloseWindow();
}
fn loadGameDll() !void {
// TODO: implement
}
fn unloadGameDll() !void {
// TODO: implement
}
fn recompileGameDll() !void {
// TODO: implement
}
This currently won't work because those game function point nowhere. So I'll flesh out the dll functions. For that I'll make use of std.DynLib
.
// main.zig (hot-reload)
var game_dyn_lib: ?std.DynLib = null;
fn loadGameDll() !void {
if (game_dyn_lib != null) return error.AlreadyLoaded;
var dyn_lib = std.DynLib.open("zig-out/lib/game.dll") catch {
return error.OpenFail;
};
game_dyn_lib = dyn_lib;
gameInit = dyn_lib.lookup(@TypeOf(gameInit), "gameInit") orelse return error.LookupFail;
gameReload = dyn_lib.lookup(@TypeOf(gameReload), "gameReload") orelse return error.LookupFail;
gameTick = dyn_lib.lookup(@TypeOf(gameTick), "gameTick") orelse return error.LookupFail;
gameDraw = dyn_lib.lookup(@TypeOf(gameDraw), "gameDraw") orelse return error.LookupFail;
std.debug.print("Loaded game.dll\n", .{});
}
That function can error, so I'll need to handle that at the call site. Seeing as there's no game without loading the game DLL, a panic is suitable.
// main.zig (hot-reloading)
pub fn main() !void {
loadGameDll() catch @panic("Failed to load game.dll");
//...
}
The game code will at least need to export those functions for this to compile so I'll quickly stub those.
// game.zig
const std = @import("std");
const GameState = struct {};
export fn gameInit() *anyopaque {
// TODO: implement
var allocator = std.heap.c_allocator;
return allocator.create(GameState) catch @panic("out of memory.");
}
export fn gameReload(game_state_ptr: *anyopaque) void {
// TODO: implement
_ = game_state_ptr;
}
export fn gameTick(game_state_ptr: *anyopaque) void {
// TODO: implement
_ = game_state_ptr;
}
export fn gameDraw(game_state_ptr: *anyopaque) void {
// TODO: implement
_ = game_state_ptr;
}
Update build.zig
The next hurdle is to update build.zig
to build both the exe and the dll.
const std = @import("std");
pub fn build(b: *std.Build) void {
// Adds an command line option to only build the game shared library
// This will be set to true when hot-reloading.
const game_only = b.option(bool, "game_only", "only build the game shared library") orelse false;
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// Create the DLL for the game library.
const game_lib = b.addSharedLibrary(.{
.name ="game",
.root_source_file = .{ .path = "src_hotreload_v1/game.zig" },
.target = target,
.optimize = optimize,
.version = .{ .major = 1, .minor = 0, .patch = 0 },
});
// The game also needs access to raylib functions.
game_lib.linkSystemLibrary("raylib");
game_lib.linkSystemLibrary("winmm");
game_lib.linkSystemLibrary("gdi32");
game_lib.linkSystemLibrary("opengl32");
game_lib.linkLibC();
// Create the dll file and copy it to zig-out
b.installArtifact(game_lib);
if (!game_only) {
const exe = b.addExecutable(.{
.name = "hotreload",
.root_source_file = .{ .path = "src_hotreload_v1/main.zig" },
.target = target,
.optimize = optimize,
});
exe.linkSystemLibrary("raylib");
exe.linkSystemLibrary("winmm");
exe.linkSystemLibrary("gdi32");
exe.linkLibC();
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| {
run_cmd.addArgs(args);
}
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step);
}
}
I'll build and run again with this command:
zig build run --search-prefix C:/raylib/zig-out -Dgame_only=false
So far, so good. But if I hit F5 an error is thrown error: AlreadyLoaded
. I need to now write the implementation for unloadGameDll
and recompileGameDll
.
// main.zig (hot-reloading)
fn unloadGameDll() !void {
if (game_dyn_lib) |*dyn_lib| {
dyn_lib.close();
game_dyn_lib = null;
} else {
return error.AlreadyUnloaded;
}
}
fn recompileGameDll(allocator: std.mem.Allocator) !void {
const process_args = [_][]const u8{
"zig",
"build",
"-Dgame_only=true", // This '=true' is important!
"--search-prefix",
"C:/raylib/zig-out",
};
var build_process = std.ChildProcess.init(&process_args, allocator);
try build_process.spawn();
// wait() returns a tagged union. If the compilations fails that union
// will be in the state .{ .Exited = 2 }
const term = try build_process.wait();
switch (term) {
.Exited => |exited| {
if (exited == 2) return error.RecompileFail;
},
else => return
}
}
I'll need to update the call-sites of these to handle the errors.
// main.zig (inside the main loop)
if (c.IsKeyPressed(c.KEY_F5)) {
unloadGameDll() catch unreachable;
recompileGameDll(allocator) catch {
std.debug.print("Failed to recompile game.dll\n", .{});
};
loadGameDll() catch @panic("Failed to load game.dll");
gameReload(game_state);
}
I'm handling the errors in three different ways there. For unloadGameDll
I can see by the logic of the code it should never attempt to unload a dll when it is already loaded, so unreachable
is stating that intent there. For recompileGameDll
, if that fails I want the game to continue running, it will simply go on to load the original dll.
Now when I re-build and run I can hit F5 and see in console that it is loading game.dll again.
Putting the game back together
// game.zig
const std = @import("std");
const c = @cImport({
@cInclude("raylib.h");
});
const GameState = struct {
allocator: std.mem.Allocator,
time: f32 = 0,
radius: f32 = 0,
};
const screen_w = 400;
const screen_h = 200;
const config_filepath = "config/radius.txt";
export fn gameInit() *anyopaque {
var allocator = std.heap.c_allocator;
var game_state = allocator.create(GameState) catch @panic("Out of memory.");
game_state.* = GameState{
.allocator = allocator,
.radius = readRadiusConfig(allocator),
};
return game_state;
}
export fn gameReload(game_state_ptr: *anyopaque) void {
var game_state: *GameState = @ptrCast(@alignCast(game_state_ptr));
game_state.radius = readRadiusConfig(game_state.allocator);
}
export fn gameTick(game_state_ptr: *anyopaque) void {
var game_state: *GameState = @ptrCast(@alignCast(game_state_ptr));
game_state.time += c.GetFrameTime();
}
export fn gameDraw(game_state_ptr: *anyopaque) void {
var game_state: *GameState = @ptrCast(@alignCast(game_state_ptr));
c.ClearBackground(c.RAYWHITE);
// Create zero terminated string with the time and radius.
var buf: [256]u8 = undefined;
const slice = std.fmt.bufPrintZ(
&buf,
"time: {d:.02}, radius: {d:.02}",
.{ game_state.time, game_state.radius },
) catch "error";
c.DrawText(slice, 10, 10, 20, c.BLACK);
// Draw a circle moving across the screen with the config radius.
const circle_x: f32 = @mod(game_state.time * 50.0, screen_w);
c.DrawCircleV(
.{ .x = circle_x, .y = screen_h/2 },
game_state.radius,
c.BLUE
);
}
fn readRadiusConfig(allocator: std.mem.Allocator) f32 {
const default_value: f32 = 10.0;
// Read the text data from a file, if that fails, early-out with a default value.
const config_data = std.fs.cwd().readFileAlloc(allocator, config_filepath, 1024*1024) catch {
std.debug.print("Failed to read {s}\n", .{ config_filepath });
return default_value;
};
// Attempt to parse that text data into a float and return it, if that fails,
// return a default value.
return std.fmt.parseFloat(f32, config_data) catch {
std.debug.print("Failed to parse {s}\n", .{ config_filepath });
return default_value;
};
}
So a few things to note. As the main exe is passing the game state back to us through an *anyopaque
, our game logic needs to cast that back to the actual GameState
type, which is why you see:
var game_state: *GameState = @ptrCast(@alignCast(game_state_ptr));
Also inside gameReload
I also read the config file again in-case that got updated.
So let's re-build and run and... oh no. The dreaded...
Segmentation fault at address 0x0
Cast your memory back to the first build. I mentioned about building Raylib as a shared library or not. The problem here is that the main exe embeds Raylib and then the DLL also embeds it's own copy of Raylib inside, so now we have Raylib residing in two different memory locations. But Raylib works on shared state, so this isn't going to work.
The solution is to build Raylib as a shared library (making it a DLL on Windows). That way the Raylib states lives in one place in memory, and both the EXE and game.dll can use that.
Going back to the Raylib directory, I re-build the project but this time with -Dshared=true
. Then I need to copy the dll file to the game's project directory. Now my project will run.
I can run the game, go to the code, change something, say the colour of the ball, then go back to the game and hit F5. The game will freeze for a moment as it recompiles and loads in the new DLL.
I can also tweak the ball's radius in the configuration file, hitting F5 then is much faster, as zig's build system knows nothing needs to be recompiled, so all that is happening is the dll is re-opened and the config file re-read.
Exercise for the reader
There's more stuff I could do to make this ergonomic, but I'll leave these for you to figure out ;)
Is to watch the modified time on the configuration files inside main, if they've been modified, trigger a reload.
Is to recompile the DLL on a seperate thread to avoid the game freeze. This isn't as trivial as it looks as you'll need to unload the DLL before you can overwrite it with the new one. So the build system would need to be tweaked to write the game DLL to a temporary file, then you can unload, overwrite the DLL from the temporary one, and re-load.
Is to draw the output of the compilation on-screen, maybe in a custom debug window or in-game console.
Top comments (5)
Nice. You could also create a new file every time you recompile the DLL (say,
game_<timestamp>.dll
), so that you can (in this order) recompile, unload the DLL, copy / rename / update a symlink (on linux) the new DLL, reload it. That way the pause would really be brief.Thank you for sharing, any reason why you don't just add Raylib as a dependency to your build.zig.zon and have one zig build command do everything at once?
I've been wondering the same. That's what I do and it works wonderfully well.
I only had to run
zig fetch --save=raylib https://github.com/raysan5/raylib/archive/<hash>.tar.gz
and add the following to build.zig:For completeness, the above works when I gave the raylib dep a
.shared = true
as well (in place of the original guide's-Dshared
).Thanks for sharing! It sounds very interesting to me, and I'm itching to try it!