22. Hot Reloading
Every time you make a change to your game code, you close it, recompile it, restart it, navigate back to where you were testing, and finally see if your change worked. This cycle wastes countless hours during development.
Hot reloading eliminates this friction. Change your code, save it, recompile, and watch the changes appear instantly in your running game - no restart required.
The Fractal Code Cycle Applied to Hot Reloading
Recall our fundamental pattern:
Input → Processing → Output
For our hot reloading system:
Input: Modified DLL file, game state from the cradle
Processing: Detect file changes, copy and load new DLL, update function pointers
Output: Game continues running with updated code
What Is Hot Reloading Good For?
Rapid Iteration
Tweak gameplay values instantly
Adjust visual feedback in real-time
Test different approaches without losing progress
Debugging
Add debug visualisation without restarting
Modify collision detection whilst testing
Experiment with fixes immediately
Level Design
Adjust entity positions live
Tune difficulty parameters on the fly
Prototype mechanics interactively
The Cradle and DLL Architecture
Hot reloading works by separating our game into two parts:
The Cradle
The cradle is the executable that stays running. It:
Owns the window and rendering context
Manages the game state memory
Loads and reloads the game DLL
Provides function pointers to Raylib
The Game DLL
The game DLL is dynamically loaded code that:
Contains all game logic (update and render)
Can be recompiled whilst the cradle runs
Operates on state provided by the cradle
Never owns its own memory (except string literals)
Think of the cradle as a shell that holds the game DLL. When you compile a new DLL, you drop it into the cradle, and the cradle connects its wires (function pointers) to the new code.
The Game API Structure
We define a struct that represents our interface to the game code:
Game_API :: struct {
lib: dynlib.Library,
reload_count: int,
last_write_time: os.File_Time,
game_init: proc(gs: ^shared.Game_State, rl: ^raylib_api.Raylib_API),
game_update: proc(gs: ^shared.Game_State, rl: ^raylib_api.Raylib_API),
game_render: proc(gs: ^shared.Game_State, rl: ^raylib_api.Raylib_API),
}
Key Components:
lib: The loaded dynamic libraryreload_count: How many times we've reloaded (used for unique filenames)last_write_time: When the DLL was last modifiedFunction pointers:
init,update, andrender
The Reload Process
Step 1: Detect Changes
Check if the DLL has been modified:
if dll_write_time, dll_write_time_err := os.last_write_time_by_name(DLL_PATH_FULL);
dll_write_time_err == nil {
if dll_write_time != game_api.last_write_time {
if !game_api_reload(&game_api) {
// Don't call into the DLL until it's loaded
continue
}
}
}
Step 2: Copy the DLL
We can't overwrite a DLL whilst it's loaded by the OS, so we make a copy with a unique name:
dll_copy_path_full := fmt.tprintf("%s%s_%d%s", DLL_PATH, DLL_NAME, api.reload_count, DLL_EXT)
if copy_err := os2.copy_file(dll_copy_path_full, DLL_PATH_FULL); copy_err != nil {
return false
}
This creates files like game_0.dll, game_1.dll, etc.
Step 3: Load Symbols
Load the function pointers from the new DLL:
if symbol_count, symbol_init_ok := dynlib.initialize_symbols(api, dll_copy_path_full);
!symbol_init_ok {
// Most likely error is 'TOO SHORT'
// as in, the file is still being written
return false
}
Step 4: Update Metadata
Record the successful reload:
if dll_last_write_time, dll_last_write_time_err := os.last_write_time_by_name(DLL_PATH_FULL);
dll_last_write_time_err == nil {
api.last_write_time = dll_last_write_time
} else {
fmt.printfln("Failed to read last write time of `%s`", DLL_PATH_FULL)
return false
}
api.reload_count += 1
Why Keep Old DLLs?
Notice we never delete old DLLs. This is intentional.
String literals and constants live in the DLL's read-only memory segment. If you delete a DLL whilst pointers still reference its strings, those pointers become invalid and will crash your program.
By keeping old DLLs around until the application closes, we ensure all references remain valid.
The Shared Game State
Game state must be allocated by the cradle and passed to the DLL:
gs := new(shared.Game_State)
game_api.game_init(gs, &rl_api)
Critical Rule: The game DLL cannot own memory.
All dynamic allocations must use the cradle's allocator. This ensures:
Memory remains valid after hot reloads
Dynamic arrays continue working
No memory leaks from abandoned DLL allocations
The Raylib API Wrapper
Raylib has thread-local context that causes issues with DLLs. Our solution: wrap all Raylib functions in a struct and pass it to the game.
The Raylib API Structure
Raylib_API :: struct {
// Constants
BLACK: Color,
RED: Color,
// ... etc
// Procedures
InitWindow: proc(width, height: i32, title: cstring),
CloseWindow: proc(),
DrawRectangle: proc(x, y, width, height: i32, color: Color),
// ... hundreds more
}
Initialisation
The cradle creates this struct once:
raylib_api.init :: proc() -> Raylib_API {
return Raylib_API{
// Constants
BLACK = rl.BLACK,
RED = rl.RED,
// ... etc
// Procedures
InitWindow = rl.InitWindow,
CloseWindow = rl.CloseWindow,
DrawRectangle = rl.DrawRectangle,
// ... etc
}
}
This took about five minutes to create by copying from Raylib's bindings and reformatting with an editor.
The Game Side
From the game DLL's perspective, everything looks normal:
package main
import "core:fmt"
import "shared"
import "raylib_api"
@export
game_init :: proc(gs: ^shared.Game_State, rl: ^raylib_api.Raylib_API) {
// Initialisation code
}
@export
game_update :: proc(gs: ^shared.Game_State, rl: ^raylib_api.Raylib_API) {
// Update logic
}
@export
game_render :: proc(gs: ^shared.Game_State, rl: ^raylib_api.Raylib_API) {
rl.ClearBackground(rl.BLACK)
rl.DrawRectangle(10, 10, 100, 100, rl.RED)
}
The @export directive makes these functions available to the cradle through dynamic linking.
Build Scripts
Building the Cradle
Windows (build_cradle.bat):
mkdir build
del /Q /S .\build\*
odin build main.odin -file -out:build/debug.exe -debug
Linux/Mac (build_cradle.sh):
rm -rf build
mkdir build
odin build main.odin -file -out:build/debug -debug
Building the Game
Windows (build_game.bat):
odin build game.odin -file -build-mode:dll -out:build/game.dll -debug
Linux/Mac (build_game.sh):
odin build game.odin -file -build-mode:dll -out:build/game.so -debug
Note the -file flag treats each file as a separate package, even though they're both in package main.
Platform-Specific Extensions
The system detects the operating system and uses the appropriate DLL extension:
when ODIN_OS == .Windows {
DLL_EXT :: ".dll"
} else when ODIN_OS == .Darwin {
DLL_EXT :: ".dylib"
} else {
DLL_EXT :: ".so"
}
Early Returns for Safety
The reload function uses early returns to handle partial failures:
game_api_reload :: proc(api: ^Game_API) -> (ok: bool) {
// If file copy fails (compilation might not be finished)
if copy_err := os2.copy_file(dll_copy_path_full, DLL_PATH_FULL); copy_err != nil {
return false
}
// If symbol loading fails (file might be incomplete)
if symbol_count, symbol_init_ok := dynlib.initialize_symbols(api, dll_copy_path_full);
!symbol_init_ok {
return false
}
// If we can't read the timestamp
if dll_last_write_time, dll_last_write_time_err := os.last_write_time_by_name(DLL_PATH_FULL);
dll_last_write_time_err == nil {
api.last_write_time = dll_last_write_time
} else {
return false
}
// Success
api.reload_count += 1
return true
}
This prevents crashes when:
The compiler is still writing the DLL
The file system is slow to update
The DLL is corrupted or incomplete
The Main Loop
The cradle's main loop checks for updates every frame:
for !rl.WindowShouldClose() {
// Check if DLL changed
if dll_write_time, dll_write_time_err := os.last_write_time_by_name(DLL_PATH_FULL);
dll_write_time_err == nil {
if dll_write_time != game_api.last_write_time {
if !game_api_reload(&game_api) {
continue // Skip this frame if reload fails
}
}
}
game_api.game_update(gs, &rl_api)
rl.BeginDrawing()
game_api.game_render(gs, &rl_api)
rl.EndDrawing()
}
If the reload fails, we skip calling the game functions to avoid crashes from invalid function pointers.
Integration into Your RPG
To integrate hot reloading into your RPG:
Create the shared package: Move
Game_Stateinto a separate packageCreate the raylib_api package: Copy the Raylib wrapper
Split your code: Separate the cradle (window management, loading) from game logic
Update build scripts: Create separate builds for cradle and game
Export game functions: Add
@exportto your update and render procedures
The exact package structure is up to you. Options include:
package cradleandpackage gamepackage mainwith-fileflag for bothpackage rpg_gamefor the game code
Hot Reloading in the System Stack
Hot reloading exists outside the traditional system stack as a development tool. It doesn't affect the final game but dramatically improves the development experience.
However, it does impose constraints on system design:
Memory ownership must be clear
State must be serialisable
Systems must handle function pointer updates
These constraints actually encourage better architecture.
The Big Idea
Hot reloading transforms game development from a cycle of "change, restart, test" into "change, test" - removing the restart entirely.
By separating our game into a stable cradle and a hot-reloadable DLL, we can iterate on gameplay, visuals, and mechanics in real-time without losing our testing state.
The investment in setting up hot reloading will pay itself back a hundredfold throughout development, especially when tuning gameplay feel, adjusting values, and debugging complex interactions.