Skip to content

Netcode

The engine is server-authoritative and stateless per snapshot: presence in a snapshot is an entity’s whole lifecycle, and a client’s job is to render the server’s answer smoothly while its own inputs are still in flight.

Everything the engine places in a world is an object, and every object that names another one is a record: a source barcode plus one layer of overrides over it. That is a single grammar in three slots — a dh-obj file’s inherits, a record in another object’s objects, and a map’s instance placement — so a variant, a carried child and a placed crate are the same construct read in three places. See dh.object → Records and overrides for the authoring form, and dh.map → instance for the placement.

A layer is a description of departures, not a log of edits: one override per part and one component per type and key, every field it does not name inherits, and "removed": true is the only removal. A record whose source is itself a record rebases: the resolver walks to the root source and lays the layers over it outermost-last, so an instance of a variant of an object resolves once, at load, without any layer being flattened into the file that names it.

Components ride the same rule. A placed object’s physics body is a dh.box, dh.collider and dh.physicsProp on the object’s root, every field optional in an override, so a placement that only wants a heavier crate states a mass and inherits the size, the material and the simulation side from the object it points at, which is what lets the level editor show a placement as a linked row over its source’s pieces and ghost only the fields that actually disagree. A map’s own crate is an object carrying a dh.physicsProp beside its box, and both are read as one body.

Every message body, and every section inside one, is a field table: one IWireShape<T>.Fields method that visits each field once, in wire order, by name (wire.U32("revision", ref revision), wire.List16<PalletAdvert, PalletAdvert>("pallets", ref pallets)). The same walk writes the value, reads it back, describes it into a schema, or reads another build’s bytes through a plan, so none of the four can disagree about the bytes. Wire is one ref struct with a mode rather than four generic visitors: no generic type argument is ever a ref struct, which is what the iOS build’s Mono full AOT needs.

The walk has a construct for every shape the protocol uses: scalars, strings, hashes and Guids; enums in one or four bytes; nested shapes; presence-byte optionals; lists behind a one- or two-byte count with a cap on both ends; a list counted by an earlier field (the snapshot’s records); sparse sets behind a presence mask whose bit order is the visit order; tagged unions (material values); a reserved length-prefixed section; and tails, where an older writer’s packet may end.

WireMessages.All maps every message id and direction to its shape, and WireMessages.Schema builds the whole protocol as data from it: every message, shape, enum and material row, spelled as canonical text, with a 16-byte hash of that text. Packets carry no names or tags; the schema is what a peer matches fields by.

At join each side sends its schema’s hash, and its full text only when the other has never seen it (see the connection sequence). Two builds with the same hash read each other directly, at no cost at all. Otherwise each side builds a WirePlan from the other’s schema, and every message the other sends is rewritten into this build’s own shapes before anything dispatches it — no handler reads through a plan or knows that one exists. Packets themselves are byte for byte what they always were.

The plan pairs by name, never by position:

  • A field only the peer has is stepped over by its type. WireSchemaWalker is the one walker that knows how to step over anything a schema describes; the plan scans each of the peer’s shapes with it to learn where every field sits and where the shape ends.
  • A field only this build has keeps whatever the shape set before visiting it, exactly as it does past a tail: usually zero, an empty string or list, an absent optional.
  • A field that moved is read where the peer wrote it.
  • A field that changed width within its family is read at the peer’s width: an integer (u8 to u16, an enum to a wider enum), a string (str8 to str16), a list’s count, a sparse set’s mask. A field retyped across families reads as absent. blobChunk.totalBytes went from i32 to u64 this way so a pallet past 2 GiB can travel; an older peer still reads a smaller pallet’s size, and the server checks the client can read blobRefused before streaming it anything that would not fit.
  • Enum members and flag bits pair by name, so a renumbered member keeps its meaning.
  • A union case pairs by name. A case this build does not have leaves its list entry out rather than guessing at it.
  • Material rows map through the peer’s material table by token: its row 41 is whatever row this build keeps water.foamColor in. A token this build lacks, or one whose kind changed, leaves its pair out, so reordering the table costs nothing.

