DRIFTLANDS co-op survival on drifting tides — studio progress

ADR-0008: Netcode Wire — Transport, Tick, RPC Surface, Replication Representations, Late-Join Snapshot

Status

Accepted

Accepted 2026-09-11. Gate 0 confirmed GO the same day by the two-machine friend playtest (production/playtests/gate-0-2026-09-11/, record production/gate-checks/gate-0-provisional-pass-2026-09-11.md); the provisional note and revisit clause this ADR was written under are discharged. Playtest observation carried to the register: occasional remote stutter at ≈ 150–180 ms RTT (interpolation tuning, ADR-0009 §6).

Date

2026-09-11

Engine Compatibility

Field Value
EngineUnity 6.3 LTS (6000.3.18f1) · Netcode for GameObjects 2.13.2 · Unity Transport 2.7.3 · Multiplayer Services SDK 2.2.1 (Relay, Authentication, Core) · Multiplayer Tools 2.2.9 (Network Simulator)
DomainNetworking
Knowledge RiskHIGH — every NGO 2.x claim in ADR-0001/0002/0005/0015 was unverifiable until now (NGO was not in the project manifest; modules/networking.md is 1.x-era). Mitigated 2026-09-11 by reading the 2.13.2 package source in the Gate 0 prototype project (Library/PackageCache/com.unity.netcode.gameobjects@cfd429cc91ec): RpcAttributes.cs, RpcTarget.cs, RpcParams.cs, NetworkTickSystem.cs, NetworkConfig.cs, NetworkVariableBase.cs, NetworkVariablePermission.cs, Collections/NetworkList.cs, Components/NetworkTransform.cs, Core/NetworkManager.cs, Transports/UTP/UnityTransport.cs, CHANGELOG.md. Facts below marked (src) were read there, not remembered.
References Consulteddocs/engine-reference/unity/VERSION.md, breaking-changes.md (Multiplayer: NGO 1.x deprecated, NetworkTransform.Update → OnUpdate), deprecated-apis.md, modules/networking.md (banner-marked 1.x — used for nothing), prototypes/combat-netcode-feel-concept/REPORT.md + Scripts/RelaySession.cs (Relay calls compiled and run 2026-09-11), the gate record, ADR-0001/0002/0003/0004/0005/0015, design/quick-specs/netcode-session-2026-08-27.md, architecture.md §3–§4 and Required-ADR row 8, QQ-03/QQ-04
Post-Cutoff APIs Used[Rpc(SendTo.X, Delivery = RpcDelivery.Reliable, InvokePermission = RpcInvokePermission.Everyone)] — RequireOwnership is [Obsolete] in 2.13.2 (src, CHANGELOG #3731); SendTo.{Server, ClientsAndHost, SpecifiedInParams} (src RpcTarget.cs); RpcParams.Receive.SenderClientId (src); NetworkManager.NetworkTickSystem.Tick event + NetworkConfig.TickRate (default 30, src); NetworkVariable<T> with NetworkVariableWritePermission.Server and NetworkVariableUpdateTraits{MinSecondsBetweenUpdates, MaxSecondsBetweenUpdates} (src); NetworkList<T> where T : unmanaged, IEquatable<T> + OnListChanged (src); NetworkTransform.AuthorityMode (Server/Owner), Interpolate, PositionThreshold (default 0.001), UseUnreliableDeltas, InterpolationTypes{LegacyLerp, Lerp, SmoothDampening}, Teleport(pos, rot, scale) (src); NetworkConfig.ConnectionApproval + ConnectionApprovalCallback(ConnectionApprovalRequest{Payload}, ConnectionApprovalResponse{Approved, CreatePlayerObject, Reason}) (src); NetworkManager.OnConnectionEvent (src); NetworkTopologyTypes.ClientServer (src — distributed authority exists in 2.x and is not used); NetworkObject.InScenePlaced for the two in-scene objects (IsSceneObject deprecated in 2.13, CHANGELOG #4000); UnityTransport.SetRelayServerData, GetCurrentRtt, MaxPayloadSize, DisconnectTimeoutMS, HeartbeatTimeoutMS (src); RelayService.CreateAllocationAsync / GetJoinCodeAsync / JoinAllocationAsync, AllocationUtils.ToRelayServerData(alloc, "dtls") (compiled + executed 2026-09-11: allocation europe-west4, join code returned)
Verification Required(V1) Stage-order probe (ADR-0002 Verification 1, still open): in which player-loop stage NGO 2.13 applies incoming RPCs and NetworkTransform state relative to NetworkTickSystem.Tick — a PlayMode probe with NetworkUpdateLoop.RegisterNetworkUpdate markers, result recorded in this ADR's Amendments. (V2) Per-object reliable ordering holds over Relay: two clients interleaving envelopes on HostCommitChannel arrive in per-sender order (BB AC-20c witness). (V3) Snapshot chunks of ≤ 4 096 B reassemble and apply before the joiner's player spawns; a 40 KB InstanceMetadata table round-trips byte-identical. (V4) Send-rate and interpolation values in §8 are provisional until the friend playtest CSV (gate0-rtt.csv, both machines) gives the real RTT/jitter distribution. (V5) A NetworkVariable written inside Drain() is visible on the client in the delta of the next tick, and an OutcomeRpc from the same drain arrives ≤ 1 tick earlier — measure the lag in ticks (expected 1) and the outcome-vs-variable order (specialist reading of NetworkBehaviourUpdater; ADR-0002 assumed same-tick). (V6) NetworkList initial contents arrive with the spawn message (ADR-0005/0010 NodesReady contingency). (V7) NetworkConfig.TickRate = 50 yields NetworkTickSystem.TickRate == 50 and a Tick cadence of 20 ms ± 1 ms in a standalone build.

Revisit clause (Gate 0)

Section At risk from a NO-GO? If NO-GO
§1 transport/session, §3 RPC surface, §4 encoding, §6 late joinNo — mechanism, not feel; the prototype and the 2026-09-11 Relay run exercised themunchanged
§2 tick rate, §8 send rates / interpolationYes — these are the knobs a "hits feel disconnected" verdict points atre-tuned by amendment from the CSV; ADR-0009's interpolation buffer inherits
§7 RangeSlackUnitsNo — TR-combat-003 forbids RTT-derived tolerance, so it is never re-tuned from a latency distribution, not even once by handchanged only by a combat-feel decision with the GDD's assent
§5 representation tablePartially — the rule (state = NetworkVariable, edge = outcome RPC) stands; a NO-GO naming enemy warping moves EnemyNet cadence into ADR-0012's revisitper-row amendment
The model (owner rail / host rail / one channel / host tick)No — ADR-0002/0015; a NO-GO against the model is a game-concept decision, not a wire amendmentescalate to /architecture-review, not here

Documents Amended by This ADR

Document Amendment
ADR-0002§5 step 4 / §7: a variable written in Drain() replicates at the next tick, not the same one (NGO 2.13.2 NetworkBehaviourUpdater subscribes to Tick first and sends before the drain); outcomes still leave in the same tick's batch — §2 here. Performance row: command latency = RTT + ≤ 1 tick for outcomes, ≤ 2 ticks for state. SubmitRpc attribute becomes [Rpc(SendTo.Server, Delivery = RpcDelivery.Reliable, InvokePermission = RpcInvokePermission.Everyone)] — RequireOwnership is obsolete in NGO 2.13 (compiles with a warning; the fence in ADR-0003 treats a warning-producing attribute as a failure). CommandEnvelope.payload encoding is fixed here (§4). HostCommitChannel gains the one outbound RPC, OutcomeRpc (§3) — the "ClientsAndHost broadcasts issued from inside the drain" ADR-0002 §7 permits, now with a shape. CommandKind gains SessionReady (§6).
ADR-0015PlayerKey source: client-persisted GUID carried in the ConnectionApproval payload via IPlayerIdentity (§1.3); Steam ID is a second provider at ship time. Replication rows named in ADR-0015 get their representation (§5).
ADR-0001t replication: NetworkVariable<long> on TideClockAdapter, WritePerm = Server, default traits (delta on change — t changes once per second by construction). Confirmed API (src).
ADR-0003Driftlands.Runtime.asmdef references gain Unity.Networking.Transport and Unity.Services.Multiplayer (behind ISessionLauncher only); Net_OnlyChannelDeclaresServerRpc is replaced by Net_RpcSurfaceIsClosed (§9 — exactly three [Rpc] methods in Runtime, by name and target) and gains Net_NoObsoleteRpcFlags. Rules.Commands.CommandKind gains SessionReady.
ADR-0004The late-join snapshot is a SnapshotDocument written through ISaveWriter from the same Serialize methods (§6) — the "same writers" clause in ADR-0004's Enables is now literal.
ADR-0005none in substance; LandNet spawn message carries NetworkList<NodeState> per ADR-0010 (V6 here).
architecture.md§2 NetcodeSession row loses its ⚠️; §3.1 "RPC ⚠️" edges become SubmitRpc/OutcomeRpc; §3.4 step 5 expanded (§6 here); Required-ADR row 8 ✅; QQ-03 closed ([Rpc(SendTo.…)] verified against source, InvokePermission supersedes RequireOwnership); QQ-04 unchanged (ship-time).
design/quick-specs/netcode-session-2026-08-27.mdRule 1 "Steam transport expected" → UTP + Unity Relay at MVP, Steam at ship (QQ-04); Rule 7's deferred list is now decided here.
docs/architecture/control-manifest.md✅ Regenerated 2026-09-11 (Manifest Version 2026-09-11). Foundation section: NetcodeSession is no longer "Gate-0 blocked"; the §9 fences and §7 bounds become Required/Forbidden rows at the next /create-control-manifest.
docs/registry/architecture.yamlsee Registry candidates at the end.

ADR Dependencies

Field Value
Depends OnADR-0002 (channel, envelope, drain), ADR-0003 (assemblies, HostContext, fences), ADR-0015 (replicated combat rows, PlayerKey concept, HostTickOrchestrator), ADR-0001 (t), ADR-0004 (writers/readers for the snapshot), ADR-0005 (LandNet as the land's network identity)
EnablesADR-0009 (player transform authority — inherits §2 tick, §5 rule, §8 interpolation baseline), ADR-0010 (inventory replication — CARRY/SharedStorage/NodeState representations must follow §5's rule), ADR-0011 (structure registry — same), ADR-0012 (enemy simulation — EnemyNet cadence, spawn contract)
BlocksNetcodeSession epic (every story); Combat wire stories; tide story 010 (t replication); land story 009; presentation-ui-hud-screens stays behind ADR-0010/0011
Ordering NoteFirst of the five. ADR-0009's interpolation buffer and ADR-0012's enemy cadence must budget the one-tick state lag of §2. Write ADR-0009 next (movement is the other half of the feel the playtest measures), then 0010, 0011, 0012. Every value marked provisional is re-tuned once, from the playtest CSV, by one amendment to this ADR — not by editing the four dependents.

Context

Problem Statement

Seven accepted ADRs and thirteen designed systems agree on a model — two authority rails, one ordered command channel drained once per host tick, a pure world — and every one of them stops at the byte boundary with a ⚠️: which RPC attribute, which NetworkVariable, what payload, what tick rate, how a late joiner gets the tables, what the host refuses. Row 8 of the architecture's Required-ADR table lists the open items: transport, the 2.x RPC attribute surface, per-state NetworkVariable-vs-RPC choices, late-join snapshot mechanics, intent-validation bounds, send rates, relevancy. Until they are fixed, no NetcodeSession story can be written and every Core/Feature epic behind ADR-0009–0012 is blocked. Gate 0 has been provisionally passed (user decision, 2026-09-11) on the 2026-08-25 prototype plus a same-day two-instance run over the real Relay hop, so this ADR is written now, with the friend playtest as its confirmation.

Constraints

Requirements (TR-IDs this ADR closes or bounds)

TR-net-001 (host-and-play, NGO 2.x), TR-net-002 (two rails on the wire), TR-net-003 (late-join full snapshot), TR-net-004 (disconnect despawns the body, session-scoped rows retained, rejoin = late join), TR-net-005 (transport — Relay now, Steam ship-time), TR-net-006 (one ordered channel — representation confirmed), TR-net-008 (execution-phase ordering — V1 records it), TR-combat-002 (StrikeIntent encoding), TR-combat-003 (fixed tolerances never scaled by RTT — §7), TR-combat-007 (telegraph = replicated Phase), TR-combat-008 (dash i-frames: owner mirror fact via DashStartedCommand, host wins if not arrived), TR-harv-001 (swingsRemaining replicated — representation class), TR-harv-007 (host proximity sanity check — §7 bounds), TR-craft-008 (craft commit host-side — CraftCommit encoding), TR-ai-001 (host-simulated enemies, clients render replicated transform + Phase), TR-tide-003 (t replicated on join/change), TR-tint-004 (flipbook frameIndex as one replicated int — §5). Bounded but decided by dependents: TR-move-002/004/005 (ADR-0009), TR-inv-002/004/007 (ADR-0010), TR-bb-002/007 (ADR-0011).

Decision

**Alternative A: UTP + Unity Relay behind ISessionLauncher, ClientServer topology, host-and-play at 50 Hz; replicated state is a server-written NetworkVariable<T>/NetworkList<T> on the NetworkObject that owns it; every host-confirmed event is one [Rpc(SendTo.ClientsAndHost)] OutcomeRpc(OutcomeEnvelope) on HostCommitChannel, flushed from drain phase 4; the owner→host direction is SubmitRpc and nothing else; payloads are fixed-layout INetworkSerializable structs per CommandKind/OutcomeKind through a codec registry in Runtime; a late joiner receives one SnapshotDocument (the reconciled tables through the ADR-0004 writers) in ≤ 4 KB reliable chunks on a SessionNet object, then declares itself ready with a SessionReadyCommand on the channel; PlayerKey is a client-persisted GUID carried in the connection-approval payload; the host refuses envelopes outside a small, fixed validation table; relevancy is none.**

1. Transport and session

2. Tick and timing

3. The RPC surface — closed, three methods

Method Object Attribute Direction Purpose
SubmitRpc(CommandEnvelope env, RpcParams rpc)HostCommitChannel (in-scene, always spawned, server-owned)[Rpc(SendTo.Server, Delivery = Reliable, InvokePermission = Everyone)]owner → hostthe only inbound host-rail RPC (ADR-0002); enqueue only
OutcomeRpc(OutcomeEnvelope o)HostCommitChannel[Rpc(SendTo.ClientsAndHost, Delivery = Reliable)]host → everyonethe only confirmed-event broadcast; sent only from drain phase 4 (ctx.Broadcast flush), in command order
SnapshotChunkRpc(SnapshotChunk c, RpcParams rpc)SessionNet (in-scene, always spawned, server-owned)[Rpc(SendTo.SpecifiedInParams, Delivery = Reliable)] targeting RpcTarget.Single(joinerClientId, RpcTargetUse.Temp)host → one joinerlate-join snapshot chunks (§6)

No other method in Driftlands.Runtime or Driftlands.Presentation carries RpcAttribute (fence §9). NGO-internal RPCs (NetworkTransform, NetworkAnimator if ever used) are package code and outside the fence's assemblies. Both HostCommitChannel and SessionNet are separate NetworkObjects on purpose: ordering is per object, and a 40 KB snapshot burst must not sit in front of a strike envelope.

4. Encoding

CommandEnvelope (INetworkSerializable, unmanaged)   OutcomeEnvelope (INetworkSerializable, unmanaged)
  byte    Kind        (CommandKind)                    byte    Kind        (OutcomeKind)
  uint    ClientSeq   (owner-stamped)                  ulong   OwnerClientId (or ulong.MaxValue for enemy/none)
  byte    Length      (0..MaxCommandPayloadBytes)      byte    Length
  fixed byte Payload[64]  (fixed-layout per Kind)      fixed byte Payload[128]   (128 since ADR-0010: CarrySync)
  NetworkSerialize writes Kind, ClientSeq, Length, then exactly Length bytes one by one — never a byte[]

5. Replication representations — the rule and the table

Rule. A value whose current state a late joiner needs is a server-written NetworkVariable<T> (or NetworkList<T> for a collection of unmanaged rows) on the NetworkObject that owns it — delivered with the spawn message, delta on change, no event needed. A fact that must be observed as an edge (something happened, a number to show, a sound to play) is an OutcomeRpc from the drain. Never both for the same fact — with one registered exception: a table that is snapshot-delivered and append-only may carry its new row on the outcome edge, provided the row is idempotent on apply (insert-if-absent, keyed by its id). InstanceMetadata is the only such table: CraftCommitted carries the row so the crafting flyout needs no round-trip; a duplicate on a joiner is a no-op, and a joiner cannot miss one because it has no player object — and so receives no gameplay outcome it must act on — until its snapshot has applied. Never an owner-written NetworkVariable for an owner→host fact (ADR-0015, registered). A late joiner sees current state and no past events — that is correct by construction.

State / fact Representation Object Write Traits / cadence Owner ADR
tide tNetworkVariable<long>TideClockAdapter (in-scene)Serverdefault (changes 1 Hz)ADR-0001
player Hp, State, BleedOutRemainingTicksone NetworkVariable<PlayerActorNet> (int, byte, int)PlayerNet on the player objectServerdefault — written by CombatState at end of Tick/drain, delta same tickADR-0015 rows
player transformNetworkTransform AuthorityMode = Owner, Interpolate = true on non-ownersplayer objectOwnertick rate; details ADR-0009ADR-0009
enemy PhaseNetworkVariable<byte> (EnemyPhase)EnemyNet on the enemy object (one NetworkObject per enemy, pre-spawn values ride the spawn message)ServerdefaultADR-0015 / ADR-0012 ✅
enemy AnimFrame (TR-tint-004 flipbook index)NetworkVariable<byte> written by the host EnemyAnimator from the phase fractionEnemyNetServerMinSecondsBetweenUpdates = 0.05 (send-gating, source-confirmed)ADR-0012 ✅
enemy transformEnemyTransform : NetworkTransform AuthorityMode = Server, X/Y, PositionThreshold 0.02enemy objectServerfull tick rate; global buffer (players win)ADR-0012 ✅
node swingsRemainingNetworkList<NodeNetRow> (ushort NodeIndex, byte SwingsRemaining), index-aligned with LandInstance.NodesLandNetServerpopulated before Spawn() (ADR-0005); full contents ride the spawn message (source-confirmed, ADR-0010 V1)ADR-0010 ✅
structure registryNetworkList<StructureNetRow> (uint InstanceId, ushort StructureIndex, short X, short Y, byte W, byte H)in-scene BaseNet (filled before StartHost)Server (RegistryReplicator after each drain — a mirror)per-row delta; relics as index 0xFFFFADR-0011 ✅
SharedStorage contentsslot-indexed NetworkList<StackNetRow> (ushort ItemIndex, ushort Count, uint InstanceId), fixed length storageSlotsStorageNet per container, spawned by PlaceStructureServer (StorageReplicator after each drain — a mirror, not host-rail state)per-row delta on changeADR-0010 ✅
CARRYnever on the wire as state — the owner's CarryView is a pure fold over the outcomes the host already broadcasts, seeded by an owner-addressed CarrySync outcome from SessionReadyCommand.Execute; transfers are predicted, harvests are not———ADR-0010 ✅
Known* setssnapshot only (§6); a new member arrives as its own outcome (TemplateLearned is ADR-0005's Append edge — an OutcomeRpc kind reserved here)———ADR-0004/0005
InstanceMetadatasnapshot (§6) + the new row on CraftCommitted — the registered append-only exception; apply is insert-if-absent by InstanceId———ADR-0004/0010
enemy HP, stagger meter, immunitynever (ADR-0015)————
PlayerSnapped (ADR-0009), SwingLanded, DamageAbsorbed, HarvestResolved, StructurePlaced/Demolished, CraftCommitted, PlayerDowned/Died, CommandRejectedOutcomeRpcHostCommitChannelhost, drain phase 4reliable, in command orderADR-0002 §7
predicted FX (stance, whiff, ghost, craft stance)local only———never replicated

EnsureNetworkVariableLengthSafety stays false (src default): every replicated struct here is fixed-size; a variable-length row is a design error, not a runtime case.

6. Late join, rejoin, disconnect

client: ISessionLauncher.JoinAsync(code)                 host
   ConnectionData = PlayerKey (16 B) ──────────────►  ConnectionApprovalCallback: validate; CreatePlayerObject=false; map clientId→PlayerKey
   ◄── approval; in-scene NetworkObjects synced: HostCommitChannel, SessionNet, TideClockAdapter(t), LandNet(s) + NodeState lists
   ◄── SnapshotChunkRpc × N  (SessionNet; ≤ 4 096 B each; {ushort index, ushort count, uint docLength, byte[] bytes})
   applies SnapshotDocument once index==count-1 and every chunk is present (chunks arrive in order — one object)
   SubmitRpc(SessionReady{PlayerKey}) ──────────────►  Drain: SessionReadyCommand.Execute: row := CombatState.GetOrCreate(PlayerKey) (ADR-0015 rejoin rule);
                                                        placement := tide EC1 (still-docked land → last pos, else depositPoint/home); ctx.QueueSpawn(PlayerSpawn(clientId, key, pos))
   ◄── player object spawn (after the envelope loop) with PlayerActorNet + NetworkTransform state ◄── OutcomeRpc(PlayerJoined) to everyone

7. Intent validation — what the host refuses before Execute

All at receipt inside SubmitRpc/Enqueue (cheap, no gameplay knowledge) or as the first lines of Execute (gameplay gates already in ADR-0002/0015):

Check Where Rule On failure
Kind is a defined CommandKindreceiptenum rangedrop, count
Length ≤ MaxCommandPayloadBytes and equals the codec's fixed sizereceiptexact sizedrop, count
ClientSeq > last[PlayerKey]receiptADR-0002 §8drop (duplicate/replay)
ClientSeq ≤ last + seqWindow (64)receipta client cannot jump its counterdrop, count
≤ maxEnvelopesPerTickPerClient (16)receiptflood bound — a human emits < 5drop excess, count
sender has an actor row (joined)Execute§6CommandRejected(ActorDead)
proximity slack (TR-harv-007, BB F1, combat AC-11/12): HostKnown(attacker) within range + rangeSlack of the referent, rangeSlack = 1.0 uExecute (already the commands' gates)fixed, never scaled by RTT (TR-combat-003); 1.0 u ≈ moveSpeed 6 u/s × 0.17 s covers one RTT of owner movement the host has not seenthe command's own ReasonCode
ResolveTick sanity: ≥ NowTick and ≤ NowTick + maxResolveAheadTicks (50 = 1 s)Executehost-stamped, so only a codec bug can violate itassertion (host log), reject

Counters (droppedByRule[Kind]) are exposed on SessionState for the HUD debug overlay and the playtest CSV. None of these is a gameplay rule; every gameplay gate stays where ADR-0002/0015 put it.

8. Send rates, interpolation baseline, relevancy — provisional (V4)

9. Fences (EditMode, every CI pass — additions to ADR-0003 §6)

Test Asserts
Net_RpcSurfaceIsClosedexactly three methods in Driftlands.Runtime ∪ Driftlands.Presentation carry RpcAttribute: HostCommitChannel.SubmitRpc (SendTo.Server), HostCommitChannel.OutcomeRpc (SendTo.ClientsAndHost), SessionNet.SnapshotChunkRpc (SendTo.SpecifiedInParams) — read via CustomAttributeData; replaces Net_OnlyChannelDeclaresServerRpc
Net_NoObsoleteRpcFlagsno RpcAttribute sets RequireOwnership (named argument absent)
Net_TopologyIsClientServerthe in-scene NetworkManager prefab/scene serialises NetworkTopology: 0 (ClientServer) and ConnectionApproval: 1; TickRate equals netcode.json's value at boot (PlayMode)
Net_NoOwnerWrittenNetworkVariablesno field of type NetworkVariable<>/NetworkList<> in Runtime/Presentation is constructed with NetworkVariableWritePermission.Owner (IL scan of ctor calls, same technique as IlScan)
Net_CodecsFitTheCapevery registered codec's fixed size ≤ MaxCommandPayloadBytes; every CommandKind/OutcomeKind has exactly one codec; round-trip Write→Read is identity for a fixture per kind
Net_NoRelevancyFilterno assignment to NetworkObject.CheckObjectVisibility in Runtime/Presentation (raw-text fence, like GpuGuardFenceTests)
Net_NoVariableReadsInOutcomeHandlersreview-checklist item (IL inspection is analyzer territory, ADR-0003 TD-ADR C5): no OutcomeRpc consumer reads a NetworkVariable.Value in the handler body
Net_RejectionCarriesNoStatethe CommandRejected codec's FixedSize == 5 (uint ClientSeq + byte ReasonCode) and it writes nothing else; ReasonCode stays the closed enum of ADR-0003 — a value naming world state (a position, an HP, another player) is rejected at review
Net_SessionLauncherIsTheOnlyTransportToucheronly RelaySessionLauncher (and later SteamSessionLauncher) references Unity.Services.* or UnityTransport.SetRelayServerData/SetConnectionData

Architecture Diagram

                     ┌── ISessionLauncher (Relay now · Steam at ship) ── UTP 2.7.3 ── Relay ──┐
 OWNER (client)      │                                                                       │   HOST
 InputFrame → machines → ICommand ─ codec ─► CommandEnvelope ═ SubmitRpc (HostCommitChannel, reliable, per-object order) ═► Enqueue
                                                                                              NetworkTickSystem.Tick @ 50 Hz
                                                                                              HostTickOrchestrator: Advance → CombatState.Tick → EnemyAI.Tick → Drain
                                                                                                 phase 4 flush: ctx.Broadcast → OutcomeRpc (ClientsAndHost) ─┐
 NetworkTransform (Owner auth) ═══════════════════════════════════════════════════════════════► host-known positions              │
 ◄══ NetworkVariable deltas: t · PlayerActorNet · EnemyPhase/AnimFrame · NodeState lists · NT(Server auth enemies) ◄══ same tick ◄┘
 ◄══ OutcomeRpc: SwingLanded · DamageAbsorbed · HarvestResolved · StructurePlaced… · CommandRejected(owner only) ◄════════════════┘
 late join: approval(PlayerKey) → in-scene objects sync → SnapshotChunkRpc×N (SessionNet) → SessionReady (channel) → player spawn

Key Interfaces

namespace Driftlands.Runtime.Net
{
    public interface ISessionLauncher {                       // the ONLY transport toucher (QQ-04 seam)
        Task<string> HostAsync(int maxClients);               // returns the join code
        Task JoinAsync(string joinCode);
        Task LeaveAsync();                                    // host: FlushAllAsync then Shutdown
        ulong CurrentRttMs { get; }                           // UnityTransport.GetCurrentRtt(ServerClientId); 0 on the host
    }
    public interface IPlayerIdentity { PlayerKey Key { get; } }   // persisted GUID at MVP; Steam ID provider at ship

    public unsafe struct CommandEnvelope : INetworkSerializable {   // ADR-0002's wire unit, encoding fixed here; unmanaged — no byte[] on the wire
        public CommandKind Kind; public uint ClientSeq; public byte Length; public fixed byte Payload[64];
        public void NetworkSerialize<T>(BufferSerializer<T> s) where T : IReaderWriter { /* Kind, ClientSeq, Length, then Length bytes one at a time */ }
    }
    public unsafe struct OutcomeEnvelope : INetworkSerializable { public OutcomeKind Kind; public ulong OwnerClientId; public byte Length; public fixed byte Payload[128]; /* 128 B since ADR-0010 (CarrySync ≈ 90 B) */ }
    public struct SnapshotChunk : INetworkSerializable { public ushort Index, Count; public uint DocLength; public byte[] Bytes; /* ≤ SnapshotChunkBytes — the ONE byte[] on the wire: a join allocates a few dozen 4 KB arrays once */ }

    public sealed class HostCommitChannel : NetworkBehaviour {    // exactly one, in-scene, always spawned (ADR-0002)
        [Rpc(SendTo.Server, Delivery = RpcDelivery.Reliable, InvokePermission = RpcInvokePermission.Everyone)]
        private void SubmitRpc(CommandEnvelope env, RpcParams rpc);          // enqueue only; owner := rpc.Receive.SenderClientId
        [Rpc(SendTo.ClientsAndHost, Delivery = RpcDelivery.Reliable)]
        private void OutcomeRpc(OutcomeEnvelope o);                          // called ONLY from the drain's phase-4 flush
    }
    public sealed class SessionNet : NetworkBehaviour {           // exactly one, in-scene, always spawned; snapshot traffic only
        [Rpc(SendTo.SpecifiedInParams, Delivery = RpcDelivery.Reliable)]
        private void SnapshotChunkRpc(SnapshotChunk c, RpcParams rpc);       // host → RpcTarget.Single(joiner, RpcTargetUse.Temp)
    }
    public interface ICommandCodec { CommandKind Kind { get; } int FixedSize { get; } void Write(ICommand c, FastBufferWriter w); ICommand Read(FastBufferReader r, ulong owner, uint seq, long nowTick); }
    public interface IOutcomeCodec { OutcomeKind Kind { get; } int FixedSize { get; } void Write(IOutcome o, FastBufferWriter w); IOutcome Read(FastBufferReader r, ulong owner); }

    public struct PlayerActorNet : INetworkSerializable, IEquatable<PlayerActorNet> { public int Hp; public byte State; public int BleedOutRemainingTicks; /* … */ }
    public sealed class PlayerNet : NetworkBehaviour { public NetworkVariable<PlayerActorNet> Actor = new(writePerm: NetworkVariableWritePermission.Server); }
    public sealed class EnemyNet  : NetworkBehaviour { public NetworkVariable<byte> Phase = new(writePerm: Server); public NetworkVariable<byte> AnimFrame = new(writePerm: Server); }

    public sealed class SessionState {                          // host: clientId ↔ PlayerKey; per-key last ClientSeq; drop counters
        public bool TryKey(ulong clientId, out PlayerKey key); public ulong? ClientOf(PlayerKey key);
        public IReadOnlyDictionary<CommandKind, int> DroppedByRule { get; }
    }
}

namespace Driftlands.Rules.Commands                            // engine-free additions
{
    public sealed record SessionReadyCommand(ulong OwnerClientId, uint ClientSeq, PlayerKey Key) : ICommand;   // GetOrCreate row · placement per tide EC1 · QueueSpawn(player)
    // CommandKind += SessionReady;  ReasonCode += NotReady
}

namespace Driftlands.Data
{
    /// netcode.json — loaded and validated by ADR-0006's loader; every wire number lives here, none in C#.
    public sealed record NetConfig(int TickRate /*50; 20–60*/, int MaxCommandPayloadBytes /*≤64 — the fixed buffer is the ceiling*/, int MaxOutcomePayloadBytes /*≤128 since ADR-0010*/,
        int SnapshotChunkBytes /*4096; ≤ 6000*/, int SeqWindow /*64*/, int MaxEnvelopesPerTickPerClient /*16*/, float RangeSlackUnits /*1.0; 0–2*/,
        int MaxResolveAheadTicks /*50*/, float NtPositionThreshold /*0.01*/, bool NtUnreliableDeltas /*true*/, int DisconnectTimeoutMs /*30000*/);
}

Alternatives Considered

Alternative B: everything as RPCs (no NetworkVariables)

Alternative C: everything as NetworkVariables/NetworkLists (no outcome RPCs)

Alternative D: OnSynchronize on SessionNet for the snapshot

Alternative E: one NetworkList per table as the snapshot

Alternative F: 30 Hz tick (NGO default)

Alternative G: Steam transport now

Consequences

Positive

Negative

Risks

GDD Requirements Addressed

GDD / spec Requirement How This ADR Addresses It
netcode-session quick-spec Rule 1, 4, 5, 7 (TR-net-001..005)host-and-play; late-join contract; despawn + session-scoped rows; the deferred wire list§1 transport/identity; §6 snapshot + SessionReady; §3–§5 the wire
netcode-session Rule 2 (TR-net-002), Rule 6two rails, forbidden patterns§5 rule + table; Net_NoOwnerWrittenNetworkVariables; no relevancy, no DA topology
TR-net-006/008one ordered channel; execution-phase order§3 (per-object ordering, two objects on purpose); V1 records the stage
combat-system TR-combat-002/003/007/008intent shape; fixed tolerances; telegraph replicated; dash i-frames owner-authoritative§4 StrikeResolve layout (no tick); §7 RangeSlackUnits fixed; §5 EnemyNet.Phase; DashStarted as a command (ADR-0015)
harvesting TR-harv-001/007swingsRemaining replicated; host proximity sanity check; host-confirmed flyouts§5 NetworkList<NodeState> class (ADR-0010 details); §7; HarvestResolved outcome
crafting TR-craft-008commit host-side; stance client-predictedCraftCommit command + CraftCommitted outcome carrying the mark row
enemy-ai TR-ai-001host-simulated; clients render replicated transform + telegraph§5 EnemyNet + server-auth NetworkTransform
tide TR-tide-003t replicated on join/change§5 row 1
art-bible TR-tint-004flipbook frameIndex as one synced int§5 EnemyNet.AnimFrame (enemies; players derive from their own machine)
movement / inventory / base-building TR-move-002/004/005, TR-inv-002/004/007, TR-bb-002/007representation classesbounded by §5's rule; decided in ADR-0009/0010/0011

Performance Implications

Migration Plan

Validation Criteria

Related Decisions

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

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

Amendments — 2026-09-11 (ADR-0011 structure registry replication)

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

Editorial amendments — 2026-09-11 (TD-MANIFEST C3)

Editorial amendments — 2026-09-12 (NetcodeSession story 002, installed-package facts)

Amendments — 2026-09-12 (V1 stage order — NetcodeSession story 003)

V1 is closed. Measured on NGO 2.13.2 / Unity 6000.3.18f1 with the in-process probe (Tests/PlayMode/Net/StageOrderProbePlayTests.cs, two NetworkManagers over UTP loopback under the Multiplayer Tools NetworkSimulator at a measured 140 ms RTT, ≥ 500 ticks at 50 Hz): **incoming RPCs and owner-authoritative NetworkTransform state apply BEFORE NetworkTickSystem.Tick** in 461 of 461 comparable frames (100 %, threshold 99 %; run of record probe003i, asserted by the fixture — skipped grammar lines 0, comparable-frame floor 200). Record and logs: production/playtests/netcode-session-003-stage-order-probe.md, production/playtests/netcode-session-003-logs/.

Mechanism, confirmed against source: message dispatch (RPC handlers and NetworkTransformMessage → TransformStateUpdate) runs from MessageManager.ProcessIncomingMessageQueue() in NetworkUpdateStage.EarlyUpdate (NetworkManager.cs:364); Tick fires from NetworkManager.NetworkUpdate(PreUpdate) via NetworkTimeSystem.UpdateTime → NetworkTickSystem.UpdateTick (NetworkManager.cs:414, NetworkTimeSystem.cs:167, NetworkTickSystem.cs:103). The PlayerLoop orders EarlyUpdate → FixedUpdate → PreUpdate, so the BEFORE/AFTER result is structural, not statistical. (The probe's stage-attribution table is read with the next-marker rule — the probe's markers register after NetworkManager's own system, and NGO registers no Initialization system, NetworkManager.cs:1221-1228 — and agrees: messages in EarlyUpdate, Tick in PreUpdate.)

Measured under continuous per-frame owner-transform writes and one RPC per frame from a single client; the structural argument does not depend on write frequency, so sparse writes are not expected to differ — but that case was not measured.

Consequences: §2 and §7 stand unchanged; the drain on Tick sees every command received in the same frame. ADR-0009 §7's HostKnown staleness bound stays "≤ 1 RTT + 1 tick"; RangeSlackUnits stays 1.0. Tick may fire 0..N times per frame (NetworkTickSystem.cs:94-107) — consumers must not assume one tick per frame.

Editorial (engine facts from the probe, for later stories): the Multiplayer Tools NGO adapter is process-global and rebinds to the last-instantiated NetworkManager (Adapters/Ngo1/Ngo1AdapterInitializer.cs:48-57), so in-process simulation applies to one manager's transport; its per-packet delay is counted four times per round trip on that pipeline (35 ms → 140 ms RTT); a runtime-built NetworkObject cannot be a dynamic-spawn source (GlobalObjectIdHash is internal — use a prefab asset, which OnValidate hashes even when saved during play); batch-mode PlayMode ignores Application.targetFrameRate; two in-process managers share Time.frameCount.

Amendments — 2026-09-12 (story 005 — RPC method visibility)

The Key Interfaces snippet in §3 shows private void SubmitRpc(...). The shipped HostCommitChannel declares both SubmitRpc and OutcomeRpc as internal (visible to the two test assemblies through InternalsVisibleTo) so the EditMode/PlayMode fixtures can drive them without a live RPC round trip. NGO's ILPP performs no access-modifier check on [Rpc] methods (NetworkBehaviourILPP.cs — static/abstract/generic/return-type/name-suffix checks only), the surface fence Net_RpcSurfaceIsClosed pins the exact set regardless of visibility, and no production assembly is granted internals. Visibility is therefore a test seam, not a widening of the surface. Also recorded: within one tick's flush, owner-addressed CommandRejected outcomes are sent AFTER every broadcast outcome of that tick (ADR-0002 §7 order: outcomes in append order, then rejections) — a client may see a later command's confirmed outcome before an earlier command's rejection from the same tick. Rejections are a separate, owner-addressed stream and carry no ordering guarantee against broadcasts.

Amendments — 2026-09-13 (NetcodeSession story-006b — readiness seam, host-local join, player spawn)