Hot Reload
There are three separate reload mechanisms, they work on different things, and none of them is a substitute for the fourth option — which is usually the right one.
| Mechanism | Replaces | Triggered by |
|---|---|---|
| Game-assembly reload | all of DigitalHeaven.Game | the reload console command, fired automatically on a file change |
| Shader reload | every renderer pipeline, from recompiled GLSL | saving a .vert/.frag/.glsl, or shaders.reload |
| .NET hot reload | individual method bodies, anywhere | saving a .cs file while running under watch.bat |
| Preferences | nothing — the value is simply read again next frame | a console line, or the settings screen |
Start here: is it a number?
Section titled “Start here: is it a number?”If what you want to change is a tuning value — a threshold, a size, a gain, a color — then neither reload path is the answer, and reaching for one will waste your time.
A const is inlined by the compiler into every call site. The running process never reads the field you edited, so a hot-reload delta of the file that declares it changes nothing observable. A static readonly is no better: its initializer already ran and is not re-run.
Tuning values belong in a preference, which applies on the very next frame with no reload of any kind:
] client.audio.fallWind.startSpeed 9] client.debug.axisGizmoPlate 0] world.render.exposure 2.4That is the fastest loop the engine has — faster than either reload path, because nothing is rebuilt at all. When you find yourself editing a constant twice, that constant wants to be a preference.
Game-assembly reload
Section titled “Game-assembly reload”DigitalHeaven.Game — the server-side gameplay module — is loaded into a collectible AssemblyLoadContext from an in-memory copy, so the DLL on disk is never locked. reload drops the old context, loads the rebuilt assembly and re-registers its systems; entities persist, only the code behind them is replaced. EmbeddedServer watches the game project and fires reload for you on a change.
This path has none of the limits below, because the whole assembly is swapped rather than patched — new fields, new types, changed signatures all survive.
Its limit is scope. The game assembly is the server-side gameplay module: map install, the console commands over it, and its systems. Client rendering, audio, UI and the netcode all live outside it and are not affected by reload.
Shader reload
Section titled “Shader reload”Save a shader under Engine/DigitalHeaven.Engine.Client/Shaders/ and, about half a second later, the frame is drawn with it. Nothing is typed and nothing is relaunched.
] shaders.reloadThe command exists so the same path can be run without touching a file. Unlike a save it answers even when nothing needed recompiling.
What happens on a save. The write burst is collapsed by a 400 ms debounce — the same one the map and game-source watchers use — and then, on a worker thread, every stage whose SPIR-V is older than its source (or than any .glsl helper it includes) is recompiled by the in-repo DigitalHeaven.Engine.ShaderCompiler, the same tool the build runs. Only the swap happens on the frame thread: the device is waited idle, every renderer pipeline is replaced, and the frame after that is the new one. The stall is a device idle over two frames in flight, not a load.
A shader with a typo costs a console line. The rebuild is a transaction, in this order:
- Compile every stale stage into a scratch directory. A failure stops here.
- Create every shader module and every pipeline into locals, while the live ones keep drawing.
- Only once all of them exist: wait for device idle, swap the fields, destroy the old ones.
So a failure at step 1 or 2 destroys whatever the attempt built and returns, and the next frame draws exactly what the last one did. The compiler’s own file(line): error DHSC00n: message diagnostics print into the console verbatim, a toast says so with the console closed, and the next good save recovers with no relaunch. Freshly compiled SPIR-V is only published — only becomes what the engine reads — after every pipeline has been built from it, so what is on disk is always a set that is known to work.
What it does not cover. The Halcyon and ImGui backends build their pipelines from private shader libraries of their own; an edit to halcyon_*.frag or imgui.frag is compiled and published by the same path but takes effect the next time those backends are constructed. And nothing checks that a shader still matches PipelineLayouts: change a binding index or a push-constant offset and the module compiles, the pipeline builds, and the frame renders garbage with no diagnostic. That is equally true across a rebuild-and-relaunch — the reload does not make it worse — but it is the one edit the loop cannot tell you about.
It is a development affordance and only exists in one. The shipping path is the SPIR-V embedded in DigitalHeaven.Engine.Client by the build, exactly as before. The disk override and the watcher are gated on a DEBUG build and on finding the client project’s Shaders/ directory above the running assembly, so a Release build — and any Debug copy outside the checkout — starts no file watcher, launches no compiler, and reads no shader from disk. --noShaderReload opts out of the watcher in a dev build; the console command then reports that it is off.
.NET hot reload
Section titled “.NET hot reload”Engine/scripts/watch.bat runs the host under dotnet watch, so saving a file applies the change to the running process. It watches the whole transitive source set, not just the host project, so an edit anywhere in the engine is picked up.
watch.bat singleplayer, embedded serverwatch.bat --map night-city same, opening straight into a mapApplies live: method bodies. Most logic edits — a different formula, an extra branch, a reordered call — land without a restart.
Does not apply:
constvalues andstatic readonlyinitializers, per the section above.- Adding or removing a field, changing a method signature, changing a type’s shape.
dotnet watchcalls these rude edits and prompts in its terminal window before restarting; the script leaves it interactive on purpose, so a rude edit cannot kill a playtest without asking. - Anything already captured in a live object. An ImGui font atlas, a decoded audio buffer: the code that builds them changes, the thing already built from the old code does not. Recreating those is what a restart is for — except for renderer pipelines, which have their own path above.
The rule of thumb
Section titled “The rule of thumb”Logic hot reloads. Numbers should be preferences. Shape changes need a restart.
Widening the boundary
Section titled “Widening the boundary”Where a change could have been reloadable and was not, that is worth fixing rather than working around. Two standing preferences, in order:
- Prefer a preference to a constant for anything a person would plausibly want to tune. It costs a
Preference<T>declaration and aGetat the point of use, and it converts a rebuild into a console line. The client render and audio settings are already built this way. - Prefer the game assembly for logic that does not own device resources. Code that lives there gets whole-assembly reload for free, with none of the patching limits.
Neither is a rule to apply retroactively across the codebase — but when new code is being written, the reloadable shape is rarely more work than the non-reloadable one, and it compounds.