What the schema cannot cover is a field whose MEANING changed under the same name, and the answer is protobuf’s: give it a new name. The schema text’s own spelling, the connect request, the two schema messages and the envelope are the protocol’s fixed frame, and changing any of them is the one thing that still moves the protocol version.

The current packets are checked in under Engine/DigitalHeaven.Engine.Tests/Fixtures/Wire/v2/, one per message kind and one per branch worth pinning, beside the schema text. WireFixtureTests holds every codec to them byte for byte in both directions, and WireSchemaTests walks every one of them with the schema alone, no codec in the loop, to its last byte. Changing a field table is changing the wire: rerun the two test classes with DH_WIRE_FIXTURES=write and the regenerated files show the change in review.

The v80 packets stay frozen under Fixtures/Wire/v80/ with the schema they were written under. LegacyWireFixtureTests reads every one through a plan made from that schema alone: a packet whose shapes have not moved since comes back byte for byte in today’s codec. WirePlanTests pairs test shapes that differ the way two builds would — a field added, removed, moved, widened, a sparse member rebitted, an enum renumbered, a union case added, the material table reordered — and reads each both ways.

The client and server free-run on separate machines, so the engine regulates three clocks explicitly instead of hoping they stay aligned.

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.

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.

TickPacer sleeps most of each tick and spins the last couple of milliseconds, through DeadlineWait, the wait the client’s frame limiter shares. 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.

On Windows the sleep is a high-resolution waitable timer, which keeps sub-millisecond accuracy whatever the process timer period is, so a sleep factor near 1 is the ordinary reading there. server.tick.raisePriority (default on) runs the tick thread above normal priority for the loop’s lifetime, so ordinary-priority work on a busy machine does not make it wake late.

server.timerPeriod (default on) raises the host’s sleep-timer period for the run loop’s lifetime — the same fix the client’s frame limiter has always applied to its own Thread.Sleep calls, now shared through one HostTimerPeriod helper instead of a second copy of the winmm P/Invoke. On Windows the helper also opts the process out of Windows 11’s background timer-resolution throttling, which otherwise drops the request for a window it judges to be in the background. On Windows this is the difference between Thread.Sleep rounding to its ~15.6 ms default and landing near 1 ms; on macOS and Linux there is no equivalent knob, so the preference is a no-op there and the sleep granularity stays whatever the kernel scheduler gives it.

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 + the earned jitter margin), 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.

The buffer is sized for a host that clumps. The replica ring holds client.interp.bufferTicks snapshots (16, ~266 ms — read once when the session’s store is built, so a change applies to the next session), and both the delay ceiling and the render clock’s snap threshold derive from that one number rather than repeating it. client.interp defaults to two snapshot intervals (2 / TickRate), which with the fixed margin tick is 3 ticks of delay on a healthy link. On top of that the clock earns a margin with the same control law the tick clock uses for the server’s input queue — the shared JitterMargin: every held or snapped frame widens it by client.interp.marginGrowth up to client.interp.maxMarginTicks, and calm play decays it at one tick per client.interp.marginDecay seconds, handing the latency back. client.interp.autoMargin turns the earned half off. The previous numbers — a ring of 4 (66 ms) with a 2-tick delay and a snap threshold of 4 ticks of error — were narrower than a single late wake on a macOS host, whose 83 ms sleep overshoot arrives as a five-tick burst: the clock ran out of buffer, held every remote pose, then snapped. That is the freeze-then-jump remote players were reported to do.

The server bounds the burst from its end. One loop iteration simulates at most server.tick.maxCatchUp ticks (4); the rest of the backlog stays in the universe’s accumulator and runs on the following iterations, which the pacer returns from immediately while the loop is behind. Nothing is merged and nothing is dropped — the wire still carries one snapshot per simulated tick with its real tick number, which is the property the per-tick TickCompleted broadcast exists to protect; only the depth of a single delivery is capped. Coalescing several ticks into one packet would have been the cheaper fix and is exactly the regression that per-tick broadcast replaced.

