Odin · hot reload

Hot reload, four levels deep

A program split in two: a small host that owns the game's persistent state in its own memory, and a shared library that holds the gameplay code. The host loads the library, calls its procedures each frame, and watches its file on disk. Rebuild the library and the host swaps in the new code without losing the state. Step through one swap.

The state lives in host memory; the library is reloadable. Step the swap below — the state box never moves, only the code does.
host .exe
stable — never restarts
for should_run() {
  update()
  poll lib file
  if changed → swap
}
calls
game library
reloadable — your edits
@(export) game_update
@(export) game_memory
@(export) game_hot_reloaded
… enemy AI, physics

The persistent state (size_of)

Game_Memory {
  frame     : u64   = 8
  counter   : int   = 8
  last_reload : f64   = 8
  allocator  : Allocator = 16
}

size_of(Game_Memory) = 40

What you're seeing

Level 1 was what it is. Level 2 is why you pay for the split: the loop from "change a number" to "see the result" collapses from a full rebuild-and-relaunch down to save → see.

Most of building a game is tuning — jump height, damage falloff, camera-shake intensity, spawn cadence. The right value is a feel question, answered by trying it, looking, and trying again, hundreds of times. The cost of one tweak is dominated by how long until you see it. Compare the two loops for a single tweak:

rebuild + relaunch the whole program

rebuild the library only — host keeps running

The emergent property: because the state lives in the host and only the library is rebuilt, every step that re-establishes the situation — open the window, click through the menu, load the save, walk back to the spot you were testing — happens once and then never again for the rest of the session. You tweak with the character mid-jump, the enemy mid-chase, the exact scene already on screen. The feedback loop is short enough that you tweak twenty times and keep the one that feels right, instead of tweaking twice and shipping the first one that isn't wrong.

The counterfactual: if your code were a fixed set of values you never tune — or a tool you run once and read the output — none of this earns its keep. You'd just rebuild and run. The split pays off precisely when you re-run the same code against the same evolving state over and over, changing one line between runs.

Why the alternatives are worse

push every tunable out into a data file the running program re-reads
jump.cfg
damage.cfg
camera.cfg
spawn.cfg
…and the loader for each
every new tunable needs a file entry, a parser, and a field to bind it to — before you can try it.

Rebuild and relaunch every tweak. Correct, simple, and slow — the per-tweak cost is the whole startup path, paid hundreds of times. This is the loop the split exists to kill.

Move every tunable into an external data file the program re-reads at runtime. Now numbers reload live — but only the numbers you predicted. The moment a tweak is structural (a new branch, a reordered update, a different AI rule) the data file can't express it, and you're back to rebuilding. You also pay up front: each tunable needs a file format, a parser, and a binding, written before you can adjust it. Reloading the code needs none of that — any edit you can compile is live, structural or not.

Build a command console into the game and re-type tweaks each session. The values evaporate when you close the window, and a console can only reach the hooks you exposed. It's a second, weaker authoring surface bolted next to your real one — more code, narrower reach, and nothing survives the next launch.

Level 2 sold you on the split. Level 3 is the bill, and it's a single hard invariant: the persistent state's layout must stay stable across a swap. The new code reinterprets the same bytes the old code wrote — so both versions have to agree, byte for byte, on what those bytes mean.

The host holds one allocation of Game_Memory and never re-makes it. When the new library reads g_mem.counter, it reads a fixed byte offset into that allocation. If you append a field, reorder fields, or retype one, the offsets move — and the new code now reads counter out of bytes that used to be something else. The shipped struct is 40 bytes (Level 1). Toggle an edit and watch the size move:

old code's view
new code's view

How the host catches it: the library exports its own size — game_memory_size :: proc() -> int { return size_of(Game_Memory) } — and the host compares the new library's number against the old one's before committing the swap. Same number, swap proceeds. Different number, the host refuses the swap and waits for a manual restart rather than reinterpret the old bytes through a layout that no longer fits them. That one comparison is the guardrail that turns a whole class of silent corruption into a clean, loud stop:

// in the host loop, before committing the swap:
if next.memory_size() != api.memory_size() {
    // 40 -> 48: fields added/removed/retyped, so the old
    // allocation can't be reinterpreted. Skip the swap.
    fmt.eprintfln("Game_Memory shape changed; skipping swap")
} else {
    api = next
    api.hot_reloaded(ptr)   // safe: layouts agree
}
three more things that survive compilation but break the bytes The size check catches anything that changes how big the struct is, but a few edits keep the size and still corrupt meaning, so the discipline is "append-only, never reorder." Reordering fields of the same total size slips past a size check — the bytes line up but mean different things. Reordering an enum's values remaps which stored byte means which variant, so state typed as that enum decodes wrong. And storing a procedure value in the state points at code inside the old library; once that library is gone, calling through it jumps into reclaimed memory. The rule that dodges all three: keep the state plain data, add fields only at the end, and never store a pointer into the library in the bytes that outlive it.

The last level is the property the whole split exists to produce: state outlives code. What carries across a swap is not a copy of the world — it's a single address, an 8-byte number, and the bytes it points at never move.

Walk the swap once more, as data. The old library hands the host the state pointer; the host loads the new library, whose g_mem global starts out nil; the host calls game_hot_reloaded(ptr), and the new code writes that same pointer into its g_mem. From that instant, g_mem.counter in the new code reads the exact bytes the old code was writing — no serialize, no migrate, no copy. Acted out in one process, the counter climbs to 240, the swap happens, and the new code reads it back unchanged:

// "old code" ran 240 frames, then we swap:
ptr := game_memory()        // the state pointer (a rawptr)
g_mem = nil                 // new library's global starts empty
game_hot_reloaded(ptr)      // hand it the SAME pointer

before reload: counter=240
after reload:  counter=240
same bytes?    true

The emergent payoff: the persistent struct lives in the host, so the library can be torn down and rebuilt under it without the world flinching — the window stays open, the player keeps their position, the enemies keep chasing. And the procedures the host calls during this dance are the same @(export) procedures the shipping build calls directly: one path opens the library and binds those names at runtime, the other calls them as ordinary package procedures, and the gameplay code is byte-identical between them. There is no "remove the hot-reload scaffolding before you ship" step — the fast-iteration architecture is the release architecture.

That's the arc: L1 a stable host owns the state, a swappable library owns the code, and a rebuild loops the new code back in → L2 that split shrinks the tune-and-look loop to save → see → L3 the price is a fixed layout, since new code reinterprets old bytes — guarded by a size_of check → L4 only an address crosses the swap, so state outlives code, and the same exported procs ship unchanged.

probes reproduce with odin run · the size_of(Game_Memory)=40, the 40→48 shape change, the contract compiling, and the counter surviving the swap are real compiler output (claims/lessons/18-hot-reload-tour). The live reload itself is described from this repo's lab host (lab/src/main_hot_reload.odin).