A practical guide to implementing hotreloading in Rust for game development, demonstrating a host/worker architecture that preserves state between reloads while avoiding the pitfalls of dynamic dispatch and invalid function pointers.
The promise of hotreloading—changing program behavior without restarting—has long been a developer fantasy. While many game engines offer hotreloading for data files, scripts, or shaders, the ability to hotreload actual Rust code remains elusive. The traditional solution involves exposing a Rust API to a scripting language like Lua or Python, but this introduces significant overhead: a separate language ecosystem, memory model mismatches, and a calcified API layer that becomes a maintenance burden. What if we could use Rust itself as the scripting language? This article demonstrates how to build a hotreloading system where Rust code can be modified and reloaded at runtime, with state preservation, using a host/worker architecture.
The core insight is to split the application into two parts: a persistent host that manages state and external communication, and a reloadable worker that contains the game logic. The worker is compiled both as a dynamic library (.so) and a static library (.rlib), allowing hotreloading during development and static linking for release. Communication happens through a trait object implemented by the host and passed to the worker. Since Rust lacks a stable ABI, this approach relies on using the same compiler and flags for both components, ensuring ABI compatibility. This is strictly a development tool, not a plugin system.
The initial setup involves a workspace with three crates: base for shared types and the communication trait, host for the main application, and worker for the reloadable logic. The worker's Cargo.toml specifies both cdylib and lib crate types to produce both dynamic and static libraries. The host depends on the worker only optionally, behind a staticlink feature flag. For the demonstration, Macroquad is used as a lightweight game library to provide a window and rendering context.
Communication is defined through a Context trait in the base crate, exposing methods like draw_text and is_pressed for input handling. The host implements this trait, while the worker exports an unmangled update function that receives a mutable reference to the trait object. This function is marked with #[unsafe(no_mangle)] and #[allow(imper_ctypes_definitions)] because exposing Rust trait objects across the FFI boundary is technically unsafe but works reliably given the shared compiler.
Loading the dynamic library at runtime is handled by the libloading crate. The host loads the .so file, retrieves the update function symbol, and stores it alongside the library handle to prevent unloading. A peculiar workaround is needed for glibc: defining __cxa_thread_atexit_impl to bypass the check that prevents unloading libraries using thread locals. This leaks thread-local storage on reload but is acceptable for development. For static linking, the host directly calls the worker's update function.
The basic hotreloading loop uses cargo watch to rebuild the worker on source changes. The host monitors the .so file's creation timestamp and reloads it when it changes. The WorkerWrapper struct manages the lifecycle, dropping the old library and loading the new one. This enables stateless hotreloading, where each reload starts with a fresh worker instance.
However, many game mechanics require persistent state between reloads. To achieve this, the host must hold state that survives library unloading. A naive approach of storing state in the worker fails because the memory becomes invalid when the library is unloaded. The solution is to pass a type-erased pointer wrapped in a PersistWrapper struct, which holds a raw pointer, size, and alignment. The worker creates this state via an exported hot_create function, and the host manages it across reloads.
But this introduces a critical problem: dynamic dispatch. Trait objects like Box<dyn Trait> contain a vtable pointer that points into the unloaded library. After reload, this pointer becomes invalid, leading to segfaults. Avoiding dynamic dispatch entirely is possible but impractical; it requires meticulous avoidance of traits and reliance on enums or ECS architectures, which is error-prone and unsustainable for large projects.
The elegant solution is to serialize the state before reload and deserialize it after. Using a fast serialization library like nanoserde, the worker exports before_reload and after_reload functions. Before unloading, the worker serializes the game state to a JSON string and stores it in the PersistWrapper. After reloading, it deserializes the string back into a Rust struct. This ensures all pointers are valid and eliminates the risk of dangling vtables. The host calls these functions during the reload process.
This approach has additional benefits: it enables easy implementation of quicksave/quickload features and allows dumping the game state for analysis. However, it requires explicit marking of serializable state, typically via derive macros. For production save games, you would need versioning and selective serialization, but for development, full serialization is sufficient.
Several practical tips emerge from this architecture. Incremental compile times are crucial; slow builds defeat the purpose of hotreloading. Minimize dependencies, avoid deep generic chains, and use fast linkers like mold. Place slow-compiling dependencies in the host and expose handles to the worker. Thread-local storage should be managed by the host, as it leaks on reload. Immediate mode APIs (recomputing values each frame) work better with hotreloading than retained mode APIs. For concurrency, keep async or multithreaded code in the host to simplify worker reloads. Adding a panic handler in the worker's update function can prevent crashes from taking down the entire process.
Other hotreloading approaches exist. subsecond by the Dioxus team offers experimental hotreloading with a focus on reducing linking time. relib provides a similar host/worker model with additional safety features. For simpler needs, inline tweak allows hotreloading specific values without architectural changes.
In conclusion, hotreloading Rust code is not only possible but practical for game development. By combining a host/worker architecture with serialization-based state preservation, developers can enjoy rapid iteration without the overhead of external scripting languages. The trade-offs—careful state management and serialization overhead—are outweighed by the productivity gains. As the author notes, hotreloading is a "memetic hazard": once experienced, it becomes indispensable. The complete example is available on GitHub, providing a foundation for building games with live code reloading.


Comments
Please log in or register to join the discussion