The perf overlay’s detail level carries the client half as a line of its own — interp 3 t (+0.0) held 0 snaps 0 burst 1 — the delay in force, the earned margin, and the session’s held frames, render-clock snaps and deepest snapshot burst. The same counters go to the log every client.interp.reportInterval seconds, and only when something actually held or snapped since the last line, so a clean session says nothing.

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.

Every correction a reconcile produces goes into the offset, with no exceptions. The render is lerp(previous, current) + offset, so a correction the offset does not absorb is one the camera shows raw. That includes a reconcile the server adopted the client’s claim for: an adopted claim says the server stepped from this client’s number at the tick entry it was handed, never that the replay landed where the client has since predicted — and a server that starved a tick in between advanced the pawn a whole tick of motion while acking nothing. Excepting that case is what made the camera alternate between two positions one tick of motion apart, every frame, for as long as the queue kept starving. When the premise really does hold the correction is ~0 and the sub-millimeter residue is dropped rather than eased, so absorbing unconditionally costs nothing in the case the exception was written for.

A discontinuity has to announce itself to be snapped. Both offsets are zeroed on the replicated PawnSpawnEpoch changing — respawn and teleport, the only authoritative position writes outside the mover — or on the pending-input ring overflowing. Never on the size of the correction: flight speed is a preference the player sets, so no fixed magnitude separates a teleport from an honest mispredict. At world.noclipSpeed 400 a single tick of divergence dwarfs any threshold worth writing, and discarding it jumps the camera exactly as far as the correction it refused to ease.

A server-owned movement flag changes state on the same tick at both ends. Noclip and frozen are replicated, stripped from every arriving claim, and reconstructed from the acked state when the client replays — so the transition is part of the state the replay starts from rather than something the client toggles on its own clock. Leaving fast flight still strands the prediction meters ahead of authority for the ticks the new flag spends in transit; that is an honest mispredict, and it eases out like any other.

server.forgiveness decides how much of a client’s own answer the server will take instead of its own. The server always keeps simulating every player from inputs through the same shared CharacterMover.Step the client predicts with; forgiveness only decides what happens when the two answers disagree.

When the replicated forgiveness is below 1.0, the client attaches a claimed transform — position, velocity, stance height, eye height and the claimable character flags — to its input packet. The server steps the input, then hands the claim and the pawn’s own simulated components to ForgivenessGate, which owns the entire policy: the curve, the tolerances, and what a claim is allowed to say. Inside tolerance the claim is adopted whole; past it the server’s answer stands and the client is corrected exactly as it always was. A claim is never partially adopted and never blended.

Why accept-or-correct and not a blend. A blend of two positions produces a third position nobody simulated — a state with no history, that neither side can reproduce and that will disagree with both of them on the next tick. Accept-or-correct keeps every replicated state a real one: it is either the state the server simulated or the state the client simulated, and both were produced by the same mover from real inputs.

Three things carry it:

  • The client learns the server’s value from WorldSettings.Forgiveness, replicated in every snapshot like the other world settings. WorldSettings.Default is the authoritative 1.0, so a client that has not yet heard from a server claims nothing.
  • The wire shape is optional. The input packet carries a presence byte and the claim only when the client knows forgiveness is below 1, so a fully authoritative server reads byte-for-byte the packet it always read.
  • The client learns the outcome from ClaimAccepted in the owner-detail section. It is telemetry, not a shortcut: it reports what the gate did with the tick entry it was handed, and the reconcile path treats an adopted claim like any other — if the replay still disagrees with the prediction, the offset absorbs it. At 1.0 the flag is always false and no claim is ever sent.

