An Idea
I used to think hot reloading was something only “managed” languages could do, not “compiled” languages. When I change a React component it can update on the page; when I change a line of code in a Rust daemon I have to kill the program and bring it back up. This makes writing games in the “compiled” languages somewhat painful. Games tend to be highly stateful; if a bit of behavior only triggers in a particular state, it can be annoying to return to that state after relaunching the game. For example, if you’re tweaking font size in a dialogue system, you’ll need to re-initiate a dialogue each time you change the code.
Last year I had a realization while working on my hobby programming language (called Felt). Felt compiles to WebAssembly (WASM); in WASM the code for your application is entirely separate from its linear memory[1]. It’s really easy to replace the code and keep the memory the same. As long as your program can still interpret that memory the same way[2], this gives you hot reloading for free. I made a demo in Felt where a single keystroke recompiled the game and reloaded it live, allowing you to change all the rendering or game logic without having to relaunch or replay anything.
This year I’ve been working on Felt a lot less. Instead I’m currently making a small game in Rust. Pretty early on I ran into some font layout bugs in my new game engine, and wanted seamless code reloading. Is such a thing possible?
It is, if we take advantage of dynamic libraries. There are two ways to link binary blobs of code together: static linking (where we concatenate the binaries into one final product) and dynamic linking (where the libraries are found on-disk each time the program launches). By default Rust statically links crates together, which means we can’t easily load in a new version of our game’s logic. But if we dynamically link the crates, we can reload the dynamic library.
The “Easy” Way
At first I tried the hot-lib-reloader
crate. It seemed promising, and has a nice API; take this example from their docs:
#[hot_lib_reloader::hot_module(dylib = "lib")]
mod hot_lib {
// Reads public no_mangle functions from lib.rs and generates hot-reloadable
// wrapper functions with the same signature inside this module.
// Note that this path relative to the project root (or absolute)
hot_functions_from_file!("lib/src/lib.rs");
// Because we generate functions with the exact same signatures,
// we need to import types used
pub use lib::State;
}
fn main() {
let mut state = hot_lib::State { counter: 0 };
// Running in a loop so you can modify the code and see the effects
loop {
hot_lib::step(&mut state);
std::thread::sleep(std::time::Duration::from_secs(1));
}
}
That macro automatically handles a bunch of binding work for us. Unfortunately, it also doesn’t work. Rust 2024 requires that the #[no_mangle]
attribute be decorated as unsafe
(because it allows a bunch of link-time badness), so it now looks like #[unsafe(no_mangle)]
. hot-lib-reloader
doesn’t understand this syntax. There are some open PRs to add support, but I don’t really want to get that far into the weeds. Instead I think it’s best if we cut through the magic and do the bindings ourselves.
The Simple Way
I have my game’s logic defines in a separate crate called game-core
, with the following stanza in its Cargo.toml
to make it a dynamic library:
[lib]
crate-type = ["rlib", "dylib"]
The lib exports some functions that look like this:
use async_ffi::LocalFfiFuture;
struct GameState {
// ...
}
#[unsafe(no_mangle)]
pub fn init(venus: &'static mut Venus) -> LocalFfiFuture<Box<GameState>> {
// ...
}
#[unsafe(no_mangle)]
pub fn tick(venus: &mut Venus, game_state: &mut GameState) -> bool {
// ...
}
We have a function to load everything and initialize our game state (boxed so that the caller doesn’t need to know its size), and then a function that advances our game by a frame.
Then the dev executable looks like this:
use async_ffi::LocalFfiFuture;
use libloading::{Library, library_filename};
use venus::{Color, Venus};
struct GameState;
type Init = unsafe extern "C" fn(venus: &mut Venus) -> LocalFfiFuture<Box<GameState>>;
type Tick = unsafe extern "C" fn(venus: &mut Venus, game_state: &mut GameState) -> bool;
fn main() {
Venus::run(
async |venus| game(venus).await.unwrap(),
venus::Settings::default(),
);
}
async fn game(mut venus: Venus) -> anyhow::Result<()> {
let filename = library_filename("game_core").to_owned();
let filename = filename.to_str().unwrap();
cargo(&["build", "-p", "game-core"])?;
let path = format!("target/debug/{filename}");
let mut lib = unsafe { Library::new(&path)? };
let mut init: libloading::Symbol<Init> = unsafe { lib.get(b"init")? };
let mut tick: libloading::Symbol<Tick> = unsafe { lib.get(b"tick")? };
let mut game_state = unsafe { init(&mut venus).await };
loop {
unsafe {
if !tick(&mut venus, &mut game_state) {
break Ok(());
}
}
if venus.is_key_pressed(venus::Key::F5) {
venus.clear(Color::WHITE);
venus.end_frame().await;
cargo(&["build", "-p", "game-core"])?;
lib = unsafe { Library::new(&path)? };
init = unsafe { lib.get(b"init")? };
tick = unsafe { lib.get(b"tick")? };
}
if venus.is_key_pressed(venus::Key::F6) {
game_state = unsafe { init(&mut venus).await };
}
venus.end_frame().await;
}
}
fn cargo(args: &[&str]) -> anyhow::Result<()> {
println!("Building project...");
let exit_status = Command::new("cargo").args(args).spawn()?.wait()?;
if exit_status.success() {
Ok(())
} else {
anyhow::bail!("Failed to compile project")
}
}
If you press F5, the screen clears out white and the game is re-compiled and then reloaded. If you press F6, the game restarts. I was pretty surprised at how simple and easy this was! At first I was thinking about setting up a watcher to re-compile the game lib, but compilation is fast enough (~0.5-2 seconds) that it’s worth doing the simple thing and compiling each time we reload. That way I know we’re always running the most up-to-date version.
There are caveats, the major one being that any kind of static state in the game-core
lib won’t persist between reloads. This rules out using a library like macroquad
which has a lot of implicit global state. I happen to be crotchety and prone to writing my own game engines regardless, so that doesn’t bother me. It also breaks font rendering in my game, for reasons I haven’t yet debugged (when you reload, characters that haven’t been rasterized and cached yet render as a square instead). Otherwise it’s been a nice experience to program a game with decent hot-reloading built in, without needing to add a second scripting language to my project.
This isn’t a Rust specific trick, by-the-by. The basic principles here are just as applicable to languages like C, C++, Zig, or Hare: essentially any language that compiles to native libraries can do much the same thing.
That is to say it does not follow the von Neumann Architecture that most other programs do, with a flat address space for both code and data. ↩︎
Mostly this means you can’t change any of your data structures’ layouts. ↩︎