ADR-0002: Host Commit Model — HostCommitQueue, Non-Yielding Critical Sections, Tick-Phase Ordering
Status
Accepted
Accepted 2026-08-28 —
architecture-review-2026-08-28.md(verdict CONCERNS; review amendments applied before acceptance).
Date
2026-08-28
Engine Compatibility
| Field | Value |
|---|---|
| Engine | Unity 6.3 LTS (6000.3) · Netcode for GameObjects 2.x (prototype: 2.13.2) |
| Domain | Networking / Core |
| Knowledge Risk | HIGH — post-cutoff. Everything below that touches NGO was verified against the live 2.5/2.6 package docs on 2026-08-27; nothing is taken from training data or from the project's 1.x-idiom modules/networking.md. |
| References Consulted | NGO 2.6 manual "About NetworkUpdateLoop" (stages, RegisterNetworkUpdate); NGO 2.5.1 "RPC" (universal [Rpc(SendTo.…)], RpcDelivery, DeferLocal, RequireOwnership); NGO 2.5.1 "NetworkTime and ticks" (NetworkTickSystem.Tick fires once per tick before NetworkVariable deltas are collected); NGO 2.6 "Reliability" (reliable RPC order is guaranteed per NetworkObject only); NetworkDelivery enum 2.6; docs/engine-reference/unity/{VERSION,breaking-changes,deprecated-apis,current-best-practices}.md; docs/architecture/architecture.md §3.1/§3.2/API Boundaries; ADR-0001. |
| Post-Cutoff APIs Used | [Rpc(SendTo.Server, Delivery = RpcDelivery.Reliable)] (universal RPC attribute, 1.8+, current in 2.x); NetworkManager.NetworkTickSystem.Tick; NetworkUpdateLoop.RegisterNetworkUpdate(INetworkUpdateSystem, NetworkUpdateStage) (used only by the verification harness). |
| Verification Required | (1) Which player-loop stage NGO processes incoming RPCs and applies incoming NetworkTransform state in is not documented — a PlayMode probe registers INetworkUpdateSystem at every stage and logs the order relative to RPC receipt, NetworkTransform apply, and Tick; ADR-0008 records the measured order. (2) A command submitted by a client in the same frame as its own NetworkTransform update must observe that transform when drained (BB F1 noPlayerOccupancy "one RTT stale, never more"). (3) Host-local SendTo.Server invocation enqueues synchronously (documented: "executes locally if invoked on the server") — confirm it does not reorder ahead of already-received remote envelopes. (4) ~~per-method vs per-object ordering~~ closed 2026-08-28 — the 2.6 Reliability page guarantees order per NetworkObject collectively, and the design has one method anyway. (5) Multiplayer Play Mode 2.0.x is not fit for automated tests on 6.3 (Unity staff, forum; improvements arrive in 6.4): the PlayMode validations below run as documented playtests or as two standalone builds under Network Simulator, not as a CI gate. (6) Default observer visibility (all clients observe the channel) is inferred from NGO defaults, not doc-quoted — nobody may add CheckObjectVisibility filtering to the channel object. (7) Drain unsubscribes from Tick in OnNetworkDespawn/OnDestroy (guards double-subscription under domain-reload-off test runs). |
ADR Dependencies
| Field | Value |
|---|---|
| Depends On | ADR-0001 (ITideClock.SoftClose event and the re-sample-at-resolution rule — both consumed here). Related, not a dependency: ADR-0006 — SoftCloseTick(socket) = φ+W reads one validated load-time value; relabelled 2026-08-28 (architecture review C3) to break the declared cycle 0002 → 0006 → 0003 → 0002 |
| Enables | ADR-0003 (defines HostContext and the concrete command shapes this ADR only outlines — benign co-dependency: this ADR fixes the minimum surface, ADR-0003 the full shape), ADR-0004 (the Persist write-through hook and the per-drain durability semantics), ADR-0005 (LandLifecycle consumes the step-3 teardown release), ADR-0008 (netcode wire: envelope encoding, send rate, intent-validation bounds, measured stage order, snapshot-vs-in-flight ordering — playtest-gated), ADR-0010 / ADR-0011 / ADR-0012 (their commands are ICommands on this channel) |
| Blocks | The command-execution half of every host-rail epic (Harvesting resolution, Combat resolution, Inventory transfers, Crafting commits, Base Building placement/demolish). Base Building story scoping additionally waits on ADR-0008/0011's registry-replication choice (BB forward refs) — Accepting this ADR alone does not unblock BB stories. |
| Ordering Note | Accept after ADR-0001. The model (one channel, one drain, one critical section, SoftClose-first, enemy-damage phase 0, arrival order + tiebreak) is decidable now and is what this ADR fixes; the measured stage order (Verification 1–2) is recorded by ADR-0008 and cannot invalidate the model — it can only move where Drain() is hooked. |
Context
Problem Statement
Five systems mutate host-authoritative state — Harvesting (node decrement + diff), Combat (HP, stagger, death), Inventory (transfers), Crafting (consume + produce + mark), Base Building (registry + cost). Every one of their GDDs asserts the same three things and none specifies a mechanism: (1) commits are "atomic" / "one host critical section" and re-sample every gate inside it; (2) "first host-arrival wins", with an exact-tie tiebreak of lowest OwnerClientId; (3) on a tick containing tide SoftClose, the soft-close is evaluated first (combat Rule 13, harvesting EC6). Base Building's fifth re-review routed the atomicity constraint to the Technical Director explicitly ("asserted throughout, stated as a requirement nowhere — it breaks silently in coroutine-heavy code"); the gate-check TD made it the phase's top item; the LP review flagged that the architecture's own diagram stated the drain phase as fact where it is unknown.
NGO adds a constraint the GDDs did not know about: reliable RPCs are ordered only within one NetworkObject. If Harvesting, Combat and Base Building each own their RPC, "first host-arrival wins" across systems is undefined — a placement and a craft racing the same timber can execute in either order on different runs. The channel must be one object.
Constraints
- Forbidden Patterns: no rollback/reconciliation, no host-side movement authority, no remote-player prediction (technical-preferences; netcode-session Rule 6).
- Owner rail vs host rail (netcode-session Rule 2) — this ADR is the host rail's execution model and nothing else; owner-rail writes (movement, CARRY prediction, predicted FX) never pass through it.
- Tide re-sample rule (ADR-0001 forbidden pattern
caching_evaluate_across_ticks): every command readsITideClockinsideExecute. - 60 fps / 16.6 ms host frame budget with the host also being a player (technical-preferences); Save I/O must not run inside the drain (LP review 5 → ADR-0004, QQ-11).
- Unity 6.3:
[SerializeField]fields-only;Awaitablepreferred for async; Multiplayer Play Mode 2.0.1 local instances for tests. - MVP session = host + 1; design must not branch on player count (netcode Rule 3).
Requirements
- One total arrival order for all host-rail commands from all clients and the host itself (TR-net-006).
- Each command executes as a single synchronous, non-yielding block on the host main thread; no
await/yield/coroutine insideExecute(TR-net-007). - The drain runs in one pinned slot per network tick; commands never execute on RPC receipt (TR-net-008).
SoftClosefor a socket is applied before any command touching that socket's land on the same drain (TR-combat-010, TR-harv-005).- Commands carry only intent-time data; all gates re-sample live state inside
Execute(combat Rule 7, harvesting Rule 6, crafting F1, BB F1/F2, inventory Rule 6). - Deterministic tiebreak when arrival order is indistinguishable: ascending
OwnerClientId(BB AC-20b), never reordering intents whose arrival order is known. - Host-confirmed outcomes are broadcast only after
Executesucceeds; rejected commands are silent no-ops (the honest whiff — combat Rule 8, harvesting Rule 7, BB Rule 6). - Per-command cost bounded so a full drain stays ≤ 1 ms at MVP load.
Decision
Alternative A: one HostCommitChannel NetworkObject; one [Rpc(SendTo.Server)] SubmitRpc(CommandEnvelope); one HostCommitQueue drained once per NetworkTickSystem.Tick; each command a synchronous ICommand.Execute(HostContext).
- The channel is a single NetworkObject.
HostCommitChannel : NetworkBehaviourlives on a scene-placed, always-spawned, server-owned NetworkObject (spawned with the session, never despawned, no observers filtering — every client is an observer). It declares exactly one inbound RPC: ```csharp [Rpc(SendTo.Server, Delivery = RpcDelivery.Reliable, InvokePermission = RpcInvokePermission.Everyone)] private void SubmitRpc(CommandEnvelope env, RpcParams rpc) ``` Because NGO orders reliable RPCs per NetworkObject — across all RPC methods on that object, per the 2.6 Reliability page — routing every host-rail command through this one object yields a total order per sender delivered by the transport, not reconstructed. No other NetworkBehaviour in the project may declare aSendTo.ServerRPC that mutates host-rail state (forbidden patternhost_rail_rpc_outside_channel; a reflection test asserts it). This is a hard invariant, not tidiness (TD-ADR C9): the per-object guarantee is the only thing making arrival order total; a second host-rail RPC object silently reintroduces cross-system races. Boundary with netcode-session Rule 7 (TD-ADR C3): Rule 7 reserves "RPC shapes for every commit" for the post-playtest ADR. The existence and cardinality of the submit RPC — exactly one, on exactly one object — is a model fact forced by the ordering guarantee and is decided here; its payload encoding, send rate, and intent-validation bounds are wire facts and remain ADR-0008's.InvokePermission = RpcInvokePermission.Everyoneis deliberate: any client may submit anyCommandKind; safety rests on the host overwritingownerClientIdfromrpc.Receive.SenderClientIdand on every gate being re-sampled host-side — never on who owns the channel object. CommandEnvelopeis the wire unit:{ CommandKind kind; ulong ownerClientId (set by the host fromrpc.Receive.SenderClientId, never trusted from the payload); uint clientSeq; byte[] or INetworkSerializable payload }.clientSeqis a per-client monotonic counter the owner stamps; the host uses it only for the tiebreak rule and for duplicate detection — ordering itself comes from delivery. Encoding ofpayload(customINetworkSerializableper command kind vs a generic buffer) is ADR-0008's; this ADR fixes only that the envelope exists and what it carries.- Host-local commands take the same path. The host's own player calls
SubmitRpclike any client;SendTo.Serverinvoked on the server is documented as "executes locally", so the envelope enqueues in program order relative to already-received remote envelopes (program-order relative to the same frame's receipts is Verification 3, not a doc quote).DeferLocalstaysfalse— the queue, not the RPC, provides the tick boundary. HostCommitQueue(plain C# class inDriftlands.Runtime, owned by NetcodeSession) is a FIFO of(envelope, arrivalSeq);arrivalSeqis a host-side monotonic counter assigned on enqueue. Enqueue never executes.Drain()runs once per network tick, subscribed toNetworkManager.NetworkTickSystem.Tick— documented to fire once per tick before NetworkVariable changes are collected, so every mutation a command makes replicates in the same tick's delta. Drain order:- SoftClose first.
tideClock.Advance(hostUtcNow)has already run this frame (ADR-0001 adapter); read the set of sockets whoseSoftClosefired since the last drain. Enemy attacks mid-windup/mid-active on those lands are cancelled outright (combat Rule 13). 1b. Phase 0 — enemy damage (TD-ADR C1, ruled 2026-08-28).EnemyAIhas, during its own step, resolved every enemy attack whose resolution instant is≤ NowTickinto a host-sidePendingEnemyDamagelist (attacks on soft-closed lands were cancelled in step 1). The drain applies that list before the envelope loop — so on a mutual-kill tick enemy damage resolves first, then the player's strike, and both apply (combat States & Transitions "both apply", AC-32 — a locked rule, not a knob). Player HP reaching 0 here transitions to Downed/Dead before any player command executes (combat EC15: a swing whose attacker is dead at resolution is discarded by its ownExecutere-sample). - Envelope loop — one uniform rule (TD-ADR C2). For each queued envelope in
arrivalSeqorder: if the command targets a socket in the soft-closed set and itsResolveTick ≥ SoftCloseTick(socket), drop it as a silent no-op; otherwisecmd.Execute(ctx). The threshold is identical for everyCommandKind— combat Rule 13 and harvesting EC4/AC-30 agree exactly (< φ+Wresolves,≥ φ+Wfails, no grace tick either way). There is no harvest exception here; the harvest asymmetry lives in step 3. Commands may re-check the gate insideExecuteas defence-in-depth; the drain's check is authoritative. - Teardown release — the one genuine asymmetry. Only after the loop are teardown notifications for soft-closed lands released to LandLifecycle. Combat discards pending resolutions at the boundary; harvesting requires a
< φ+Wresolution already in flight to complete before teardown (harvesting EC6 / AC-31, "deliberately inverted from combat Rule 13" — inverted in sequencing, not threshold). Ordering the release after the loop satisfies both. - Deferred effects.
ctx.Broadcast(outcome)andctx.QueueSpawn(...)buffer; both flush after the loop, in command order (TD-ADR C4). A command therefore never observes its own broadcast or spawn, and no NGO send or spawn API runs inside a critical section — that is what makes the "no re-entrant Unity API" rule in §6 checkable. - Persist. Each successful
Executecallsctx.Persist(table), which dirty-marks only; SavePersistence flushes afterDrain()returns (ADR-0004). Durability is therefore per-drain, not per-action — see Consequences and the save-persistence row below. The tiebreak (OwnerClientIdascending) applies only to a batch whose members carry noarrivalSeq— which, under this design, is only the pure-function test harness (BB AC-20b); live envelopes always have one. ICommand.Execute(HostContext ctx)is a critical section by contract: synchronous, returnsCommandResult { Success, Reason }, noawait, noyield, no coroutine start, no Unity API that can re-enter (noInstantiateof NetworkObjects inside — spawn requests are queued on the context and performed by the drain after the loop, so a command's observable state change is still atomic with its result). Every gate is re-sampled fromctxinsideExecute(ctx.Tide.Evaluate,ctx.HostPositions,ctx.Registry,ctx.Inventory). Enforced by: an analyzer rule (or, until one exists, a review-checklist item plus a reflection test that noICommandimplementation'sExecuteisasyncor returnsIEnumerator/Awaitable).- Confirmed outcomes leave through host-rail replication only — NetworkVariable deltas (collected the same tick) or
[Rpc(SendTo.ClientsAndHost)]broadcasts issued from inside the drain afterExecutesucceeds. A failed command produces no broadcast (honest no-op); the client's predicted FX simply never get confirmed. - Duplicate and stale envelopes: the host keeps the last
clientSeqperPlayerKey(ADR-0015 —clientIdis unstable across reconnects and may be reassigned; keying byclientIdwould let a rejoined player inherit a stranger's window or lose its own); the owner's counter is per session and persisted client-side across rejoin (never reset on reconnect), so an envelope withseq ≤ last[PlayerKey]is dropped for both retransmit edge cases and rejoin replays (amended 2026-08-28, architecture review C2).HostContextexposesNow(host tick) so combat/harvest can compute their resolution instant deterministically fromintent tick + phase durationrather than wall clock. - Late join and disconnect: the channel is always spawned, so a joiner can submit the moment it is connected; a disconnecting client's already-received envelopes still execute (combat EC1, harvesting EC1, crafting EC5, BB EC8–9 — "fired commits never un-happen"); un-received ones never existed.
Architecture Diagram
client A HOST (main thread) client B
──────── ────────────────── ────────
intent → CommandEnvelope HostCommitChannel (ONE NetworkObject) intent → envelope
│ SubmitRpc ─────────────────► SubmitRpc(env, rpc) ◄───────────────────────── SubmitRpc
│ reliable, per-object │ ownerClientId := rpc.Receive.SenderClientId
│ ordered │ queue.Enqueue(env, ++arrivalSeq) ← host's own player: same call, local
│ ▼
│ NetworkTickSystem.Tick (once per tick, before NV delta collection) ⚠️ stage measured
│ │
│ HostCommitQueue.Drain():
│ softClosed := tide events since last drain (ADR-0001 SoftClose)
│ for env in arrivalSeq order:
│ if targets softClosed land && resolveT ≥ φ+W → drop (harvest < φ+W completes)
│ result := cmd.Execute(ctx) ── synchronous, non-yielding, re-samples all gates
│ if ok: ctx.Persist(table) [dirty-mark]; ctx.Broadcast(outcome)
│ release teardown for softClosed lands
│ Save.FlushDirty() scheduled AFTER Drain returns (ADR-0004)
│ │
◄──── NetworkVariable deltas + ClientsAndHost outcome RPCs (same tick) ──────────────►
Key Interfaces
namespace Driftlands.Runtime.Net
{
public enum CommandKind : byte { HarvestResolve, StrikeResolve, InventoryTransfer, CraftCommit, PlaceStructure, Demolish, StrikeRevoke, DashStarted, ReviveStart, ReviveCancel /* the last four added by ADR-0015 (2026-08-28): every owner→host combat fact is a command on this channel — no notification rail */ /* extend only by ADR — Repair and Revive-channel edges are ADR-0010/combat-story decisions, not pre-reserved (TD-ADR C9) */ }
public struct CommandEnvelope : INetworkSerializable {
public CommandKind Kind;
public uint ClientSeq; // owner-stamped monotonic; tiebreak + duplicate detection only
public /* payload encoding → ADR-0008 */ ;
// OwnerClientId is NOT in the envelope: the host reads rpc.Receive.SenderClientId
}
public sealed class HostCommitChannel : NetworkBehaviour { // exactly one, scene-placed, always spawned
[Rpc(SendTo.Server, Delivery = RpcDelivery.Reliable, RequireOwnership = false)]
void SubmitRpc(CommandEnvelope env, RpcParams rpc); // enqueue only — never executes
}
public interface IHostCommitQueue {
void Enqueue(CommandEnvelope env, ulong ownerClientId); // host only; assigns arrivalSeq
void Drain(HostContext ctx); // once per NetworkTickSystem.Tick
}
}
namespace Driftlands.Rules.Commands // engine-free (ADR-0003 owns the assembly + HostContext's full shape)
{
public interface ICommand {
ulong OwnerClientId { get; }
uint ClientSeq { get; }
CommandResult Execute(HostContext ctx); // SYNCHRONOUS. No await/yield/coroutine. Re-sample every gate here.
}
public readonly record struct CommandResult(bool Success, ReasonCode Reason);
// The queue's unit — arrival sequence lives HERE, not on ICommand (TD-ADR C6; BB AC-20b's "optional arrival-sequence field"):
public readonly record struct QueuedCommand(ICommand Command, uint? ArrivalSeq);
// A batch handed to the pure resolver is fully-sequenced or fully-unsequenced BY CONSTRUCTION — a mixed batch is
// rejected at construction (BB AC-20b third case). Live drains are always fully sequenced; the unsequenced path
// exists only for the pure-function tiebreak test harness.
// SUPERSEDES architecture.md API Boundaries (recorded for /architecture-review): (1) ICommand.Execute returns
// CommandResult (was void) so Success can gate Broadcast; (2) ICommand.ArrivalSeq (uint?) → ClientSeq (uint,
// owner-stamped) on the command + ArrivalSeq (uint?, host-assigned) on QueuedCommand — the approved shape conflated
// the two; (3) IHostCommitQueue.Submit(ICommand) → HostCommitChannel.SubmitRpc(CommandEnvelope) on the wire and
// IHostCommitQueue.Enqueue/Drain on the host. architecture.md is updated alongside this ADR.
// HostContext (shape finalised in ADR-0003, QQ-12) — minimum surface this ADR requires:
// ITideClock Tide; long NowTick; IHostPositions Positions; IGameData Data;
// IInventoryTx Inventory; IStructureRegistry Structures; IStationPresenceProvider Stations;
// IReadOnlySet<int> SoftClosedSockets; void Persist(ISaveable table); void Broadcast(IOutcome o); void QueueSpawn(...)
}
Worked command (intent-time data vs. Execute-time re-sample):
public sealed record HarvestResolveCommand(ulong OwnerClientId, uint ClientSeq,
uint SwingId, NodeId Node, long ResolveTick /* HOST-STAMPED IntentTick (= NowTick at envelope receipt — ADR-0015 §3 step 0; the payload carries no tick) + harvest windup+active */) : ICommand
{
public CommandResult Execute(HostContext ctx) {
var socket = ctx.Lands.SocketOf(Node);
if (ctx.SoftClosedSockets.Contains(socket) && ResolveTick >= ctx.Lands.SoftCloseTick(socket)) return Fail(Reason.LandDeparted); // defence-in-depth: the drain's step-2 check is authoritative (TD-ADR C2)
var (docked, _) = ctx.Tide.Evaluate(socket); // gate (a) — fresh
if (!docked) return Fail(Reason.LandDeparted);
var node = ctx.Lands.Node(Node); // gate (b) — fresh
if (node.SwingsRemaining <= 0) return Fail(Reason.Depleted);
var carry = ctx.Inventory.Carry(OwnerClientId);
var yield = HarvestRules.Yield(node, ctx.Data);
if (!carry.CanAdd(node.ItemId, yield)) return Fail(Reason.BagFull); // gate (c) — fresh, all-or-nothing
ctx.Lands.Decrement(Node); carry.Add(new ItemStack(node.ItemId, yield, null));
ctx.Persist(ctx.Lands.HarvestDiffs); // dirty-mark only
ctx.Broadcast(new HarvestResolved(OwnerClientId, Node, yield));
return Ok();
}
}
Only SwingId, Node, ResolveTick are intent-time; everything else is read inside Execute.
Alternatives Considered
Alternative B: Per-system RPCs with explicit client sequence numbers and a host reorder buffer
- Description: Each system keeps its own
[Rpc(SendTo.Server)]on its own NetworkBehaviour; every intent carries(clientId, seq); the host holds envelopes in a reorder buffer untilseqis contiguous, then drains. - Pros: The natural NGO idiom — RPCs live next to the code that handles them; no envelope/kind dispatch.
- Cons: Cross-system order is reconstructed, not delivered — a late packet on one object stalls the buffer for all; the "one ordered channel" requirement becomes a host-side algorithm with its own edge cases (gaps, rejoin seq reset); every new system must remember to stamp and route; nothing structurally prevents a system executing on receipt.
- Rejection Reason: NGO already gives total order per object for free; reconstructing it across objects adds a failure mode (buffer stall) the design does not need.
Alternative C: Commands as an owner-writable NetworkList<CommandEnvelope>
- Description: Each client appends envelopes to an owner-writable replicated list on its player object; the host consumes and clears.
- Pros: No RPCs; envelopes visible as state; replay-friendly.
- Cons: Intents become replicated state with per-element delta cost; "consumed" needs a second channel back; ordering across clients is still per-object; clearing races the owner's next append; contradicts netcode Rule 2 (owner rail carries owner-owned state, not requests to mutate host state).
- Rejection Reason: Turns transient requests into persistent replicated state and blurs the two rails.
Alternative D (execution timing): Execute on RPC receipt instead of draining per tick
- Description:
SubmitRpccallsExecuteimmediately. - Cons: Execution phase becomes whatever stage NGO processes messages in (undocumented), so a command can run before or after that frame's
NetworkTransformapply non-deterministically; SoftClose-first cannot be guaranteed; two envelopes from different clients interleave with owner-movement writes mid-frame. - Rejection Reason: Violates TR-net-008 outright; the queue exists precisely to pin the phase.
Consequences
Positive
- "First host-arrival wins" is a transport guarantee, not an algorithm.
- The atomicity every GDD asserts has one enforcement point (
Executecontract + analyzer/reflection test) instead of five conventions. - SoftClose-first and teardown-after-resolution fall out of one loop in one place.
- Mutations replicate in the same tick they commit (Tick fires before delta collection) — the prototype's "~150 ms hit→stagger reads as the swing taking time" holds.
- Adding a host-rail system = adding a
CommandKindand anICommand; no new RPC surface.
Negative
- One extra hop of indirection (envelope + dispatch) for every host-rail action; a
CommandKindswitch must stay exhaustive. - Latency of a command = RTT + up to one tick (drain granularity). If ADR-0008 adopts the docs' recommendation of tick rate = FixedUpdate rate (50 Hz), that is ≤ 20 ms — inside the combat GDD's swing-masks-latency budget (
swingWindup + swingActive ≥ 0.18 s). The number is conditional on ADR-0008's tick rate. - Head-of-line blocking is the price of total order: one reliable channel means a lost packet from any client delays every host-rail command from every client until retransmit. The right trade at 2–5 players over a friends' connection; named, not hidden. Owner-rail traffic (movement) is unaffected.
- Durability is per-drain (see the save row) until ADR-0004 rules on the broadcast-before-flush window.
- The host's own player pays the tick wait too (no instant local commit) — accepted for symmetry; BB AC-48c already treats host/client feel asymmetry as a playtest item.
- Spawns requested inside a command are deferred to after the loop; a command cannot observe an object it just asked to spawn (stated, not hidden).
Risks
- NGO processes incoming RPCs after
Tickin the same frame, so a command drained this tick could not see an envelope received "this frame" → only latency, never correctness (it drains next tick); measured by Verification 1 and recorded in ADR-0008. NetworkTransformstate from the same client arrives after the drain →noPlayerOccupancyreads one tick staler than "one RTT" → BB EC21's correction already covers stale reads; Verification 2 bounds it.- A future contributor adds
asyncto anExecute→ reflection test + review checklist; analyzer rule as soon as the project has one. - Envelope growth (large payloads) →
RpcDelivery.Reliableuses fragmented-sequenced delivery per docs; payload sizes are tiny (ids + ints); ADR-0008 sets the encoding. - Tick rate mismatch (
NetworkManagertick rate ≠ FixedUpdate) → follow the docs' recommendation to equalise them; recorded as a config invariant in ADR-0008.
GDD Requirements Addressed
| GDD System | Requirement | How This ADR Addresses It |
|---|---|---|
| netcode-session | Rule 2 host rail (damage, storage, commits, registries) | every listed mutation is a CommandKind executed only by the drain |
| netcode-session | BB EC1 / crafting EC1 "first host-arrival wins; batch tiebreak lowest OwnerClientId" (TR-net-006) | single NetworkObject → per-sender delivery order; arrivalSeq on enqueue; tiebreak only for seq-less test batches (BB AC-20b) |
| netcode-session / base-building forward refs | "all host-rail commits are single non-yielding synchronous blocks on the host main thread" (TR-net-007) | ICommand.Execute contract + reflection test / analyzer; spawns deferred to after the loop |
base-building F1 (noPlayerOccupancy, placerHasExit re-sample) | commit critical section vs owner movement write phase (TR-net-008) | drain pinned to NetworkTickSystem.Tick; measured stage order → ADR-0008; staleness bounded to "one RTT + at most one tick" (Verification 2). EC21's ring-check correction and its NetworkTransform write mode are not addressed here (ADR-0009) |
| combat Rule 13 / AC-33 / AC-34 | soft-close evaluated first on the host tick; pending damage ≥ φ+W discarded; enemy attacks mid-windup cancelled | drain steps 1 and 2 |
| combat States & Transitions / AC-32 | mutual kill on the same tick: enemy damage first, then the player's; both apply | drain phase 0 (step 1b) applies PendingEnemyDamage before the envelope loop — TD-ADR C1, ruled 2026-08-28 |
| harvesting EC4 / AC-30 and EC6 / AC-31 | same threshold as combat (< φ+W resolves, ≥ fails); teardown sequenced after any in-flight resolution | uniform drop rule in step 2; teardown release in step 3 |
| combat Rule 7 / harvesting Rule 6 / crafting Rule 7 / BB Rule 6 / inventory Rule 6 | gates re-sampled at resolution, never cached | Execute reads only HostContext; intent carries intent-time data only (worked command) |
| combat EC1 / harvesting EC1 / crafting EC5 / BB EC8–9 | fired commits never un-happen on disconnect | received envelopes execute regardless of sender connection state |
| combat Rule 8 / harvesting Rule 7 / BB Rule 6 | rejected outcome = silent honest no-op; confirmed effects host-broadcast only | CommandResult.Success gates Broadcast; failures broadcast nothing |
| save-persistence Rule 3 | per-confirmed-action durability | Partially — honestly stated (TD-ADR C5): ctx.Persist dirty-marks inside the critical section; I/O runs after Drain. Durability is therefore per-drain, not per-action: a host crash between Drain() returning and the flush completing loses every command of that drain, some already broadcast. "Confirmed" for durability means flushed, not broadcast. Accepting, narrowing, or closing that window (e.g. broadcast only after flush) is ADR-0004's decision (its charter: "confirmed durability semantics", QQ-11) |
| netcode-session Rule 4 / 5 (submit path only) | a joiner can submit as soon as it is connected; rejoin replays are deduplicated | always-spawned in-scene channel (NGO spawns it for late joiners automatically); per-client clientSeq duplicate drop. The snapshot contents, snapshot-vs-in-flight ordering, and session-scoped HP/Downed retention are not this ADR's — ADR-0008 / NetcodeSession |
| tide-scheduler Interactions (Combat, Harvesting rows) | Docked re-sampled at resolution | ctx.Tide.Evaluate inside Execute (ADR-0001 forbidden pattern honoured) |
Performance Implications
- CPU: one drain per tick; each command is a handful of predicate evaluations + small table writes (pure
Driftlands.Rulescode). Budget: ≤ 1 ms per drain at MVP load (2 players, tens of commands/second worst case); measured per command kind in PlayMode. - Memory: one queue with a pooled envelope buffer; no per-command allocation after warm-up (envelopes are structs; commands are pooled or stack-only records — ADR-0003).
- Load Time: none.
- Network: one reliable RPC per host-rail action; payloads are ids and ints. No polling, no replicated intent state.
Migration Plan
No production code exists. Order: (1) ICommand/CommandResult + a fake HostContext in Driftlands.Rules so the worked command's tests run in EditMode; (2) HostCommitQueue as a plain class with a deterministic test harness (BB AC-20b/20c shapes); (3) HostCommitChannel NetworkBehaviour + Tick subscription in Runtime; (4) the stage-order probe (Verification 1–2) in Multiplayer Play Mode, results into ADR-0008; (5) first real command = HarvestResolveCommand.
Validation Criteria
- EditMode: queue drains in
arrivalSeqorder; seq-less batches drain by ascendingOwnerClientId; mixed batches are rejected at construction (BB AC-20b); duplicateclientSeqdropped. - EditMode: a reflection test finds no
ICommand.Executethat isasync, returnsIEnumerator/Awaitable, or referencesInstantiate/Destroy/Spawn/SendMessage/any RPC send — the ban is on every re-entrant Unity API, not only spawns (TD-ADR C4); and no NetworkBehaviour other thanHostCommitChanneldeclares aSendTo.ServerRPC. - EditMode: SoftClose-first — a harvest with
ResolveTick < softCloseTickcompletes and one with≥is dropped on the same drain; a pending strike≥ φ+Wis dropped (combat AC-33/34, harvesting AC-30/31 pure halves). - Integration (documented playtest or two standalone builds under Network Simulator — MPPM 2.0.x is not an automated-test vehicle on 6.3, Verification 5): two clients' placement envelopes in one simulator window → exactly one commits (BB AC-20c); a client's envelope is never executed before the drain following its receipt; measured stage order logged (Verification 1) and the same-frame
NetworkTransformvisibility bound holds (Verification 2). - EditMode: mutual-kill tick — with enemy damage pending and a player
StrikeResolvequeued, enemy damage applies first, then the strike; both apply (combat AC-32, pure drain harness). - PlayMode: mutations made in a drain are visible in that tick's NetworkVariable delta on the client.
- Perf: a drain of 50 mixed commands completes ≤ 1 ms on the host in the Editor profile.
Amendments
- 2026-08-28, ADR-0005 — the tide-edge carve-out.
LandNetspawn/despawn andIKnownLandTemplates.Appendare host-rail mutations that do not rideHostCommitChannel: anICommandmodels a player intent with an owner and a client sequence; a tide edge has neither. Bound to exactly those operations, driven byITideClockevents on the Authority clock, executed inside the tide adapter'sAdvance(which step 1 already places before the tick's drain). A third tide-driven host-rail mutation requires an amendment. Registered astide_edge_carve_out. - 2026-08-28, architecture review C2/C3. §8 duplicate detection keyed by
PlayerKeywith a client-persisted per-sessionclientSeq(was perclientId, which ADR-0015 documents as unstable across rejoin). ADR-0006 moved from Depends On to Related — the declared graph had a cycle; the substance (one load-time value) is unchanged. ADR-0001 §4 now fixesHostTickOrchestratoras the soleAdvancecaller, so step 1's "has already run this frame" is a direct-call guarantee, not an assumption.
Related Decisions
- ADR-0001 (SoftClose event, re-sample rule,
ITideClock) docs/architecture/architecture.md§3.1 (drain slot — marked provisional pending Verification 1), §3.2 (no global event bus — the queue is the only fan-in), API Boundaries (ICommand,IHostCommitQueue), QQ-01, QQ-11, QQ-12- Future: ADR-0003 (
HostContextshape, command records, analyzer), ADR-0004 (write-through off the drain), ADR-0008 (envelope encoding, measured stage order, tick rate, intent-validation bounds) - Design: netcode-session quick-spec Rules 2/5/7; base-building EC1 + forward references; combat Rule 13; harvesting EC6; crafting EC1–2; inventory Rules 6–7
Amendments — 2026-09-11 (ADR-0008 netcode wire)
RequireOwnership→InvokePermission. NGO 2.13.2 marksRpcAttribute.RequireOwnership[Obsolete](CHANGELOG #3731);SubmitRpcis[Rpc(SendTo.Server, Delivery = RpcDelivery.Reliable, InvokePermission = RpcInvokePermission.Everyone)]— same semantics (any client may submit; safety is the host'sSenderClientIdoverwrite + gate re-sampling). The two attribute samples above are edited in place.- Replication lags the drain by one tick. §5 assumed
Tickfires "before NetworkVariable changes are collected". Read from the 2.13.2 source (ADR-0008 §2):NetworkBehaviourUpdatersubscribes toNetworkTickSystem.TickduringNetworkManagerinitialisation, before any user subscriber, and sends the objects dirtied before the tick; a variable written insideDrain()therefore replicates at the next tick.OutcomeRpcbroadcasts from phase 4 still leave in the same tick's batch. Command latency: RTT + ≤ 1 tick for outcomes, ≤ 2 ticks for state (50 Hz, ADR-0008). Thedrain_hookdecision stands — stage registration remains rejected. CommandEnvelopeencoding fixed (ADR-0008 §4): unmanaged struct,fixed byte Payload[64], per-kind codecs inDriftlands.Runtime.Net.Codecs;CommandKindgainsSessionReady. §7's permitted "ClientsAndHost broadcasts" now have exactly one shape:OutcomeRpc(OutcomeEnvelope)onHostCommitChannel.
Amendments — 2026-09-11 (ADR-0009 player transform authority)
Broadcastfrom tick steps 1–3.SoftCloseDeposit(step 1,Advance) and death respawn (step 2,CombatState.Tick) producePlayerSnappedoutcomes through the tick'sHostContext.Broadcast— the same buffer the drain flushes in phase 4. Contract: the phase-4 flush drains everything buffered since the top of the tick, tick-step outcomes first (buffering order), then command outcomes (command order). §5 step 4's "a command never observes its own broadcast" is generalised to "nothing observes a broadcast before the phase-4 flush".- The tick-threshold drop rule reads no positions: a command from a player whose host-known position was overridden this tick still executes and fails its position gates (a deposited player's
< φ+Wstrike whiffs, it is not dropped).
Amendments — 2026-09-11 (ADR-0010 inventory replication)
CommandKindgainsEquip,Unequip(ADR-0010 §5).SpawnRequest(theRules.Commandsplaceholder) gets its shape{ SpawnKind Kind; uint StructureInstance; Cell Origin; int Slots }withSpawnRequest.SharedStorage(...);DespawnRequest { uint StructureInstance }andHostContext.QueueDespawnare its sibling — buffered and flushed after the envelope loop likeQueueSpawn.- Recorded rule: a command that mutates a CARRY must broadcast a delta-bearing outcome (ADR-0010 §2); the two shipped exceptions (harvest
BagFullafterDecrement; transfer restore path) are handled by the fold's unmatched-rejection rule and are the only ones.
Amendments — 2026-09-11 (ADR-0012 enemy simulation model)
- §5 step 4:
QueueSpawn/QueueDespawnare callable from tick steps 1–3 through the tick'sHostContext(the generalisation ADR-0009 made forBroadcast); the phase-4 flush drains tick-step requests first, then command requests. Enemy spawns fromLandActivated(step 1) ride this path — not the ADR-0005 tide-edge carve-out, not a host-rail table mutation.SpawnRequestgainsSpawnKind.Enemy.
Amendments — 2026-09-13 (NetcodeSession story-006b — conformance, no new decision)
Story 006b is the first story to exercise §5 step 4 end to end: SessionReadyCommand.Execute calls ctx.QueueSpawn(SpawnRequest.Player(...)), and the request is consumed by PlayerSpawner after the envelope loop, through the orchestrator's existing spawn delegate. Nothing in this ADR's decision changes — the step-4 rule is followed as written, and this note exists only so a reader tracing the spawn path from ADR-0002 finds its first real implementation. The SpawnRequest union's own shape is ADR-0010's (amended there the same day).
One consequence worth recording for story 008: Execute has no "already joined" guard, so two SessionReady commands for one PlayerKey in a single drain produce two spawns. That is pinned by a test (SessionReadyCommandTests.Two_session_ready_commands_for_one_key_in_the_same_drain_both_execute_and_both_spawn) and owned by the rejoin story, not by this ADR.