A claim witnesses the placement it was simulated under. Every placement of a pawn (an admin teleport, a kill respawn, a stage entry, a logic teleport) goes through one PawnPlacer, which bumps the pawn’s placement epoch. The claim carries a trailing placementEpoch byte, the epoch the client had when it simulated the tick, and ForgivenessGate.TryAdopt refuses a claim whose epoch is not the pawn’s. Without it, the claims already in flight when the server placed the pawn describe the old position, and at forgiveness 0 (what a closed embedded host always runs) the next one is adopted and undoes the placement. It is a byte rather than a bit because a chained teleport bumps twice inside one round trip. It is an added field of the claim shape, so a peer without it takes 0 and its claims are simply never adopted after the first placement.

A claim can never assert authority or cheat state: noclip and frozen are stripped from every arriving claim before the gate sees it, and stance and eye height are held to the same radius the position is, so a server-authored crouch the client never predicted is corrected rather than overwritten. At 0.0 the gate adopts unconditionally, and the server continues to run collision-independent logic — triggers, volumes, pressure plates — against the adopted transform.

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 has an archetype, a NetKind (PlayerPawn = 1, Prop = 2, and the logic kinds Button = 3, Door = 4, Light = 5, PressurePlate = 6; values are only ever appended, never renumbered). An entity record sends it as a byte; an object record does not, because its kind is what its component sections are, derived from the sections in one place (SectionKinds.Of), which both ends call. 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-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 that exceed the 1200-byte budget are fragmented rather than dropped; see Snapshot budget and fragmentation.

A snapshot rides the unreliable channel, so it must fit in one datagram: NetProtocol.MaxPacketBytes is 1200 bytes, the Ethernet MTU of 1500 less the IP and UDP headers with margin left for IPv6, tunneling and LiteNetLib’s own bytes. An entity record (a pawn, a runtime spawn) costs 33 bytes fixed (id, form, kind, position, yaw, pitch, eye height, flags and the two presence bytes), plus 16 for a rotation the kind can turn, 12 for a scale somebody set, and 2 + 4 per channel for its KindPayload. An object record costs what changed (see Object records): a lamp toggled against its rest entry is 13 bytes. Before the rest table and object records, a world of 33 physics props was already past the budget.

An over-budget snapshot is fragmented, never truncated and never dropped. The server slices the whole datagram — its snapshot message id byte included — into snapshotFragment messages that each fit the budget, keyed by server tick and numbered N of M. The client reassembles into one buffer that is allocated once at NetProtocol.MaxSnapshotBytes and never grows, applies the set only when it is complete, and abandons a partial set the moment a fragment of a newer tick arrives — an unreliable channel’s losses cost one tick, never a growing backlog. A set of more than NetProtocol.MaxSnapshotFragments (16) is refused on read, and a snapshot past that ceiling is not sent at all.

This is purely additive: a snapshot that fits the budget is still sent whole, byte for byte, and no protocol version was bumped. A peer that never learned the fragment id simply never applies the oversized ticks.

The server logs the budget once per composition change — a different entity count, or a crossing of the budget in either direction — rather than once per tick, since a full-state snapshot is the same shape every tick. The periodic SnapshotDiagnostics line still carries the detail.

Fragmentation is the floor, not the ceiling: it makes a real map work over a real network but spends more packets on the same bytes. The rest table below takes the map’s own entities out of the count; per-client delta compression against the last acked baseline, for entities that keep changing, is designed but not built — see Engine/design-notes/snapshot-budget.md.

Every object a map authors has one object index, its place in the map’s MapObjectTable (Core): the map’s entities in authored order, folders included, then its geometry nodes ordered by id, then whatever the editing session created. Server, desktop client, editor and mobile session all read the same table off their own copy of the map, so one number names one object everywhere: a snapshot record, a scene-state entry, a console line (scene.disable 12), an editor selection. The table is built once per loaded map and remembered against it while the map keeps its shape (its object list, that list’s length, its world and the world’s parts), so a reader that asks once per object pays a lookup rather than a walk of the map; an editor frame on gm_fork once rebuilt it 1,417 times. It is transient, a session-local handle; what is persisted (the scene-edit store, a saved map) is the object’s authored id.

A snapshot record takes one of two forms, written as a tagged union after the entity id:

