DRIFTLANDS co-op survival on drifting tides — studio progress

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
EngineUnity 6.3 LTS (6000.3) · Netcode for GameObjects 2.x (prototype: 2.13.2)
DomainNetworking / Core
Knowledge RiskHIGH — 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 ConsultedNGO 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 OnADR-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
EnablesADR-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)
BlocksThe 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 NoteAccept 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

Requirements

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).

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

Alternative C: Commands as an owner-writable NetworkList<CommandEnvelope>

Alternative D (execution timing): Execute on RPC receipt instead of draining per tick

Consequences

Positive

Negative

Risks

GDD Requirements Addressed

GDD System Requirement How This ADR Addresses It
netcode-sessionRule 2 host rail (damage, storage, commits, registries)every listed mutation is a CommandKind executed only by the drain
netcode-sessionBB 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-34soft-close evaluated first on the host tick; pending damage ≥ φ+W discarded; enemy attacks mid-windup cancelleddrain steps 1 and 2
combat States & Transitions / AC-32mutual kill on the same tick: enemy damage first, then the player's; both applydrain phase 0 (step 1b) applies PendingEnemyDamage before the envelope loop — TD-ADR C1, ruled 2026-08-28
harvesting EC4 / AC-30 and EC6 / AC-31same threshold as combat (< φ+W resolves, ≥ fails); teardown sequenced after any in-flight resolutionuniform drop rule in step 2; teardown release in step 3
combat Rule 7 / harvesting Rule 6 / crafting Rule 7 / BB Rule 6 / inventory Rule 6gates re-sampled at resolution, never cachedExecute reads only HostContext; intent carries intent-time data only (worked command)
combat EC1 / harvesting EC1 / crafting EC5 / BB EC8–9fired commits never un-happen on disconnectreceived envelopes execute regardless of sender connection state
combat Rule 8 / harvesting Rule 7 / BB Rule 6rejected outcome = silent honest no-op; confirmed effects host-broadcast onlyCommandResult.Success gates Broadcast; failures broadcast nothing
save-persistence Rule 3per-confirmed-action durabilityPartially — 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 deduplicatedalways-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 resolutionctx.Tide.Evaluate inside Execute (ADR-0001 forbidden pattern honoured)

Performance Implications

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

Amendments

Related Decisions

Amendments — 2026-09-11 (ADR-0008 netcode wire)

Amendments — 2026-09-11 (ADR-0009 player transform authority)

Amendments — 2026-09-11 (ADR-0010 inventory replication)

Amendments — 2026-09-11 (ADR-0012 enemy simulation model)

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.