Engine Overview
DigitalHeaven.Engine is a standalone game engine written in C#. It shares the DigitalHeaven repository but is its own runtime — not a mod, not built on Unity, and independent of the pallet/asset toolchain documented elsewhere on this site.
Philosophy
Section titled “Philosophy”Movement first. The engine chases Source-engine game feel. Player movement — walking, sprinting, crouching, air-strafing — is the first feature, not an afterthought, and the first milestone is a gray-box map that is fun to move around in.
Multiplayer first. Every session is a networked session. Singleplayer is an embedded server on loopback: the same code path with one player connected. There is no separate offline mode to keep working.
Hot-reloadable gameplay. Gameplay code lives in its own assembly, loaded through a collectible AssemblyLoadContext. Edit C#, rebuild, and the engine swaps the assembly in without restarting. This is a day-one constraint that shapes the whole architecture, not a feature bolted on later.
Baked lighting as identity. The visual north star is baked global illumination: MonoSH directional lightmaps, light-probe volumes for dynamic objects, and box-projected reflection probes. It arrives in stages — see the roadmap.
The Stack
Section titled “The Stack”| Layer | Choice | Version |
|---|---|---|
| Runtime | .NET, JIT only (no NativeAOT) | net10.0, C# latest |
| Windowing & input | Silk.NET | 2.23.0 |
| Graphics | Vulkan via Silk.NET — dynamic rendering only, no legacy render passes; four ordered passes per frame — sun shadow cascades, then the scene into a linear HDR offscreen target, then the tonemap resolve, then the UI; MoltenVK on macOS | 2.23.0 |
| Physics | Box3D — native, with our own interop layer | pinned commit |
| ECS | Friflo.Engine.ECS | 3.6.0 |
| Networking | LiteNetLib | 2.1.4 |
| Audio (later) | miniaudio + Steam Audio | 4.8.1 (Steam Audio) |
| Textures (later) | BCn compressed formats | — |
All versions are pinned exactly. Conventions throughout: units are meters, coordinates are Y-up right-handed with -Z forward, and the simulation runs at a 60 Hz fixed tick. The large-world coordinate architecture fixes coordinate scale as an immutable per-World creation contract: M2 remains Compact, and a future Large profile can be added without widening every Compact entity.
Projects
Section titled “Projects”Solid arrows are project references. The dashed edge is a runtime load — the Host never project-references the gameplay assembly, which is what makes hot reload possible.
graph BT Contracts["DigitalHeaven.Engine.Contracts"] Engine["DigitalHeaven.Engine"] Physics["DigitalHeaven.Engine.Physics"] Physics["DigitalHeaven.Engine.Physics"] Net["DigitalHeaven.Engine.Net"] Client["DigitalHeaven.Engine.Client"] Host["DigitalHeaven.Engine.Host"] Game["DigitalHeaven.Game"] Tests["DigitalHeaven.Engine.Tests"]
Engine --> Contracts Physics --> Contracts Net --> Engine Net --> Contracts Client --> Engine Client --> Contracts Host --> Engine Host --> Contracts Host --> Physics Host --> Net Host --> Client Game --> Engine Game --> Contracts Tests --> Engine Tests --> Contracts Host -. loads at runtime via collectible ALC .-> Game- Contracts — schema and shared vocabulary: component structs, IDs, interfaces. The hot-reload boundary.
- Engine — the headless simulation core: ECS worlds, explicit lifecycle, and fixed ticks.
- Physics — the only assembly that knows Box3D exists. The rest of the engine speaks the
IPhysicsWorld/ trace vocabulary. - Net — versioned session protocol, replication codecs, loopback and LiteNetLib transports, and the composite transport that fans one session host across both for a listen server.
- Client — window, input, network client, and the Vulkan renderer.
- Host — the executable. Boots the engine and loads the gameplay assembly at runtime. Also houses client-side prediction and reconciliation for the local player’s pawn.
- Game — hot-reloadable gameplay code.
- Tests — xUnit tests for Engine and Contracts.
Listen Server
Section titled “Listen Server”Singleplayer is an embedded server on loopback (see above). The listen server is the same embedded server with a real UDP socket bound alongside the loopback, so a player’s own windowed game can host friends without a separate dedicated process. Two archived server.* preferences govern it:
server.open(bool, defaultfalse) — whentrue, the windowed game also binds a UDP socket onserver.portso network clients can join.falsekeeps it loopback-only: pure singleplayer, no port bound. Read once at launch; changing it takes effect the next launch (it is not a hot toggle).server.maxPlayers(int, default1, min1) — total player capacity including the local host, which connects first over loopback and counts as one. So4admits the host plus three remotes;1(the default) is host-only, a closed server. The local host is always admitted regardless of the limit. A remote refused for capacity is disconnected with a “server is full” reason.
Under the hood a CompositeServerTransport presents the loopback end and the UDP socket to a single NetServer — one poll loop, one session map. It namespaces each underlying transport’s peer ids into a disjoint band so their independently-issued handles never collide, and routes each reply back to the owning transport. The NetServer is unaware there is more than one transport. A remote peer joining a listen server that lacks its map pallet flows through the normal map-replication path (advert → hash-skip or chunked transfer → reload); only the loopback host is skipped by the map broadcast, because it reloads in-process.
The dedicated server (--headless) is unchanged: it binds a bare UDP transport with no loopback and stays uncapped (server.maxPlayers is a listen-server rule).
Session lifecycle
Section titled “Session lifecycle”A client is always in exactly one of two resting places: playing on one server, or disconnected. Everything in between is an attempt that resolves into one of the two — never a half-live state that a late packet can revive.
connect <host>[:port]is terminal for whatever came before it. A live session is detached and an attempt still in flight is canceled, both before the new attempt starts, so the connection being replaced can never knock over the one replacing it. The old connection’s disconnect arrives later, from the transport, and is deliberately swallowed rather than reported as a drop.connectwith no argument retries the last endpoint — the server a drop lost, not a default one. Before any connect has been made it falls back to this machine on the default port.- A target that cannot be reached (refused, unreachable, timed out) produces exactly one console line naming it —
connect: could not reach host:port (reason)— and settles in Disconnected. No retry loop, no per-message warnings, and no re-admission to the server that was left. disconnectannounces the departure once, at the moment it is decided, and also cancels an attempt that has not completed — an explicit disconnect ends interest in the endpoint entirely. It lands the player at the main menu, and so does any other way a session ends: a server that drops or closes, and a connect that could not be completed.
The connection sequence
Section titled “The connection sequence”Four messages, in one fixed order, and the last one is the client saying who it is:
| # | Message | Direction | Carries |
|---|---|---|---|
| 1 | connectRequest (1) | Client → server | The client’s protocol version, and nothing else. |
| 2 | serverInfo (2) | Server → client | The server’s version, tick rate, world id and name, and the current map as (name, contentHash). |
| 3 | clientInfo (20) | Client → server | The player’s display name and packed 0xRRGGBB UI seed color. |
| 4 | clientReady (4) | Client → server | ”The map is loaded, spawn me” — answered with spawnAck (5). |
- The opening message is the smallest thing on the wire. It is the one a server accepts from a peer it knows nothing about, so it carries nothing a mismatched or hostile build could make the server allocate against. A version mismatch is settled at step 2, before a player’s name has crossed the wire in either direction.
clientInfois what admits the session. Receiving it is the moment the server learns the name, resolves the identity it keys admin standing on, and moves the peer to loading the map. Before it lands the server knows nothing about the player — so nothing that reads a name, an identity or an operator standing is accepted from, or sent to, a peer still in the handshake.- The order is the guarantee. Because
clientInfogoes out on the reliable-ordered channel the instantserverInfoarrives, the server knows who a peer is before it hears another word from it — a map request, a forwarded console line, a ready. - Renaming the pair and moving the player’s details out of the opening message bumped
NetProtocol.Version28 → 29.
The loading box
Section titled “The loading box”The box belongs to a map arriving, not to a join. Every way a map becomes the map the player is standing in goes through one frame-sliced job and shows this card: a remote join, the local world this process starts for itself, a typed map, a server changing map under a connected client, and the pallet watcher noticing that the loaded map was rebuilt. There is no state in which the engine is busy and silent, and no path on which a map is swapped inside a single frame.
The one deliberate exception is the two offscreen hosts — --render and --benchmark. They have no window, no frame loop to slice against and no surface to paint a box on, and both exist to produce a deterministic result, so they open their map synchronously through OfflineHost.OpenMap and always will. The engine’s very first map is the other: it is opened during host construction, before the window presents a frame, so there is nothing yet to paint over.
It is one Halcyon card, anchored bottom-right on the toast margins. It carries a title, a step counter, one line naming the current step, a thin progress bar and a footer with a Cancel button.
One line, not a log. The box shows the step it is on and nothing else — a scrolling history is a thing to read, and this is a thing to glance at. The title is the endpoint until the server names its map, at which point the map takes the title and the endpoint drops to the footer; neither line ever repeats the other. A switch knows its map from the first frame, so its title is the barcode immediately.
The counter is honest or absent. A cache hit takes two fewer steps than a download does, so until serverInfo settles that question there is no total to print and none is printed. Once the plan is resolved it is never re-planned: a total a player read a second ago stays the total, whatever a later caller believes. The two arrivals that touch no wire settle their plan on the frame they open.
| Arrival | Steps | What it drops |
|---|---|---|
| Join that downloads | 11 | Nothing. |
| Join that hash-skips | 9 | The download and the verify. |
| Local start | 6 | The dial, the reply and the cache check as well. |
| Map switch or hot reload | 4 | All of the above, plus the tail — a client already playing announces no readiness and waits for no first snapshot. |
The switch’s plan is short because it is honest: a typed map downloads nothing and shakes nobody’s hand, and the snapshots never stopped arriving, so it is loading {map} → building collision hulls → uploading textures → preparing the renderer and no more. Its footer says switching the map, or reloading the map when the same map was rebuilt underneath the player.
| # | Line | Progress |
|---|---|---|
| 1 | connecting to {host}:{port} | Indeterminate — the socket reports nothing until it binds. |
| 2 | waiting for a reply from the server | Indeterminate. |
| 3 | checking the server map against the cache | Indeterminate. |
| 4 | downloading the map ({done} of {total} MB) | Real — bytes received against bytes advertised. |
| 5 | verifying the map's content hash | Indeterminate. |
| 6 | loading {map} | Indeterminate — one parse, not a divisible one. |
| 7 | building collision hulls ({done} of {total}) | Real — colliders cooked against colliders planned. Reads building the collision mesh when the map ships one merged concave mesh instead, which is a single step with nothing to count. |
| 8 | uploading textures ({done} of {total}) | Indeterminate today; see below. |
| 9 | preparing the renderer | Indeterminate. |
| 10 | telling the server we are ready | Indeterminate. |
| 11 | waiting for the first snapshot | Indeterminate. |
A local start skips steps 1–5 and opens with starting the local world; a switch or a hot reload runs steps 6–9 only.
The tail step names what is actually sent. Under protocol 29 clientInfo rides out the instant serverInfo arrives, long before any map is loaded — so the last thing the client says before snapshots is clientReady, and the line says so.
One bar for the whole join, weighted by rough step cost and monotonically non-decreasing: a late poll carrying fewer bytes, or a step report arriving out of order, cannot move it backward, because a reading that retreats reads as work being undone and nothing in a join ever is. Where nothing is measurable it sweeps instead of sitting at a number it made up.
A failure freezes the box where it stood rather than clearing it: the bar stops and grays out, the step line is replaced by the connect: failure string verbatim — the same words the console gets, not a second wording of the same event — and the button’s verb becomes Close, which returns to the main menu. The menu stays suppressed until it is closed, so the reason is never buried under the screen it leads to.
Cancel over a join is the ordinary disconnect. There is one teardown path, and Cancel, the pause menu’s Disconnect and a typed disconnect all take it. ESC over the box opens the ordinary pause menu, and the box keeps painting underneath it.
Cancel over a switch cancels the switch, not the session. There is a world here already and it is still perfectly good, so the job is dropped at the boundary it reached and the player stays in the map they were standing in — the same thing that happens to the map a canceled join was going to replace. The server is never asked to switch, because it is only ever asked from the load’s commit.
A switch is not a loading state. ClientUiState.Loading is fed from the box’s visibility for arrivals that have no world behind them. A mid-session switch has one — the old map, still meshed, right up to the commit frame — so the cursor stays locked, the crosshair stays up, gameplay input stays live and the vignette does not ramp. Freeing the pointer and dimming the world on every pallet rebuild would make the map-authoring loop worse than the freeze it replaced. ESC still reaches the pointer if the box’s Cancel is wanted.
A join is a session with no world in it
Section titled “A join is a session with no world in it”The session begins the instant a connect is asked for, so the interaction state says Playing while the frame behind the box is still empty. Everything that belongs to a world rather than to the program has to know the difference, and one flag is what tells it: ClientUiState.Loading, set from the box’s own visibility once a frame.
- The cursor is free and visible, exactly as at the main menu and the pause menu.
- Gameplay input is gated, because freeing the pointer without gating the look is what leaves mouse-look warping a cursor somebody is trying to use.
- No crosshair. There is nothing to aim at.
- No speedometer. The player-facing HUD asks
ClientUiState.HudVisible, which is there is a world and nothing more — so a join draws no speed readout, exactly as the main menu draws none. It is the only other place the HUD and the reticle agree: everywhere a world exists, the speedometer stays and the reticle leaves. - No world-anchored developer surface. The axis gizmo, the position readout and the entity gizmos are all off —
InSessionmeans there is a world, and during a join there is not one yet. - No lens vignette and no pause scrim, for the same reason the main menu has neither: shading the corners of an empty frame is a dark border around a loading box. The player’s
client.render.vignetteis never written — only the session ramp that scales it moves, so the setting comes back untouched with the world.
Cursor lock is a property of the state, not an act performed at a transition. There is exactly one place that decides it — ClientUiState.CursorVisible — and the host asks it once a frame and applies the answer. That is what makes pressing ESC over a join and then resuming leave the pointer free: the loading term is still true on the other side of the round trip, so there is nothing for a resume to undo. The link set the menu shows is a separate question: a join in flight offers Resume and Disconnect, because there is a session to leave.
The one thing leaving the loading state does is ask the host to absorb an input frame, the same way a window drag ending does — the pointer moved while it was free, and none of that motion is a look delta.
The load is sliced across frames
Section titled “The load is sliced across frames”A map load is a frame-sliced state machine ticked from the same frame path as everything else — Parse → Collision → Upload → Commit — not a thread and not a Task.Run. Slicing is what lets the box paint at all: a synchronous load renders one frame at the start and the next when it is over. There is exactly one of these jobs, and every arrival above builds one, which is what stops the box from being a decoration on the join path.
- Parse opens the pallet, builds the collision data and starts a resumable prediction rebuild.
- Collision advances that rebuild by
client.load.hullsPerFramecolliders per frame (default64), reporting real progress as it goes. - Upload hands the pallet to the renderer. Not yet sub-sliced — the texture upload is still one frame — which is why step 8 sweeps rather than counts.
- Commit publishes the map to the host and answers the server.
Cancel is checked between slices, never inside one. The job holds no live host state until Commit, so abandoning it at any boundary releases the staged prediction world and the still-unopened pallet and leaves the map on screen untouched. Every loading frame declares itself to the frame-stall guard, so the delta the next frame is handed is clamped rather than delivered whole.
A second map supersedes the first rather than racing it. A map typed while a load is running, or a rebuild landing mid-load, cancels the job in flight and starts a new one — the half-built world it was carrying describes a map nobody is going to be standing in. What the superseded job was owed survives it: a join still waiting in loadingMap is readied up by whichever load actually lands, and anything gated on a load ending (the hot-reload watcher’s own gate) is released on the cancel rather than left shut. A switch landing inside a join’s box continues that box’s story instead of restarting it halfway.
client.net.connectTimeout (default 10 seconds) bounds an attempt that never answers. It fires as the ordinary could-not-reach disconnect — the same console line, the same landing at the menu — rather than as a failure mode of its own.
The world dissolves and is unloaded
Section titled “The world dissolves and is unloaded”A session ending takes the world with it. The map dithers away over about a second and is then genuinely released — the main menu is left over the sky, not floating above a world nobody is standing in.
- Every session end runs it, because it is wired to the one session teardown rather than to any particular cause: a typed
disconnect, the menu’s Disconnect, a server that dropped or shut down, a kick, a timeout, and a connect that never landed all dissolve identically. - It is a dither, not a fade. The opaque shader discards pixels against a screen-space threshold at the top of
main, so the surviving pixels are lit exactly as they were at rest and the vanished ones are simply not there — the world never dims, goes unlit, or fades toward black on its way out. The threshold is the same interleaved gradient noise every other dither in the renderer uses (ditherDissolvedindither.glsl), so there is one dither aesthetic across the engine rather than two, and because it is a pure function of the pixel coordinate a pixel dies once and stays dead — which is what stops the temporal resolve from smearing the dissolve back into a soft alpha fade. - Only the map dissolves. The sky, the developer surfaces and the UI sit outside the opaque pass, which is what leaves the menu over a sky rather than over a hole.
- The pause treatment leaves with it. The menu’s full-viewport dim and the lens vignette belong to a session, so they ramp off alongside the dissolve rather than popping the instant the session ends —
client.ui.sessionFadeis how long that takes, defaulted toclient.render.worldDissolveso the screen clears on one clock, and0there is instant. The pause menu and the main menu are the same screen; the scrim, the vignette and the world-anchored developer gizmos are what separates them. client.render.worldDissolveis how long it takes, in seconds (default1, max10). It applies live through one frame-uniform lane, so a change is visible on the very next disconnect with nothing rebuilt.0is a real off switch: the world is released on the same frame the session ends, with no animation at all.- The release is genuine. The render scene goes whole — every vertex and index buffer, every texture its materials uploaded, the lightmap, the irradiance volume’s storage buffer, the collider and hull wire meshes, and the open pallet handles the map was read from — along with the client’s prediction physics world and its cooked collision, and this host’s memory of what was loaded (including the content hash, so the next join re-meshes from the server’s advert instead of hash-skipping onto a map the client no longer has).
- The set-1 descriptor slots go back too. The scene hands every per-material descriptor it asked for back to the texture registry, rather than leaving it to whichever texture happens to be destroyed — see who retires a descriptor set, and when. Without that, a material with no authored texture channels would leak one slot of a finite pool on every single map load, and a long session of server-hopping would eventually run it dry.
- A new session always wins, immediately. Nothing about a connect waits for an old world to finish leaving: the dial happens at once, and the session’s begin edge — plus any map actually meshed — abandons the dissolve where it stands. The map still loaded snaps back whole rather than freezing half eaten, and the abandoned dissolve can never come back later and unload the map that replaced it. Reconnecting to the same map keeps the scene it already has, exactly as a mid-session switch does.
- A disconnect with the world already gone does nothing — no dither, no release, no console noise. There is nothing to dissolve.
Switching servers at runtime
Section titled “Switching servers at runtime”Which server a client is on is a session decision, not a launch decision. An all-in-one client — the default windowed game, which runs its own embedded server — can connect somewhere.else:27015 and go, with no relaunch; from the main menu it starts a local world again. --connect remains a launch convenience for joining a server outright, and nothing more.
- One path handles every case.
connect= leave whatever you are on, then join the named endpoint. Leaving a remote server closes a socket; leaving the embedded server shuts it down whole — universe, entities, physics and tick thread — because a world nobody is in must not go on simulating. Local → remote, remote → remote and remote → local are the same operation, repeatable for as long as the process lives. - The local world is an endpoint like any other. It is named by the reserved port
0(Conventions.EmbeddedPort); every real UDP port is 1–65535, so there is no ambiguity. A connect to it boots a fresh embedded server, which is why coming back from a remote server starts a clean local world rather than resuming a stale one. - A connect that fails leaves nothing running. The old session is given up to make the attempt — that is what “terminal for whatever came before it” means — so a failed connect lands at the main menu with the one
could not reachline. Start Game builds a fresh local world from there: the fallback is a menu, never a broken process. - Nothing crosses between sessions. The replica store, local pawn, map advertisement, command manifest, scene-edit revision, prediction and chat feed are all cleared the instant a connect is issued, so a stale entity or map hash from the previous server cannot bleed into the next one. The map on screen is re-meshed whenever the new server’s content hash differs from what is loaded.
mapfollows the server. Switching maps means switching the server’s map, so it is offered only while this client is the one hosting. Having left for a remote server,mapresolves the barcode and says the map is the server’s — the same answer a--connectclient has always given — and a pallet recompiling no longer hot-reloads anything.
Under the hood the client holds one transport for its whole life — a SwitchableClientTransport — whose underlying transport is replaced on every connect by an endpoint resolver (ClientSessionRouter), which owns the embedded server while the answer is “this machine’s own world”. The session above it cannot tell the floor moved: a disconnect the client asked for is still delivered even when the transport that owed it is already gone, and a resolver that cannot produce a transport at all surfaces as that same one-line connect failure rather than an exception out of a console command.
Player numbers are roster slots, not a running count. A number is freed when its pawn despawns and reclaimed by the same identity on a rejoin, so reconnecting keeps the number you had instead of climbing the roster every cycle. Numbers are unique among the players currently spawned; a number read off a stale pawn names whoever holds the slot now, so ownership is always resolved through the live pawn.
Chat is server-authoritative, on the same principle as everything else that several people have to agree about. A client sends only the text it typed (clientChat); the server decides whether it may be said, stamps who said it and which roster slot they hold, and reliably broadcasts one serverChat to everyone. A client is never trusted with its own name, so nobody can speak as somebody else — or as the server. The two message ids bumped NetProtocol.Version 25 → 26.
That single broadcast message carries four kinds of line, which is exactly why they can never disagree between two screens:
| Kind | Where it comes from |
|---|---|
player | A line somebody typed. |
server | The say console command. |
join | Generated server-side when a session finishes loading and starts playing. |
leave | Generated server-side when a playing session disconnects. |
Join and leave are roster events, not client courtesy messages. They are emitted from the same slot bookkeeping described above, so a rejoin reads as one ordinary join into the reclaimed slot rather than a ghost duplicate, and a peer that drops during the handshake or a map transfer — one that never reached playing — is never announced at all, in either direction. A leaver is out of the session map before the leave goes out, so the notice reaches everyone still connected and never the person who left.
What the server enforces on arrival, regardless of what the sender already checked:
- Length. Capped at
ChatLimits.MaxMessageChars(200) characters, and the sender’s display name at 32. Truncation is surrogate-safe, so a line ending in an emoji arrives whole or not at all rather than as a replacement glyph. - Control characters are stripped, not escaped — a newline would let a player forge a second line in the feed and in the server’s log, and an ANSI escape would reach past the renderer into whatever terminal is tailing that log.
- Nothing empty gets through. A line that is only whitespace or only control characters is dropped rather than broadcast as a blank row.
- Flood control is a per-session token bucket: 4 lines back-to-back, refilling at 0.75 lines per second. Bursty rather than flat on purpose — finishing a thought across three quick lines is a thing people do, sending one line every second forever is a thing scripts do. Idle time never banks more than the burst, and one peer flooding never spends anyone else’s tokens.
say <message> broadcasts as the server. It is authority-gated rather than hidden: an operator can use it from a remote console, and a plain client that reaches it is refused inside. Everything else applies — the line is sanitized and capped exactly like a player’s, and say on a server nobody is connected to says so rather than pretending it went somewhere.
Chat has its own log channel, chat, on both ends: the server logs every line it broadcasts, and a client logs every line it receives, both through ServerChat.ToLogLine() so the log and the on-screen feed word the same event identically. Because it is an ordinary category it shows up in the console’s logger filters, and a headless server — which has no feed to draw — still has a complete chat transcript in its log.
A client tells the server its interface color, as a packed 0xRRGGBB on clientInfo — the last step of the connection sequence — and again on clientThemeColor whenever the seed preference changes mid-session. The resolved triple rides the wire rather than the preset’s ordinal, so a server that predates a preset added later still stores a color it can use. Today the server only stores it on the session and replicates nothing — it exists so that a name in the feed can eventually be drawn in the color its owner picked, which is a decision that has to be made server-side or two screens will disagree about it. The pair bumped NetProtocol.Version 26 → 27.
The client side is a Halcyon surface up the left of the screen, resting a third of the viewport clear of the bottom edge rather than hugging it — a column that breathes, not a status bar. Newest line at the bottom, above where a HUD would go. Lines hold at full opacity and then fade out; opening the prompt reveals the whole scrollback again and holds it there while you type.
The closed feed and the open prompt are ONE tree, and opening moves nothing. The input row is mounted and reserving its height even while chat is closed (an invisible Layer still lays its child out), the surface is anchored by its bottom edge, and the scrollback is pinned to its end — so the lines you were reading during play stay at exactly the pixels they were at, and opening only adds the field below them, the plate behind them, the rest of the history above them, and the keyboard. Opening is what the flag changes; layout is not.
There is no open-but-unfocused state. Clicking away dismisses chat outright rather than leaving a prompt up that nothing can reach, and Escape always closes it. Either way the draft is kept and the caret comes back at its end — sending is the only thing that empties the field. Closing plays the entrance in reverse rather than cutting.
History is a bounded ring of 256 lines on the client — presentation state with no authority and no entity that owns it, so a queue is the honest model rather than a component. Fade timing runs off a monotonic client clock, not a server timestamp: how long a line has been sitting in front of you is the only thing a fade can honestly mean, and a server stamp would make a line that arrived late fade early or arrive already gone.
Everything about how it looks and how long it lingers is a client.chat.* preference, applied on the next frame: holdSeconds (12), fadeSeconds (1.5), lines (6 in the passive feed), scrollbackLines (128), scrollbackHeight (240), width (560), leftInset (24), bottomFraction (0.34 — the gap to the bottom edge as a fraction of viewport height, so the column sits in the same place at every resolution instead of drifting toward the bottom as the screen gets taller), opacity (0.55) and timestamps (off). These are local taste and are never networked — what may be said and how often is the server’s business and lives where no console line can reach it.
Console preference verbs
Section titled “Console preference verbs”Preferences are addressed by their dotted path, either bare (client.fov reads, client.fov 90 writes) or through the script-sugar verbs get, set and reset, which are exactly equivalent for a single path.
The verbs also understand a namespace — an interior node of the path tree, such as client.debug, which is not itself a preference but has preferences beneath it (the same branch autocomplete offers with a trailing dot):
get client.debuglists every preference beneath the namespace, one per line in the ordinarypath = value - helpformat.reset client.debugrestores every preference beneath it to its default and prints a count plus the affected lines (reset client.debug: restored 10 preferences to defaults), eliding the list past twelve entries.set client.debug 1is an error: a namespace has no single value, so the console names a few leaves to use instead rather than guessing one.- A path that is neither a preference nor a namespace stays an error (
Unknown preference: client.debugNope) — the console never silently no-ops.
Matching is on segment boundaries, so client.debug sweeps client.debug.colliders but never the sibling leaf client.debugSoundCues (see Debug Overlays for the whole family). The worlds.<slug> form works too, sweeping that world’s copies of the world.* rules.
A namespace reset is gated per leaf, exactly as resetting each leaf by name would be: a leaf that requires a world context the caller lacks, or that is cheat-gated with cheats off, is skipped and named with the same reason the single-path form would have given (skipped world.gravity: 'world.gravity' is a world preference; …), while the permitted leaves are still reset. The sweep can neither bypass a gate nor fail silently.
Startup autoexec
Section titled “Startup autoexec”Archived preferences persist across launches, which is exactly wrong for a few sim-critical world rules: after tuning world.gravity (or any movement value) for a test, that value would silently carry into the next session. The fix is an autoexec config, run at startup after the saved profile loads, that re-asserts the sim defaults so every launch boots into sane values unless you deliberately override.
The server boot sequence is profile-then-autoexec: profile.toml loads first (restoring every saved preference), then the world autoexec runs and wins. The autoexec is a plain, user-editable cfg exec’d through the ordinary console, seeded on first run and never overwritten afterward:
- File:
<config>/world.autoexec.cfg(per-domain by name —world.autoexec.cfgtoday, room forserver.autoexec.cfg/client.autoexec.cfglater).<config>is the engine config directory (%LocalAppData%\DigitalHeaven\Engine\config). - Covers: the persisted, non-cheat-gated world sim tunables —
world.gravityand everyworld.*movement value (accelerate,airAccelerate,friction,stopSpeed,maxSpeed,sprintSpeed,crouchSpeed,jumpHeight,airCap,surfaceFriction,stepHeight,noclipSpeed,autoBunnyHopping,coyoteTime,slopeLimit), plus thephysicsProptunables (world.prop.friction,world.prop.groundProbe,world.prop.pushSpeed,world.prop.pushResponse,world.prop.pushReach). Every line is areset <path>, never a frozen number — so a file generated months ago still boots into whatever the current engine default is, and a later retune reaches it. This is the whole point: writingworld.sprintSpeed 8into the file would pin a stale default forever. - Does not touch: any non-sim preference (client display/audio/crosshair,
server.*,world.cheats) — those persist normally.world.timeScaleis already transient (never saved) and cheat-gated, so it boots at1.0on its own; the autoexec documents it in a comment rather than setting it. - Making a tweak permanent: edit
world.autoexec.cfg(change or delete a line). Replace areset world.gravityline with the value form —world.gravity 12— and that is what the world boots into; delete the line entirely and the saved profile value stands.
Two launch flags override the startup config (server modes only — a --connect client has no server to configure):
--noAutoexec— skip the built-in world autoexec entirely; the saved profile’s sim values are used as-is.--exec <file>— execute an extra cfg after the autoexec. A custom cfg may itselfexec world.autoexecand then apply overrides, or be paired with--noAutoexecto replace the built-in autoexec completely.
Running a config file is quiet
Section titled “Running a config file is quiet”A command typed by a person answers with its automatic confirmation: reset world.gravity prints what it reset, because the person who typed it is owed a reply. A command replayed from a config file does not. Without that split, a twenty-line autoexec wrote twenty untagged confirmations into the middle of the startup log every launch.
The boundary is exec itself — not the reset verb — so nothing about typing at the console changes. Inside a file:
- Suppressed: the automatic confirmation of a read or a write. A bare path,
get,setandresetall apply silently, including a namespacereset’s summary block. - Never suppressed: problems. An unknown name, a refused (cheat-gated or world-less) write, a value the preference rejected — each still prints, because silencing a cfg’s chatter must never silence its mistakes. A leaf a namespace
resethad to skip prints on its own line rather than under the summary that was suppressed. - Never suppressed:
echo,helpandfind. These are deliberate requests for output, not confirmations —echois how a cfg says something on purpose.
Each file that runs writes exactly one properly tagged, colorized log line on the console channel, naming the file and what it did:
[20:57:33.412] [INF] [console] exec world.autoexec.cfg: 21 command(s) appliedWhen something failed, the line is a warning instead and names the split — the failing lines themselves have already printed their own messages:
[20:57:33.412] [WRN] [console] exec mytweaks.cfg: 3 of 4 command(s) applied, 1 failedComments and blank lines are not commands and are not counted. A nested exec logs its own line and is not double-counted into its parent, whose exec line already counted as one command of its own. A missing file and an over-deep nesting chain each log a warning too, so a startup exec that never ran is visible rather than silent.
Replicated World Settings
Section titled “Replicated World Settings”Some state is world-wide and server-authoritative — the same for every client of a world, owned by the server, and needed by the client (including client-side prediction). The engine replicates it through a standardized WorldSettings section carried in every snapshot, right after the header and before the entity records. It rides the same Quake-3 stateless, full-state-every-tick model as the rest of replication: each snapshot re-sends the current values, so a mid-session change converges on its own and packet loss is self-healing — no reliable-delivery or revisioning machinery. New world settings append a field to WorldSettings (and bump the protocol version); nothing else in the pipeline changes.
World time scale
Section titled “World time scale”world.timeScale is a server-owned multiplier on the rate of fixed ticks, following Source’s host_timescale: 1.0 is normal, 0.5 half speed, 2.0 double. It multiplies the real seconds fed into the fixed-step accumulator, not the per-tick dt — every tick is still exactly SecondsPerTick, so the world simply advances more or fewer identical ticks per real second. Per-tick physics is unchanged and prediction stays deterministic, because the client and server run bit-identical fixed ticks; only the cadence moves.
The server scales its sim-loop accumulation by the value, and the client scales its prediction tick accumulator by the replicated value (defaulting to 1.0 until its first snapshot), so predicted movement stays in lockstep with the authoritative sim. A change produces a brief predicted/authoritative transient that reconciliation smooths out; steady state converges.
It is a cheat-gated, transient world.* preference: set it from the console with world.timeScale <value> (rejected unless world.cheats 1, exactly like noclip), clamped to a sane positive range, and deliberately never persisted so a slow-mo session never survives a restart.
Coyote time
Section titled “Coyote time”world.coyoteTime is the per-world coyote-time grace window in seconds — the brief period after walking off a ledge during which a jump input still fires as if the pawn were grounded. It is on by default (0.1 s, i.e. 6 ticks at 60 Hz); 0 disables it. Like every other world.* movement tunable it lives in the server-owned world preference store (so it survives gameplay-assembly hot reload and two worlds can disagree), and the mover converts it to whole sim ticks against the shared Conventions.TickRate, never a hardcoded 60.
The window is why it must be replicated. Jump logic runs inside the shared CharacterMover stepped on both the server and the client predictor; if the two used different coyote values, every ledge jump would mispredict and rubber-band. So the value rides the WorldSettings section (the same channel as world.timeScale) and the predictor applies the replicated value to its mover config before each predicted step — and re-applies it whenever a mid-session change arrives — so prediction and the authoritative sim step the ledge jump identically. The mover tracks ticks-since-grounded per pawn (replicated in the owner-detail section for reconciliation) and consumes the window the instant any jump launches, so a single airborne period yields at most one coyote jump and a normal jump cannot be chased by a bonus one.
Slope limit
Section titled “Slope limit”world.slopeLimit is the steepest surface a pawn can walk up, in degrees; steeper surfaces cause the player to slide. The mover never compares angles — it compares the ground contact’s normal-Y against a cosine threshold — so the degree value is converted to that ground-normal-Y minimum (cos(slopeLimit)) once, where the MoveVarValues snapshot is assembled or overlaid, never per tick. The default is derived the other way round from a fixed 0.7 minimum walkable normal-Y (so the degree default, ≈ 45.57°, and the 0.7 threshold can never drift apart through rounding). A lower degree limit demands a flatter surface, which is a higher minimum normal-Y, so fewer surfaces count as ground. Like the other movement tunables it is a persisted world.* preference, part of the world autoexec, and replicated through WorldSettings so prediction agrees with the authoritative sim.
Surface friction
Section titled “Surface friction”world.surfaceFriction is a ground grip multiplier applied to ground acceleration, ground friction, ground-stick strength and slope grip together — the default is 1.0 (full grip, the baseline feel). It is resolved at the ground-categorization point: the mover reads the grip of the dh.material on the surface under the feet (surface.frictionMultiplier, keyed per-triangle for mesh maps), falling back to this world default where no per-surface material applies. Lower values are slidy — the pawn keeps more speed and gets less acceleration control — and, crucially, they let gravity slide the pawn down walkable slopes: the grip statically holds a slope while its angle is within the grip (tan(angle) ≤ grip, so at full grip the pawn holds every walkable slope up to the ~45.57° limit), and once the angle exceeds that the pawn accelerates downhill at g·(sin − grip·cos), exactly a block on an incline. A slime/ice material (e.g. frictionMultiplier 0.12) therefore slides you down even a gentle ramp, while a steeper-than-limit surface slides regardless. 0 is frictionless. Like the other movement tunables it is a persisted, per-player-overridable world.* preference, part of the world autoexec, and replicated through WorldSettings. (The same grip will drive dynamic rigid-body props once those exist; today it scopes to the player mover.)
Per-player overrides
Section titled “Per-player overrides”Every movement variable is not only a per-world default but also per-player overridable, server-authoritative. The world’s effective MoveVarValues are resolved once per tick, then each pawn’s sparse override set (MoveVarOverrides — every field optional, absent fields inherit the world value) is overlaid before that pawn is stepped, so one player can run moon gravity or a higher jump without touching anyone else. Derived values follow the effective inputs: JumpImpulse is recomputed as sqrt(2·g·h) over the overridden gravity and jump height, and an overridden slopeLimit converts through the same degree→cosine path.
The same machinery carries the world’s look — see per-player look overrides below — so the console scope has one vocabulary spanning both.
Overrides are set from the console under the players.<target>.<var> scope, authority-gated (world.cheats-style, host/local server console only):
players.<target>.<var>— bare, prints the effective value and whether it is inherited or an override.players.<target>.<var> <value>— sets that one override.players.<target>.look <slug>— adopts a whole built-in look in one line (see below).reset players.<target>.<var>— clears one override (the field re-inherits; the component is removed when its last override is cleared, leaving the pawn byte-identical to a fresh joiner).reset players.<target>.look— clears that player’s whole look set, leaving their movement overrides alone.reset players.<target>— clears all of that player’s overrides, as if they had just joined.reset players— clears every connected player’s overrides, echoing the affected count.players/players.<target>— lists connected players with their override counts, or one player’s active overrides across both vocabularies.
<target> is a player’s display name or numeric id; an ambiguous name errors and lists the matching ids. <var> is a leaf name from either vocabulary — the movement values (gravity, maxSpeed, jumpHeight, slopeLimit, …) or the look values below. The two sets of leaf names are disjoint, so a name in neither errors and lists both. Overrides live on the pawn entity, so they survive respawn and map change; they are cleared on that player’s disconnect and on session reset, and are runtime-only (never persisted).
Prediction parity is mandatory, so the receiving client’s own pawn’s sparse override rides the owner-detail section of the snapshot (alongside ticks-since-grounded); the predictor overlays the replicated world WorldSettings and then its own override before each predicted step. Remote pawns need nothing extra — the server is authoritative over their motion.
The world’s look
Section titled “The world’s look”WorldSettings also carries the world’s render block — the art direction a map authors and every client of that world renders with. It is fixed-size and stateless like the movement block: a float exposure, the tonemap curve as its enum ordinal in one byte, then the bloom block — a switch byte and five floats (bloomIntensity, bloomThreshold, bloomSoftKnee, bloomDiffusion, bloomAnamorphic). Adding the bloom fields bumped NetProtocol.Version 18 → 19, and nothing else in the pipeline changed — which is exactly the append-a-field, bump-the-version path this section is built for.
The block is look, never simulation, so a client that disagrees with it mispredicts nothing: a player’s client.render.* override is unset by default and simply follows the world, and pinning one changes only what that player sees. See the color pipeline for the full setting list and dh.map → The world’s look for how a map seeds it.
Per-player look overrides
Section titled “Per-player look overrides”The look is per-player overridable on exactly the terms the movement variables are: a sparse all-nullable LookOverrides set on the pawn, every field optional, absent fields inheriting the world’s resolved look. It is what lets an admin park one spectator on a flat curve, or hand a ported map’s author the source engine’s response chain while everyone else keeps the house look, without touching the shared world.render.* rules.
The set carries exactly the ten fields a look preset carries, addressed by the same leaf names the world preferences use:
| Variable | World preference |
|---|---|
players.<target>.exposure | world.render.exposure |
players.<target>.tonemap | world.render.tonemap |
players.<target>.bloom | world.render.bloom |
players.<target>.bloomIntensity | world.render.bloomIntensity |
players.<target>.bloomThreshold | world.render.bloomThreshold |
players.<target>.bloomSoftKnee | world.render.bloomSoftKnee |
players.<target>.bloomDiffusion | world.render.bloomDiffusion |
players.<target>.bloomAnamorphic | world.render.bloomAnamorphic |
players.<target>.halfLambert | world.lighting.halfLambert |
players.<target>.falloff | world.lighting.falloff |
Eight are world.render.* and two are world.lighting.*, because a look is the shading model’s response and the response is split across the two blocks. Each variable parses, clamps and formats through its world preference, so the per-player value can never accept a range the world value would not.
players.<target>.look <slug> is the batch form: it expands a built-in look — neutral, unity, source, the same slugs a map adopts — into the override set in one line. It replaces the player’s look set rather than merging into it, because a look is a whole response chain and half of one is nobody’s intent; a field the preset does not speak to stays inherited, exactly as it does when a map adopts the same look. neutral therefore pins nothing at all, which removes the component and returns the player to following the world. An unknown slug errors and lists the valid ones.
Rendering is purely local, so the set is delivered to the owning client only — it rides the owner-detail section of the snapshot, in its own 16-bit presence mask appended after the movement overrides. A player with no look override costs two bytes; a player carrying every field costs thirty-three (seven floats, plus the bool and the two enums at one byte each). Adding the block bumped NetProtocol.Version 24 → 25.
The client resolves in four layers, outermost winning: an explicit client.render.* stored preference, then the player’s replicated look override, then the world’s resolved look, then the engine default. A player’s own console edit still beats what the server hands them — the local override is the one thing that was always theirs.
Client/Server Clocking & Interpolation
Section titled “Client/Server Clocking & Interpolation”The client and server free-run on separate machines, so the engine regulates three clocks explicitly instead of hoping they stay aligned.
Server input queue & honest acks
Section titled “Server input queue & honest acks”The server keeps a small per-session input queue (12 ticks deep) and simulates exactly one input per fixed tick, oldest first — every input the client predicted runs for exactly one tick, in order, even when several packets drain in one poll or a frame runs multiple catch-up ticks (inputs apply through the universe’s per-tick TickPreparing seam). The ack carried in each snapshot is the last sequence actually applied to simulation, never a merely-received one; acking an unsimulated input would make the client drop a pending input from its reconcile replay and diverge. On a starved tick (empty queue) the previous input keeps applying for up to 3 consecutive ticks, after which movement is zeroed (look angles kept) so a dead link cannot walk a pawn off a ledge.
Client tick-clock regulation
Section titled “Client tick-clock regulation”The client holds a small steady lead over the server so that queue never starves nor overflows: a soft rate controller scales the real seconds fed into the tick accumulator by up to ±2% (client.net.clockMaxRate), driven by the server’s own replicated queue depth — a signed byte in the owner detail carrying either the ticks waiting unsimulated or, negative, the run of consecutive starved ticks (NetProtocol.Version 27 → 28).
Regulating the server’s number rather than the client’s un-acked backlog is the whole design. The backlog a client can see is floored by the round trip — it cannot fall below RTT × tick rate no matter what the clock does — so steering it toward a fixed target of 2 was unreachable on any internet link: at ~100 ms it sat near 6 forever, pinned the controller to its slow clamp, and the old snap-hold fired at ordinary transatlantic latency. The server’s queue depth has no such floor; the two clocks differ by a rate, not a delay.
Jitter is answered by widening the target, never by hardening the gain: each starved tick grows a margin (client.net.marginGrowth, capped by marginMax) that bleeds back off over calm seconds (marginDecay), handing the player their input latency back. The hold is now reserved for a genuinely dead link — a full second of un-acked input (client.net.holdBacklog, default Conventions.TickRate), not a ping. Everything is live-tunable under client.net.*, and all of it resets when the client leaves a session, so a connect to another server never regulates the new link by the old one’s smoothed depth. The perf overlay reads buf 2/2.0 rate x1.000, showing - until the first real reading rather than pretending an unknown queue is an empty one.
Server tick pacing
Section titled “Server tick pacing”TickPacer sleeps most of each tick and spins the last couple of milliseconds. Thread.Sleep overshoots its request by a factor that is a property of the host, not a constant: on Windows it lands close, but a headless macOS arm64 host measured Sleep(1) → 7.8 ms and Sleep(14) → 83 ms, which collapsed a nominal 60 Hz server to a measured 20 Hz with no warning in the log. The pacer therefore measures its own overshoot — a multiplicative factor learned from every sleep, rising instantly and decaying slowly — and requests budget / factor milliseconds instead of the budget. Where even a 1 ms sleep would overshoot the remaining budget it spins, bounded by server.tick.maxSpin so a pathological timer costs a slice of a core rather than all of one. When the effective rate falls below server.tick.rateWarnFraction of nominal the pacer logs it, rate-limited by server.tick.rateWarnInterval, naming the measured Hz and the overshoot factor.
Snapshots broadcast from the universe’s per-tick TickCompleted seam — one snapshot per simulated tick, not one per loop iteration. On the macOS host that distinction was worth 5× : ~5 ticks merged into a single packet, so the client saw ~20 snapshots a second each advancing the server tick by five. (Remote entities survive that intact — the render clock advances on real frame dt and corrects at ±5%, snapping only past the replica ring — but the input queue and the ack cadence do not.)
Remote interpolation on the server-tick timeline
Section titled “Remote interpolation on the server-tick timeline”Remote entities interpolate on the server-tick timeline: each snapshot sits at tick × SecondsPerTick in sim time — an exactly even grid regardless of wall-clock arrival jitter or time scale. The client advances a remote render clock by dt × timescale each frame, softly rate-corrected (±5%) toward the newest received tick minus the interpolation delay, snapping only when a stall pushes it outside the buffer. The delay is whole ticks (ceil(client.interp in ticks) + 1 margin tick, 2 ticks at the default), time-scale independent — the old wall-clock delay formula and its /timescale correction are gone, because arrival times no longer participate at all. A lost snapshot lerps across the gap; an alpha-1 clamp (held frame) can only mean genuine packet loss.
Reconcile smoothing
Section titled “Reconcile smoothing”Prediction mispredicts are adopted instantly in simulation while a decaying visual offset keeps the camera continuous (~100 ms ease-out). This covers both the feet position and the eye height — a server-authoritative stance change eases the camera vertically instead of snapping (the crouch-jitter fix) — and both offsets snap together on teleports and overflow, never smoothing across a real jump.
Networked Entities
Section titled “Networked Entities”Beyond player pawns, the world holds server-driven entities — doors, platforms, buttons, and the props gameplay builds on. They replicate through the same Quake-3 stateless full-snapshot model: an entity enters a snapshot purely because it carries a NetworkedEntity component, and it leaves the client the instant it stops appearing in one. There is no explicit create or destroy message; presence in the snapshot is the entire lifecycle. A static map boxBrush deliberately has no NetworkedEntity component, so map geometry never costs a byte of snapshot bandwidth — only things that actually move or change do.
Each replicated record names its archetype with a NetKind byte (PlayerPawn = 1, Prop = 2, and the logic kinds Button = 3, Door = 4, Light = 5, PressurePlate = 6; the enum is wire format — values are only ever appended, never renumbered). On the client a ReplicaKindRegistry maps each kind to a visual; a record of an unregistered kind is simply ignored, and a kind’s visual is torn down implicitly when its entity drops out of the snapshot.
Kind payloads
Section titled “Kind payloads”Kind-specific state rides the snapshot as a fixed-shape KindPayload: up to four continuous float channels plus one bits byte of discrete flags. The two halves blend differently on the interpolation timeline — channels lerp (a door’s open-fraction eases), while bits snap to the newest value (a light is on or off, never half-lit). The wire form is self-describing and compact: a ChannelCount byte precedes exactly that many floats, so an empty pawn payload costs just 2 bytes (count + bits) and a full four-channel payload costs 18. Growing a kind’s schema is additive — an older snapshot’s missing channels read as zero and blend up — and a hostile packet declaring more than MaxChannels (4) channels is rejected rather than read past the value’s fixed shape. Snapshots stay inside the same 1200-byte budget as the rest of replication.
Interaction channel
Section titled “Interaction channel”Player→world interaction is a typed reliable channel, separate from the unreliable snapshot stream. The client raycasts from the eye each frame; pressing Use sends an entityInteract (target net id + InteractionKind byte) to the server. The server is the sole authority: it re-resolves the target from its own entities and re-checks reach against its own positions — the client’s claimed target is only a selection hint, never trusted — then dispatches to a per-kind handler. Reach is 2.5 m and the look-cone selection aid requires a 0.9 alignment dot; a per-session token bucket caps interactions at 10/second so a flooding client cannot spam handlers. The server answers with an entityEvent (source net id + EntityEventKind + a KindPayload), the reliable server→client counterpart gameplay uses to announce authoritative results.
Logic runtime
Section titled “Logic runtime”Map behavior runs on a server-authoritative logic runtime modeled on Source’s EntityIO: entities declare named outputs, each wired to an ordered action list of calls and blocking delays (see dh.map → Logic entities for the authoring schema). The runtime is a pure state machine over an ILogicHost seam; the networking layer binds to it through a NetLogicHost adapter that spawns one NetworkedEntity per solid logic entity, routes validated interactions (a Use-press on a button) into the matching runtime node, and replicates each node’s visual state back out — a door’s open fraction as a lerped KindPayload channel, a light’s on/off as a snapped bit.
It advances once per simulation tick, so a delay is quantized to whole ticks via SecondsPerTick and stays correct under any world time scale. Delayed continuations ride a tick-based scheduled queue; the activator (the interacting player) threads through a whole run, including across timer boundaries. A per-fire call-depth cap of 8 breaks any re-entrant wiring cycle before it can recurse without bound, and — at debug log level — every firing is traced (doorButton fired onPressed -> slidingDoor.toggle). Signals are strictly event-and-boolean: outputs fire as discrete events and gates hold boolean levels that fire only on an edge; there are no per-frame polling or analog voltages.
Each solid logic entity also owns a physics collider the same adapter installs: a button’s and a fixture-bearing light’s is a static box (a bare light emitter — one naming no material slot — is invisible and installs none), while a door’s is kinematic and teleported each tick to the authoritative open fraction — the same channel-0 value that replicates — so server collision tracks the visible leaf and closes off the doorway when shut. The teleport runs on the tick thread before the physics step, so it is deterministic on the tick. The client does not predict door collision (its predictor colliders come only from static map geometry), so a player walking into a door as it closes briefly predicts pass-through and is then reconciled to the server’s authoritative position.
On the client, the same ReplicaKindRegistry routes each logic kind to a visual that draws it as a textured box: a door slides along its authored axis by distance × open-fraction (the lerped channel, so the glide is smooth), a light both swaps its fixture material between its on/off texture slots and submits its analytic light — one snapped bit driving both halves, so the lamp’s glass and the room it lights can never disagree — and a button depresses along its facing while the pressed latch is set. The wire carries only kind, spawn position and the KindPayload; the box’s extents, orientation, slide and materials — and a light’s color, range, aim and cone angles — come from a client-side catalog built off the same map definition both ends load, keyed by kind and spawn position. That is why the whole analytic light system needed no per-entity wire change: static light data rides the map, and only the on/off bit replicates.
A live map switch rebuilds the whole logic runtime: the adapter despawns the old map’s networked logic entities, destroys their colliders, discards the old runtime (dropping any scheduled continuations), then installs the new map from scratch — leaving no leaked scheduled events, replicated entities or physics bodies, and idempotent across repeated switches (A→B→A restores A’s exact state). The per-tick counter stays monotonic across the rebuild so cooldowns and delays keep the same timeline. Both the embedded and dedicated server compositions drive this through one World.MapLogicChanged seam, and the client rebuilds its prop catalog from the new definition in the same switch.
Why These Choices
Section titled “Why These Choices”Box3D. Descends from the Rubikon lineage (Source 2’s physics) and exposes a CastMover-style API that maps directly onto Source-style character controllers. s&box ships it natively, so it is proven on exactly the movement-focused workload this engine cares about. It is native code, so it stays quarantined behind the Physics module and our own interop.
Friflo.Engine.ECS (over Arch and Flecs.NET). Benchmarks at or near the top among C# ECS libraries, is pure managed code with no native binary, and is small enough to vendor if upstream stalls. Decisively, it is hot-reload-safe: tearing down a collectible gameplay assembly leaves no native state behind to corrupt.
LiteNetLib (over GameNetworkingSockets and QUIC). Pure C# with a long track record in shipped games. .NET 10’s built-in QUIC still lacks unreliable datagram support, which real-time game traffic needs, and GameNetworkingSockets would add a native dependency for capabilities LiteNetLib already covers in managed code.
JIT forever. Hot reload is a day-one feature, and collectible AssemblyLoadContexts require a runtime that can load and unload IL. NativeAOT can do neither, so it is excluded by design — not deferred.