FormForWhat it carries
entitya pawn, a runtime spawn: anything no map authoredkind, position, yaw, pitch, eye height, flags, optional rotation and scale, the KindPayload
objecta map objectthe object index (varu32), flags, an optional transform section (position, optional rotation and scale), and a list of component sections, one per replicated component the object carries (at most ComponentWireIds.MaxPerObject, 4)

A component section is the component’s wire id from ComponentWireIds (light 1, mover 2, button 3, pressure plate 4, physics prop 5, light probe volume 6, voxel volume 7, instance 8) followed by a sparse set of dirty fields: the payload’s four channels, its channel count and its eight bits, each present only when its mask bit is set. Every part of an object record is stated against a base: the record’s rest entry when the snapshot names a rest revision and the table holds the same entity at that index, otherwise nothing. A part equal to its base is left out, so a lamp whose on bit flipped travels as one light section carrying one field, and a button that is also a mover sends its button section on a press and its mover section while it moves, each only while it differs, and both ends compute the base with the same ObjectSections.BaseFor. The object index is a varint, one byte below 128, two below 16,384, three past it, so an imported map with more objects than a two-byte index could name still addresses every one.

Every map-authored entity — a lamp, a door, a button, a crate — is a networked entity, and a full-state snapshot would carry every one of them on every tick. An imported map with 1,583 lights measured 63,828 bytes a tick, past the fragment ceiling, so no snapshot was ever sent and the joining client never learned its pawn. Most of those records say only what the map already says.

So each world keeps a rest table (MapRestTable): the state of every map object (every object record) as it stood when the table was taken, its head (flags and transform) held per object index and each component section per object index and wire id, so a client reassembles a record from the sections it was sent and the ones its table holds. The server sends it to each client in mapRestTable chunks of at most NetProtocol.RestTableChunkRecords (512) records, reliable ordered and scoped to the map generation, ahead of the first snapshot that uses it. A snapshot then names the table’s revision in its header (restRevision) and carries only the object records that differ from the table, and only the sections of each that differ, plus every entity record (pawns, spawned assets) and, whole, any object the table does not name. The client fills in each table entity the records left out, exactly as the table holds it, so everything downstream — the visuals, the editor’s pick, inspector, revert and gizmo labels — reads the same replicas as before. The same 1,583-light map now sends the pawn alone: 459 bytes, with resting=1584 on the composition line.

The cost scales with what changed: a light a logic action toggles travels as one record of one section while it differs (13 bytes, where the whole record is 35), and leaves the snapshot again the tick it is back to its table state.

  • Correctness never rests on the table being the authored state, only on both ends holding the same one. Anything that differs is sent; anything the table does not name is sent.
  • The table is retaken when a map’s entities are installed or rebound (a map switch, a reinstall, a hot reload), and whenever an entity it names stops being collected, so a despawn can never leave a ghost the client keeps filling in. A retake mints a new server-wide revision and every client is sent the new table.
  • A client sets a snapshot aside while it names a revision the client does not hold yet; with the table on the reliable channel that is at most a round trip after a retake.
  • Older clients are unaffected. The server thins snapshots only for a client whose wire schema lists mapRestTable; any other client keeps receiving every record with restRevision zero, each object record stated against nothing. An older server’s snapshot reads as revision zero, a full snapshot. Object records themselves are protocol 2: they brought the varu32 wire type, which a protocol 1 schema cannot spell.

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.

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 replicated logic object, routes validated interactions (a Use-press on a dh.button) into the matching runtime node, and replicates each behavior’s visual state back out as its component’s section — a mover’s open fraction as a lerped KindPayload channel, a light’s on/off as a snapped bit. One object is one node, with one behavior per logic component on it, so a button that moves is one entity with two sections.

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.

