Hot Reload
There are two separate reload mechanisms, they work on different things, and neither is a substitute for the third option — which is usually the right one.
| Mechanism | Replaces | Triggered by |
|---|---|---|
| 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.
The protocol version
Section titled “The protocol version”NetProtocol.Version is the number a client and a server must agree on before a join is allowed, and it travels with the binaries: gameplay is compiled into the host, so there is no half of a build that can be redeployed on its own.
The current version is 1, and it is meant to stay there. It names the wire FORMAT, not the fields: since the reset, each side sends the hash of its wire schema at join and reads the other’s messages by field name, so a field added, removed or moved, a message added or a material row reordered no longer moves the number. What still does is a change to the schema mechanism itself — the connect request, the two schema messages, the envelope, the schema’s own spelling — or a field whose meaning changed under the same name, which is better given a new name instead.
The count before the reset reached 80 (NetProtocol.LastNumberedVersion), when the snapshot header gained its WorldId. A build from that era is refused with both numbers spelled into the disconnect reason, and the server logs that the client predates the schema exchange; see a player on another build.
Map hot reload
Section titled “Map hot reload”The embedded host watches every pallet the loaded map is made of — its own, and every material, texture and model pallet it reads through — and reloads the map when dh build republishes any of them. Rebuilding a material a map uses is enough; nothing about the map’s own source has to change.
One build is one reload, however many of the map’s pallets it touched and however long it takes. Every compile run (dh build, Studio’s builds, the in-game editor’s) leaves a build marker beside the pallets it writes: .dh-build-<buildId>.json, written as building when the run starts and rewritten as complete when it ends, pass or fail. While a marker says building, the engine collects the pallet changes it sees and reloads nothing; once the run is complete and client.pallets.buildSettleMs (default 1500 ms) has passed, so the last pallet’s own file notification has landed, it reloads once for all of them. A marker whose process is gone, or that is older than client.pallets.buildHoldMaxMs (default 15 minutes), is ignored, so a killed build never freezes reloading. A build that pins its own stamp (dh build --core) writes into the repository’s content tree and leaves no marker.
A change with no build around it, such as one pallet copied in by hand, has no marker to wait for. Its notifications restart a sliding debounce window (client.pallets.reloadDebounceMs, default 500 ms) and the reload fires once, after the window closes with nothing new. A pallet that is not in the loaded map’s closure reloads nothing at all. Every pallet that contributed is counted and named in the one log line:
pallet 'dev.example.materials.harbor' rebuilt; hot-reloading 'dev.example.maps.harbor:harbor.dh-map', which depends on it2 pallets 'dev.example.materials.harbor', 'dev.example.maps.harbor' rebuilt; hot-reloading 'dev.example.maps.harbor:harbor.dh-map'The compiler’s own skip-if-unchanged write (see CLI: unchanged output) is what keeps this window narrow in practice: a dependency dh build left untouched never reaches disk, so it never republishes and never contributes a notice — a dh build on one map file-watcher-triggers a reload only for the pallets in its dependency chain that actually changed.
A rebuild landing while a reload is already running is never dropped and never stacked: it is folded into a single follow-up reload once the in-flight one finishes.
A map reload does not reload the people and props in it. When the rebuilt map is the one already on screen, every worn avatar and spawned object keeps drawing the model it had and is read again behind the swap; nothing falls back to the stand-in for a model that was already showing. An object whose materials use the scene’s detail table is the exception: its rows index the table of the scene that just left, so it starts over under its stand-in. A different map is a change of place and starts every object over, as before.
Avatar and spawned-object hot reload
Section titled “Avatar and spawned-object hot reload”Everything the map path does, applied to what a map is not: the avatar a player is wearing, and the objects spawn put in the world. Same watcher, same catalog, same client.pallets.reloadDebounceMs window — a dh build that rewrites a map pallet and an avatar pallet together is still one burst and one settle.
What differs is that this half asks whether the bytes actually moved. The map reloads because a pallet FILE was written. An avatar cannot afford that: re-advertising a worn avatar makes every other client in the world re-read it, so a dh build with no source change must cost nothing. Each rebuilt pallet’s header is re-read and its per-asset checksums — the same CompiledFileEntry.Checksum the texture cache already decides residency with — are compared entry by entry against the ones the session was holding. Only the assets whose checksums moved are reported:
pallet 'dev.example.avatars.mayu-custom' rebuilt; 2 asset(s) changed: textures/mayu-body.dh-texture, textures/mayu-face.dh-texturepallet.reload: 'dev.example.avatars.mayu-custom' reloaded 3 thing(s)An avatar whose textures changed and whose mesh did not keeps its mesh: the mesh’s checksum did not move, so the asset cache and the texture uploader both hand the reload the copies they already hold.
The worn avatar re-advertises itself. The client offers the rebuilt closure through the same WornPallets path a fresh pick uses, and the server accepts a changed closure for a barcode it already holds, drops the stale one, and re-broadcasts — so every other player pulls the new bytes over the ordinary avatar transfer. The decision is keyed on the closure’s content hashes, never on the barcode: a rebuild keeps the barcode and moves the hashes, and a re-offer of identical hashes sends nothing, so a server already mid-upload is never disturbed.
Spawned instances are rebuilt in place. The entity keeps its id, its transform and its asset-table slot; only ReplicatedAsset’s revision channel moves, which is the cue every client binds its object cache to. That is the same mechanism spawn.reload drives by hand, applied to every instance in the rebuilt pallet at once:
] pallet.reload dev.example.avatars.mayu-customThe console verb is the server half, and it is what the detecting client submits. It takes operator authority like the other spawn.* verbs, so a pallet recompiling on your machine can only reload the world your machine owns.
A failed rebuild keeps what is loaded. Nothing here destroys anything: a revision bump is a request to re-read, and a pallet caught mid-write or left broken cannot be fingerprinted at all, so the baseline is untouched, nothing is told to re-read, and the copy on screen stays on screen. The toast names the failure.
A toast reports every reload that changed something, naming the pallet and how many assets moved — the notification queue, so it is readable with the overlay closed.
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 shape the map watcher uses, though the map watcher’s own window is the tunable client.pallets.reloadDebounceMs (see Map hot reload) rather than a fixed 400 ms — 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 Halcyon.Graphics.ShaderCompiler (under Halcyon/), 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 HSC00n: 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_* stages belong to Halcyon.Vulkan, not to the client, so the watcher never sees them: the backend loads them from its own embedded resources, and an edit to one takes effect after a rebuild. 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 repository root above the running assembly (the client project’s Shaders/ directory beside the built Halcyon.Graphics.ShaderCompiler), 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 harbor 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. A baked 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.
Startup-only data root
Section titled “Startup-only data root”DH_ENGINE_DATA_ROOT redirects the engine-owned user-data root for an isolated desktop or
headless process. Set it before launching to a fully qualified absolute directory path,
for example /tmp/engine-session on macOS or D:\\EngineSessions\\test on
Windows. The value is the complete root: the engine does not append DigitalHeaven/Engine.
With the variable unset, the default remains the OS local-application-data directory plus
DigitalHeaven/Engine.
The root is captured once when EngineEnvironment initializes at startup. It is not a
preference, does not hot reload, and cannot switch a running session’s storage. Empty,
whitespace-only, relative, and malformed paths fail initialization with an error naming
DH_ENGINE_DATA_ROOT; they never silently fall back to the default. Paths are normalized
using the host OS’s path rules, without expanding ~ or embedded environment variables.
Directory creation and write-permission checks remain with the normal storage consumers.
The derived locations stay together: config/ (including profile.toml, autoexecs and
launch presets), logs/, cache/, benchmarks/, demos/, and sceneEdits/. Explicit
output paths still win where supported: offscreen-render logs remain beside the render
output, and explicitly chosen benchmark/demo paths are unchanged. This does not redirect
source pallets, the workspace, SDK caches, or unrelated application data.
Use a dedicated engine-data directory, not the source workspace: the engine’s cache is disposable. Setting the variable does not copy, move, or delete existing data. Use a separate directory per isolated process when settings and logs must not be shared.
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 a shape that survives a method-body patch. Logic whose state lives in the world rather than in a long-lived object graph is logic
dotnet watchcan edit under you; a value baked into a constructed object needs a restart to see a change.
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.