PVG
22. Hot Reloading — Program Video Games

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 library

  • reload_count: How many times we've reloaded (used for unique filenames)

  • last_write_time: When the DLL was last modified

  • Function pointers: init, update, and render

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:

  1. Create the shared package: Move Game_State into a separate package

  2. Create the raylib_api package: Copy the Raylib wrapper

  3. Split your code: Separate the cradle (window management, loading) from game logic

  4. Update build scripts: Create separate builds for cradle and game

  5. Export game functions: Add @export to your update and render procedures

The exact package structure is up to you. Options include:

  • package cradle and package game

  • package main with -file flag for both

  • package rpg_game for 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.