Objects join and leave the installed logic one at a time. NetLogicHost.Spawn and Despawn add and remove single objects of the installed map, by their object index, with no rebuild: the shape placements streamed in by distance will use. Nothing else in the graph is touched, so every other node keeps its state, its replica and its body. A despawn settles the object’s touch volume at empty first (a plate or a dh.trigger fires its onEndTouch), then removes its body, its networked entity and its node, and drops every continuation the node had in flight, as a disable does. A connection naming an absent object is dropped in silence, the same silence a disabled target gets, and reaches the object again once it spawns back, as a fresh node built from its record (LogicRuntime.Load and Unload; an unloaded node’s id is reused, and nothing it scheduled can replicate onto the node that takes its slot). The session’s scene edits standing against the object (its switch, its collider state, its move and its rewiring) are kept while it is out and apply again when it returns.

Each solid logic entity also owns a physics collider the same adapter installs from its object’s dh.collider: a button’s is a static box, a plate’s a sensor that blocks nothing, and a light’s fixture collides through its box’s generated collider, while a door’s is kinematic and moves with a real velocity: once the tick’s logic has settled, the host places the body where last tick left it and gives it the velocity that reaches this tick’s open fraction (IPhysicsWorld.MoveKinematicBody, Box3D’s b3Body_SetTargetTransform). The movement systems then run, and a pawn standing on the door reads that velocity as its ground’s point velocity (Source’s base velocity) and is carried by exactly the distance the physics step then moves the door. A teleport reported zero velocity, which left a rider behind on a slide and shoved it on a lift. The door’s slide is MoverPose’s, shared by the server, the prediction and the visuals.

A child follows its parent, whatever the parent is. An object’s world pose is its authored pose carried by the motion of everything above it in the scene hierarchy, through folders, brushes and every entity kind: a plate in a folder on a lift rides the lift, and a lamp on that plate rides it too. Nothing about the child or its parent is special-cased; what moves on its own account is one predicate (EntityHierarchy.MovesOnItsOwn, an object carrying a dh.mover today), and everything under it is dynamic and re-resolved every tick. The resolver is EntityHierarchy, built from the whole map, folders included: the server gets it beside the logic list (World.InstallMapLogic publishes both, World.MapHierarchy), moves a dynamic solid’s collider (kinematic, whatever its kind) and trigger volume with it, and the client’s catalog builds the same one from the same map to draw and predict it. A physics prop is neither carried nor carries: the solver owns its pose, it rides by friction, and what hangs under it keeps its authored pose.

The client’s prediction world carries the same solids, and it predicts the doors. A door replicates, beside its open fraction on channel 0, the signed rate its fraction changes by per tick on channel 1 (a trailing channel, so an older client reads the fraction alone). The rate is written after the door’s end-of-travel output has run, so a snapshot taken on the tick a bouncing platform turns already carries the way back. That is the Quake 3 mover trajectory: ReplicatedPropBodies.Predict places every door, and every rider, at the tick each replayed input is simulated on (snapshot tick + k for the k-th un-acked input) with that tick’s velocity, so a rider is carried by the same distance on both ends and a ride logs no correction. What the trajectory cannot know is news: a press that starts a door, or wiring that sends it back at an end, costs one correction of at most the latency window’s worth of travel. One table, LogicBodies.Of (Core), answers “what body does this object’s logic make” for both ends and for the static install — dynamic for a physics prop, kinematic for a mover, static for a button, a sensor for a plate — and the client’s catalog carries the answer beside the body’s one box shape, so the two can never drift apart. ReplicatedPropBodies (Engine.Client) mints one kinematic box per replicated solid in the predictor’s own physics world and moves it to the pose of the newest snapshot state before every predicted step; the shape, orientation and slide come from the same client-side catalog the visuals read, through the shared LogicPropPose.Pose resolver, so the collider and the drawn box are the same object by construction. The server keeps ownership of every one of them — the client never simulates their dynamics, it only mirrors poses — and ReplayFromServer syncs the mirror from the snapshot it replays from, so a replay steps against the world the server had at that tick as far as the client knows it. A predicted walk now slides along a crate and stops at a closed door instead of passing through and being snapped back. What the mirror cannot see is collider state: a solid the scene editor switched off or turned into a trigger is not on the wire, so it stays solid in prediction until the server’s correction moves the pawn.

A rider is drawn on the platform as the platform is drawn. The platform is drawn at the render time, which trails the tick the local pawn is predicted at by the interpolation delay plus the latency, so on a moving lift the predicted pawn stood 0.1 to 0.2 m off the platform anyone could see. The client shifts the local pawn’s DRAWN position by the difference, Quake 3’s CG_AdjustPositionForMover: the predictor remembers the body it stood on entering the tick, and ReplicatedPropBodies.TryMoverLag measures that body’s drawn pose against the pose the pawn actually stood on (a step carries its rider against the pose the body held entering the tick, so one tick before the one it lands on). Both eye endpoints take it, so the camera, the body and the aim ride the drawn platform together, and nothing simulated moves. It lives in the predictor’s eye (ClientPredictor.TryGetEyeInterpolated), so the desktop and the mobile frame get the same picture. Like Quake 3’s, it lets go the moment the pawn leaves the platform, which moves the drawn pawn by that same lag.

Client-simulated props (sim: client) are the mirror image: they live only in the prediction world, and the pawn both collides with them and pushes them. The push rule is one implementation, PropPush.Apply, that the server’s prop loop and the client’s ClientPhysicsProps both call — an impulse that matches the prop’s horizontal velocity to the mover’s capped speed rather than accumulating into it — so a client crate a player shoulders moves the way a server crate does. Its tuning (world.prop.push*) is world-contextual and unreplicated, so the client reads its own store’s values; that is sound only because a client-sim prop is never on the wire to disagree about.

Placed voxel volumes are solid in the prediction world as on the server, and the two build them from the same inputs rather than from the wire: each end reads the volumes off its own copy of the map, filters them through one VoxelColliders.Placements (off, deleted and non-solid wire indices from the scene state drop out), stands each at one VoxelVolumePose.World (the authored pose with the scene edit laid over it), and cooks a chunk into one static mesh body from the same face pass. The server keeps chunks around every player and the predictor around its own, syncing before every predicted step and every replay, and both cook a chunk within the urgent radius of a player on the spot, so neither end steps a pawn over a floor still in the queue. Near a player the two body sets are the same bodies, position and cooked geometry alike.

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 position, the object index and the component sections; 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 the object’s authored identity. The index is the transient handle: both ends read it off the same MapObjectTable, and the id it resolves to is what the catalog is actually keyed on — so a prop the solver has shoved across the room still finds its own shape, and a record whose kind disagrees with the object it names draws nothing rather than someone else’s box. 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.

A voxel volume’s placements are logic objects the server streams in by distance, through NetLogicHost.Spawn and Despawn, by VoxelPlacementHost. What they do to the volume’s cells travels two ways, and the split is the whole design:

  • Writes ride a message. The cells the session wrote (a door opened, written through as its open block) are the server’s World.VoxelCells, and every chunk whose writes change goes to every client on the reliable channel as a voxelCells message (map-scoped, after MaterialLayers in the join baseline), each chunk whole: every write it holds, its grid, its chunk edge and its blocks layer, so a chunk lost and resent, or two in either order, leave the same cells, and a client can lay a chunk over a volume it has not opened yet. A late joiner gets every written chunk. It is a message rather than a snapshot section for the reasons the material layers are: rare, unbounded, and not to be dropped.
  • Claims ride presence. No claim is ever sent. A client claims, every frame, the cells its present VoxelBlock replicas stand for, read off its own copy of the generated record, and releases them in the frame the replica leaves. The server claims before it spawns and releases after it despawns. Presence in a snapshot is an entity’s whole lifecycle, so a claim can never arrive before or outlive the object holding it, which a separately sent set could not promise against an unreliable snapshot.

Both land in the client’s NetClient.VoxelLive, which the renderer and the prediction read their volumes through. The new message id was appended after the highest, so an older client that does not know it draws every volume as its container stores it, and an older server never sends it; there is no NetProtocol.Version bump.

A scene edit is not a snapshot. It is a reliable, full-state account of everything an editing session has changed, and it travels on two messages of its own:

MessageDirectionWhat it carries
sceneEditclient to serverone gesture’s field edits — object index, component type id, the field’s description key, the value or none for a revert — and component edits (an add, or removed over an inherited one)
sceneNodeStateserver to every clientthe whole edit set under a revision: the object states, the transforms, the collider states, the names, the field edits, the component edits, the reflection probe and camera retunes and the wiring, each keyed by object index

One edit vocabulary. Every described field of every described kind — a light’s intensity, a light probe volume’s probeSpacing, a door’s moveDistance — is one entry of one fields list, named by the key its generated description spells and never by a card’s row. A value is one of four forms (numbers, a switch, a word, a list of words); in the state message it is always present, and in an edit request no value means revert. Fields with no description yet keep their own lists until their kind is converted: the collider state (stage 3, step 3), the reflection probe and the camera (step 5).

Both ends check a write the same way. The editor checks a field write against its description before it sends it, and the server checks it again on arrival, through one Core helper (SceneEditRules, over ComponentFieldWrites): the component must be one the object wears (SceneEditRules.WornDescriptions: its kind’s own, the physics prop an object instance carries, and a collider where it holds or can hold one), the key must name a field of it, the value must be that field’s type and among its choices, and a number outside its range is clamped to the end. A write that fails is refused by name and told to the sender alone. Only a session with world authority may send one; any other is refused whole. The editor’s inspector offers a card’s fields for editing on the same list. A field of a component the map source has no place to state yet (a physics prop on an instance) is applied, replicated, undone and replayed like any other but stays pending and unsavable, so a save never writes a key the compiler would not read.

A late joiner is told everything that stands. The join sends the current sceneNodeState, so a lamp somebody brightened an hour ago, a respaced probe box and a retuned camera reach a client that connected a moment ago — the contract the edit verbs have kept since they shipped: hide drops the body, move moves it, and a late join replays both. An edit also retakes its object’s rest record: the object leaves the rest table and is stated whole until the table is next taken, so no snapshot ever states it against what it stood at before the edit.

A retuned light is one of these edits like any other; there is no separate light layer on the wire. Each client turns the light fields into the patch its renderer lays over the authored light (LightFieldOverrides), in the scene state applier both hosts share, so the desktop client and the iPad see the same lamp.

A fire command (or a weapon’s trigger, later) sends a reliable fireRequest (the client’s claimed eye origin and aim direction) to the server; the server is the sole authority that traces. It recomputes the pawn’s real eye from its own replicated position and CharacterMotion.EyeHeight, and drops the request outright — logging a warning, sending nothing back — when the claimed origin strays past FireLimits.EyeTolerance from that real eye, the same “recompute and re-check, never trust the claim” shape the interaction channel already uses. A per-session token bucket (FireLimits.MaxFiresPerSecond) caps the rate the same way interactions are capped.

A successful trace resolves to a HitKind — Surface, Water, or Nothing — and broadcasts a reliable hitEvent (point, normal, kind, body, and the struck preset’s impact set by name) to every session in the same world. A ray that crosses the water fold before reaching a solid produces two hitEvents from one shot: the water crossing first, at the displaced surface height where the ray enters, then the far solid hit — so shooting through water at a target on the far side works, and water itself never becomes a surface preset. Both new message ids were appended after the existing highest id, with no NetProtocol.Version bump; an older client that doesn’t know either message simply doesn’t send or handle it.

Clients dress a hitEvent with an impact sound alone — HitDressing (Engine.Client.Frame) picks the clip by HitKind (the surface’s own impact set, or a water splash with a steeper distance falloff) and plays it through the shared AudioOneShots.PlayAt one-shot-at-position helper. The event’s shape leaves room for a decal or particle effect to attach later as trailing optional fields, with no protocol change required.

The fire console command fires from the local camera through the same request path, so even a local shot is server-authoritative, and logs a result line naming the resolved kind, point, and distance.