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/, recordproduction/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 |
|---|---|
| Engine | Unity 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) |
| Domain | Networking |
| Knowledge Risk | HIGH — 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 Consulted | docs/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 join | No — mechanism, not feel; the prototype and the 2026-09-11 Relay run exercised them | unchanged |
| §2 tick rate, §8 send rates / interpolation | Yes — these are the knobs a "hits feel disconnected" verdict points at | re-tuned by amendment from the CSV; ADR-0009's interpolation buffer inherits |
§7 RangeSlackUnits | No — TR-combat-003 forbids RTT-derived tolerance, so it is never re-tuned from a latency distribution, not even once by hand | changed only by a combat-feel decision with the GDD's assent |
| §5 representation table | Partially — the rule (state = NetworkVariable, edge = outcome RPC) stands; a NO-GO naming enemy warping moves EnemyNet cadence into ADR-0012's revisit | per-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 amendment | escalate 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-0015 | PlayerKey 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-0001 | t replication: NetworkVariable<long> on TideClockAdapter, WritePerm = Server, default traits (delta on change — t changes once per second by construction). Confirmed API (src). |
| ADR-0003 | Driftlands.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-0004 | The 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-0005 | none 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.md | Rule 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.yaml | see Registry candidates at the end. |
ADR Dependencies
| Field | Value |
|---|---|
| Depends On | ADR-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) |
| Enables | ADR-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) |
| Blocks | NetcodeSession epic (every story); Combat wire stories; tide story 010 (t replication); land story 009; presentation-ui-hud-screens stays behind ADR-0010/0011 |
| Ordering Note | First 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
- The model is fixed and not revisited here: owner-authoritative movement/dash/CARRY, host-authoritative everything else, prediction for the local player only, no rollback, no dedicated server, no host migration, no offline simulation (technical preferences, netcode-session Rule 6 — "the ADR may add detail; it may not relax these").
- ADR-0002: exactly one inbound host-rail RPC on exactly one always-spawned
NetworkObject; enqueue-only on receipt; execution only inDrain()underNetworkTickSystem.Tick; confirmed outcomes leave through host-rail replication only. - ADR-0003:
Rulesis engine-free — commands are plain records; anything that names NGO lives inDriftlands.Runtime; reflection fences are the floor. - ADR-0015: replicated combat rows are exactly player
Hp/State/BleedOutRemainingand enemyPhase; enemy HP and meters never replicate; every session-scoped row keys onPlayerKey, neverclientId. - QQ-04 (2026-09-02): the transport is a byte-pipe behind
ISessionLauncher; UTP + Relay for the playtest and MVP, Steam at ship. - NGO 2.13.2 as verified:
RequireOwnershipobsolete; reliable RPC ordering is documented perNetworkObject;NetworkList<T>elements must beunmanaged; the stage at which incoming messages are applied relative toTickis undocumented (V1). - Session capacity 2 at MVP, 2–5 at Tier 1 (netcode-session Rule 3) — no mechanic branches on count; the wire may not either.
- Gameplay values are data-driven (coding standards): every numeric knob here is a
netcode.jsonfield loaded and validated by ADR-0006's loader, not a C# constant.
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
ISessionLauncher(Driftlands.Runtime.Net) is the only type that knows how bytes get to another machine:HostAsync() → JoinCode,JoinAsync(string code),Leave(),CurrentRttMs(readsUnityTransport.GetCurrentRtt(ServerClientId)). MVP implementationRelaySessionLauncher:UnityServices.InitializeAsync→ anonymous sign-in →CreateAllocationAsync(maxClients)/JoinAllocationAsync(code)→utp.SetRelayServerData(AllocationUtils.ToRelayServerData(alloc, "dtls"))→StartHost()/StartClient(). NeverSetConnectionData(ip, port)on a Relay session (NGO manual; the prototype comment). The Steam launcher at ship is a second class behind the same interface (Steamworks.NET per QQ-04) and touches nothing else.- Topology:
NetworkConfig.NetworkTopology = ClientServer(src) — distributed authority is present in NGO 2.x and is forbidden here (distributed_authority_topology): the host rail exists precisely so one machine owns the world. - Identity —
PlayerKey(ADR-0015's open source).IPlayerIdentity.Keyis aGuidcreated once and persisted atApplication.persistentDataPath/Driftlands/player-key(outside the world save — it identifies the machine's player, not the world).NetworkConfig.ConnectionApproval = true; the client setsNetworkConfig.ConnectionData = key.ToByteArray()(16 B) beforeStartClient; the host'sConnectionApprovalCallbackreadsrequest.Payload, rejects (Approved = false,Reason = "bad key") anything that is not exactly 16 bytes or isGuid.Empty, setsCreatePlayerObject = false(the player spawns later, §6), and recordsclientId → PlayerKeyinSessionState. Two connected clients presenting the same key: the second is rejected (Reason = "key in use") — a rejoin only happens after NGO has raised the first client's disconnect. Steam at ship:IPlayerIdentityreturns the Steam ID as aGuid-shaped 16 B value with a provider tag byte; the approval path is unchanged. - Timeouts (UTP defaults, src, kept):
DisconnectTimeoutMS30 000,HeartbeatTimeoutMS500,ConnectTimeoutMS1 000 ×MaxConnectAttempts60. Rationale: netcode-session Rule 5 makes a disconnect a late join later, so a long timeout costs nothing but a frozen remote body for ≤ 30 s, while a short one turns Wi-Fi hiccups into despawn/rejoin churn. Provisional (V4). - Host quit:
ISessionLauncher.Leave()on the host awaitsSave.FlushAllAsync()(ADR-0004) thenNetworkManager.Shutdown(); every client'sOnConnectionEvent(Disconnected)routes to the title screen with the reason string (UI stack ADR-0014 owns the screen). No host migration.
2. Tick and timing
NetworkConfig.TickRate = 50(fromnetcode.json, validated 20–60).tickDt = 0.02 s.NetworkTickSystem.Tickis the drain hook (ADR-0002);HostTickOrchestratoris its sole subscriber (ADR-0015). AllResolveTickarithmetic (ADR-0015 §3) usesNetworkManager.NetworkTickSystem.ServerTime.Tickon the host; clients never compute a resolve tick.- Replication lags the drain by one tick (specialist finding, NGO 2.13.2 source).
NetworkBehaviourUpdatersubscribes toNetworkTickSystem.TickduringNetworkManagerinitialisation — before any user subscriber — andTickis a plainevent Actioninvoked in registration order; its handler sends the objects dirtied before this tick and only then doesHostTickOrchestrator's drain run. ANetworkVariablewritten insideDrain()is therefore collected and sent at the next tick. ADR-0002's "replicates in the same tick's delta" and the registry'sdrain_hookreason are amended accordingly (below);NetworkUpdateLoopstage registration for the drain stays rejected (ADR-0002drain_hook) — a one-tick lag is a known, bounded cost; an undocumented stage coupling is not. - Ordering consequence, binding on every consumer: an
OutcomeRpcflushed from drain phase 4 rides the same tick's message batch, so a client may receiveSwingLanded/DamageAbsorbed/HarvestResolvedone tick before theNetworkVariabledelta the same drain produced (enemyPhase, playerHp, aNodeStaterow). Presenters and HUD act on the outcome's own payload (DamageTaken,Yield, …) and treat variables as state that will catch up; nothing reads aNetworkVariableinside an outcome handler expecting it to already reflect that outcome (registered:outcome_handler_reads_variable). - Command latency budget: RTT + ≤ 2 ticks = RTT + 40 ms worst case for state; RTT + ≤ 1 tick for outcomes (the figure ADR-0002's performance row assumed applies to outcomes only). 50 Hz halves what 30 Hz would have cost here.
NetworkTransformsends at the tick rate (NGO semantics): 50 state updates/s per moving object.Time.fixedDeltaTime = 1 / TickRateis set once at boot by NetcodeSession for ADR-0009's benefit — owner movement integrates at the same period it replicates at; the value is recorded here because it is shared, validated innetcode.json(FixedDeltaTime == 1/TickRate), and ADR-0009 may not change one without the other. Phase alignment betweenFixedUpdateandTickis not assumed anywhere (V1 measures it).
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 → host | the only inbound host-rail RPC (ADR-0002); enqueue only |
OutcomeRpc(OutcomeEnvelope o) | HostCommitChannel | [Rpc(SendTo.ClientsAndHost, Delivery = Reliable)] | host → everyone | the 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 joiner | late-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[]
ICommandCodec/IOutcomeCodec(Driftlands.Runtime.Net.Codecs): one sealed codec perCommandKind, registered in a static read-only table keyed by kind;Write(ICommand, FastBufferWriter)/Read(FastBufferReader, ulong owner, uint seq, long nowTick) → ICommand. The codec is whereIntentTick = nowTickis stamped (ADR-0015 §3 step 0) and where aNode-kindStrikeIntentbecomesHarvestResolveCommand(routing at receipt). Commands themselves stay engine-free records inRules.Commands; the codec is the only place a Rules record meets a byte.- Fixed layouts — every payload is a struct of blittable fields, little-endian, written field by field with
FastBufferWriter.WriteValueSafe; no strings, no arrays, no nullable references.Vector2→ twofloat;NodeId/InstanceId/StructureId/TemplateId→ theirint/uintvalue;PlayerKey→ 16 B;Cell→ twoint.MaxCommandPayloadBytes = 64,MaxOutcomePayloadBytes = 128(64 as first written; raised by ADR-0010 forCarrySync— this sentence corrected 2026-09-11, see Editorial amendments) — the fixed-buffer sizes, compile-time;netcode.jsonmay lower the acceptedLength, never raise it. A codec that writes more is a construction-time failure in EditMode (Net_CodecsFitTheCap, §9). - Per-kind layouts (bytes):
StrikeResolve4+8+1+4 = 17 (SwingId,Aim,Kind,Node?; no tick — host-stamped);StrikeRevoke4;DashStarted4;ReviveStart/ReviveCancel16 (PlayerKey);HarvestResolve4+4 (SwingId,Node) — a node-kind strike, never sent as its own kind by the owner;InventoryTransfer(ADR-0010 fixes fields; ≤ 64);CraftCommit4+4 (RecipeId,StationInstance?);PlaceStructure4+8 (StructureId,Cell);Demolish4 (InstanceId);SessionReady16 (PlayerKey, echoed for the fence — the host trusts the approval-time mapping, not the payload). Outcomes:PlayerSnapped(ADR-0009 —PlayerKey16 + reason 1 + position 8 = 25 B; the one host→owner "move yourself" message),SwingLanded,DamageAbsorbed(ADR-0003's two records —HitPointas two floats,VictimasActorId= 1 tag byte + 8),HarvestResolved,StructurePlaced/StructureDemolished,CraftCommitted,PlayerDowned/PlayerDied,CommandRejected(ClientSeq, ReasonCode)— sent to the owner only via the same broadcast withOwnerClientIdset; other clients ignore it. A rejection is not an ADR-0002 §7 violation: it carries no state, it un-predicts one owner's FX (feedback presenters need theReasonCode— combat "a rejected strike produces nothing" holds for the world, the owner's own whiff is local). - Allocation: no
byte[]on the command or outcome wire. NGO'sBufferSerializer.SerializeValue(ref byte[])reads throughFastBufferReader.ReadValueSafe(out byte[])— a fresh array per receive (specialist, src), so a pooled array is impossible on the read side. The envelope is anunsafestruct withfixed byte Payload[64], andNetworkSerializewritesLengthbytes individually; the codec reads straight from the fixed buffer. Steady state allocates nothing per command (guardrail: 0 B/command, verified with theAllocatingGCMemoryconstraint plus a control allocation — the tide story-006 lesson).MaxCommandPayloadBytesis therefore a compile-time 64 (the fixed-buffer size);netcode.jsonmay only lower the acceptedLength.
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 t | NetworkVariable<long> | TideClockAdapter (in-scene) | Server | default (changes 1 Hz) | ADR-0001 |
player Hp, State, BleedOutRemainingTicks | one NetworkVariable<PlayerActorNet> (int, byte, int) | PlayerNet on the player object | Server | default — written by CombatState at end of Tick/drain, delta same tick | ADR-0015 rows |
| player transform | NetworkTransform AuthorityMode = Owner, Interpolate = true on non-owners | player object | Owner | tick rate; details ADR-0009 | ADR-0009 |
enemy Phase | NetworkVariable<byte> (EnemyPhase) | EnemyNet on the enemy object (one NetworkObject per enemy, pre-spawn values ride the spawn message) | Server | default | ADR-0015 / ADR-0012 ✅ |
enemy AnimFrame (TR-tint-004 flipbook index) | NetworkVariable<byte> written by the host EnemyAnimator from the phase fraction | EnemyNet | Server | MinSecondsBetweenUpdates = 0.05 (send-gating, source-confirmed) | ADR-0012 ✅ |
| enemy transform | EnemyTransform : NetworkTransform AuthorityMode = Server, X/Y, PositionThreshold 0.02 | enemy object | Server | full tick rate; global buffer (players win) | ADR-0012 ✅ |
node swingsRemaining | NetworkList<NodeNetRow> (ushort NodeIndex, byte SwingsRemaining), index-aligned with LandInstance.Nodes | LandNet | Server | populated before Spawn() (ADR-0005); full contents ride the spawn message (source-confirmed, ADR-0010 V1) | ADR-0010 ✅ |
| structure registry | NetworkList<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 0xFFFF | ADR-0011 ✅ |
SharedStorage contents | slot-indexed NetworkList<StackNetRow> (ushort ItemIndex, ushort Count, uint InstanceId), fixed length storageSlots | StorageNet per container, spawned by PlaceStructure | Server (StorageReplicator after each drain — a mirror, not host-rail state) | per-row delta on change | ADR-0010 ✅ |
| CARRY | never 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* sets | snapshot 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 |
InstanceMetadata | snapshot (§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, immunity | never (ADR-0015) | — | — | — | — |
PlayerSnapped (ADR-0009), SwingLanded, DamageAbsorbed, HarvestResolved, StructurePlaced/Demolished, CraftCommitted, PlayerDowned/Died, CommandRejected | OutcomeRpc | HostCommitChannel | host, drain phase 4 | reliable, in command order | ADR-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
SnapshotDocument={ worldSeed, configVersion, t, epochState }+ the reconciled tables the host already holds in its last full save document (ADR-0004 §3lastFullDoc) minus every CARRY (ADR-0010: the joiner's own bag arrives as the owner-addressedCarrySyncoutcome from itsSessionReadyCommand.Execute— the same drain that creates its row — closing the snapshot→SessionReady window; no other player's CARRY is ever on this machine) minussharedStorageandharvestDiffs(both have live replicated forms:StorageNetrows andLandNet.Nodes; the header carriesitemIndexHash) plus asessionActorssection the save has no counterpart for (ADR-0004 §6's "superset" clause, TD-ADR C9 there / C1 here): for everyPlayerKeyinCombatState— connected or not —{Hp, State, BleedOutRemainingTicks, OnLand}, written byCombatStatethrough the sameISaveWriterbut never registered withSaveRegistry. Without it a joiner could not know that a disconnected player is Downed-and-revivable (netcode-session Rule 5), becausePlayerNetexists only on spawned bodies. Produced on the main thread at the drain boundary (a consistent point), UTF-8 JSON. Size at MVP: low tens of KB; chunking is the honest answer toUnityTransport.MaxPayloadSize(src default 6 144 B; chunk 4 096 leaves envelope headroom).- Mapping to ADR-0004 §5's load order (so no story implements a step twice): the joiner runs the step-9-equivalent deserialisation only. Steps 0–2 (file pick, version gate,
TideClock.Restore) are replaced by the connection — no file;tarrives fromTideClockAdapter's NetworkVariable andconfigVersionin the document must equal the joiner's own (mismatch → disconnect with reasonConfigVersion); step 3 (epoch) is carried inepochState; steps 4–7 are skipped — the host reconciled and the joiner trusts it (TR-net-003 "byte-for-byte"); step 8 (player placement, tide EC1) is not skipped — it moves toSessionReadyCommand.Execute, the only place it runs for a joiner. SessionReadyCommandand the soft-close drop. It targets no socket, so ADR-0002's step-2 drop rule does not apply; instead its placement re-samplesctx.SoftClosedSocketsandctx.Tide.EvaluateinsideExecute, so a land that soft-closed in step 1 of the same drain yieldsdepositPoint, never last-position — the ADR-0002 defence-in-depth pattern, not an exception to it.- Snapshot vs. a live spawn in flight. ADR-0005's tide-edge carve-out can spawn a
LandNetwhile the joiner's chunks are in transit, on a differentNetworkObjectwith no ordering relation toSessionNet. Apply order on the joiner is snapshot-first, then live replicated state; on conflictLandNet'sNetworkList<NodeState>is authoritative over the snapshot'sharvestDiffsrows for that socket (the list is the live table; the diff table is its persisted shadow). ADR-0010 owns the merge. - A joiner that has not applied the snapshot has no player object, so it cannot move, strike or build;
HostCommitChannelwill still accept its envelopes (always spawned, ADR-0002 §9) and every command's gate re-sample fails on "no actor row" →CommandRejected(ActorDead).SessionReadybefore the snapshot completes is rejected (Reason = NotReady) and the joiner retries after apply. - Rejoin = this path (TR-net-004): the actor row survives the disconnect (ADR-0015);
GetOrCreatefinds it; HP/Downed/bleed-out restore per netcode-session Rule 5. - Disconnect: NGO destroys the disconnecting client's owned objects because
NetworkObject.DontDestroyWithOwnerisfalseby default (srcNetworkObject.cs,NetworkConnectionManagerdisconnect path) — the player prefab keeps that default — the body despawns (TR-net-004);SessionStatekeepsPlayerKey → row; theclientIdis released (NGO may reuse it — never a key). In-flight envelopes already received execute (ADR-0002 §9). - Host-side snapshot cost: built once per join (not per tick); a join during a heavy drain waits for the next drain boundary.
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 CommandKind | receipt | enum range | drop, count |
Length ≤ MaxCommandPayloadBytes and equals the codec's fixed size | receipt | exact size | drop, count |
ClientSeq > last[PlayerKey] | receipt | ADR-0002 §8 | drop (duplicate/replay) |
ClientSeq ≤ last + seqWindow (64) | receipt | a client cannot jump its counter | drop, count |
≤ maxEnvelopesPerTickPerClient (16) | receipt | flood bound — a human emits < 5 | drop excess, count |
| sender has an actor row (joined) | Execute | §6 | CommandRejected(ActorDead) |
proximity slack (TR-harv-007, BB F1, combat AC-11/12): HostKnown(attacker) within range + rangeSlack of the referent, rangeSlack = 1.0 u | Execute (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 seen | the command's own ReasonCode |
ResolveTick sanity: ≥ NowTick and ≤ NowTick + maxResolveAheadTicks (50 = 1 s) | Execute | host-stamped, so only a codec bug can violate it | assertion (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)
NetworkTransform(both rails): tick-rate updates (50 Hz),Interpolate = trueon non-authority instances,PositionThreshold = 0.01(src default 0.001 is sub-pixel at PPU 32 — wasted sends),UseUnreliableDeltas = truefor players and enemies (a dropped delta is superseded 20 ms later; NGO still sends the periodic reliable full state),PositionInterpolationType = Lerp(LegacyLerpis the 1.x behaviour;SmoothDampeningis ADR-0009's option to test),UseHalfFloatPrecision = false(2D at 32 PPU needs 0.03 u precision; half-float over ±64 u is 0.03–0.06 — not worth it).InterpolationBufferTickOffsetstays default until the CSV says otherwise (ADR-0009 owns the finalremoteInterpBuffer).NetworkVariabletraits: default everywhere exceptAnimFrame(0.05 s min) — set withfield.SetUpdateTraits(new NetworkVariableUpdateTraits{…})after construction (srcNetworkVariableBase.SetUpdateTraits), never a constructor argument. Nothing here needsMaxSecondsBetweenUpdates.- Relevancy: none. Every client observes every
NetworkObject; at 2–5 players and one docked land the whole world is ≤ ~60 replicated objects.NetworkObject.CheckObjectVisibilityis never set (relevancy_filtering, forbidden — it would make "late joiner sees current state" conditional). - Bandwidth envelope (2 players, 8 enemies, host → client): NT ≈ 9 objects × 50 Hz × ~28 B ≈ 12.6 KB/s; NVs and outcomes ≪ 1 KB/s; ≈ 15 KB/s down, ≈ 2 KB/s up. Comfortably inside Relay's free tier and any home uplink.
9. Fences (EditMode, every CI pass — additions to ADR-0003 §6)
| Test | Asserts |
|---|---|
Net_RpcSurfaceIsClosed | exactly 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_NoObsoleteRpcFlags | no RpcAttribute sets RequireOwnership (named argument absent) |
Net_TopologyIsClientServer | the in-scene NetworkManager prefab/scene serialises NetworkTopology: 0 (ClientServer) and ConnectionApproval: 1; TickRate equals netcode.json's value at boot (PlayMode) |
Net_NoOwnerWrittenNetworkVariables | no field of type NetworkVariable<>/NetworkList<> in Runtime/Presentation is constructed with NetworkVariableWritePermission.Owner (IL scan of ctor calls, same technique as IlScan) |
Net_CodecsFitTheCap | every 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_NoRelevancyFilter | no assignment to NetworkObject.CheckObjectVisibility in Runtime/Presentation (raw-text fence, like GpuGuardFenceTests) |
Net_NoVariableReadsInOutcomeHandlers | review-checklist item (IL inspection is analyzer territory, ADR-0003 TD-ADR C5): no OutcomeRpc consumer reads a NetworkVariable.Value in the handler body |
Net_RejectionCarriesNoState | the 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_SessionLauncherIsTheOnlyTransportToucher | only 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)
- Description: state changes broadcast as events; clients keep their own copies.
- Pros: one mechanism; every change is an observable edge.
- Cons: a late joiner must be replayed into or given a bespoke snapshot for every table; a dropped-before-join event is state the joiner never has; duplicates ADR-0004's document for no gain.
- Rejection Reason: NGO already delivers
NetworkVariable/NetworkListcontents with the spawn message — the late-join problem is solved by the engine for state; only events need a broadcast.
Alternative C: everything as NetworkVariables/NetworkLists (no outcome RPCs)
- Description:
SwingLandedetc. become nonce-bumped variables clients diff. - Pros: no RPC surface beyond
SubmitRpc. - Cons: an edge encoded as a value loses coalesced events (two hits in one tick = one delta), needs an ever-growing log list or a nonce race, and the prototype's
_staggerNoncewas exactly this hack; a late joiner would replay stale nonces as fresh edges. - Rejection Reason: events are not state; the nonce pattern is the 1.x workaround for not having
SendTo.ClientsAndHost.
Alternative D: OnSynchronize on SessionNet for the snapshot
- Description: override
NetworkBehaviour.OnSynchronize(src) so the tables ride the object's own spawn-sync message. - Pros: no chunking code; arrives before
OnNetworkSpawn. - Cons: the payload rides NGO's scene/spawn synchronisation whose size behaviour for tens of KB is undocumented; no way for the joiner to ack before its player exists; couples the snapshot to
EnableSceneManagement. - Rejection Reason: explicit chunks on a dedicated object are boring and testable (V3);
SessionReadygives the join an ordered place in the drain.
Alternative E: one NetworkList per table as the snapshot
- Description:
Known*,InstanceMetadata, registry asNetworkList<T>onSessionNet. - Pros: auto-sync, per-row deltas.
- Cons:
T : unmanagedforces every table into fixed structs (no strings, no tags-as-objects), duplicating the save schema in a second shape; per-table atomicity only — a joiner can observeInstanceMetadatawithout theKnownRecipesthat references it (crafting EC21's race); ADR-0010/0011 still need lists for the live tables (nodes, storage) — this alternative is not rejected there, only as the snapshot. - Rejection Reason: one document, one apply, one code path shared with the save file.
Alternative F: 30 Hz tick (NGO default)
- Cons: 33 ms resolve granularity; ADR-0002's ≤ 20 ms command-latency budget explicitly assumed 50 Hz; the CPU difference on a 2-player host is unmeasurable.
- Rejection Reason: chosen 50 Hz (user decision 2026-09-11); revisit only if the playtest CSV shows the host frame budget suffering, which it will not at this scale.
Alternative G: Steam transport now
- Rejection Reason: QQ-04 (2026-09-02) — the transport is swappable behind
ISessionLauncher; Relay needs no App ID and worked end to end on 2026-09-11.
Consequences
Positive
- Every ⚠️ in
architecture.md§2–§4 has a name: two RPCs on the channel, one onSessionNet, a representation table, a snapshot document, a validation table, a tick rate. ADR-0009–0012 can be written against fixed edges. - The same-tick replication claim ADR-0002 made is corrected here from source rather than discovered in the first netcode story — one tick at 50 Hz, stated and budgeted.
- The RPC surface is closed and counted (three methods) — the strongest ordering and review property this project has, now a CI fence rather than a convention.
Net_RpcSurfaceIsClosedis a genuine strengthening of ADR-0003'sNet_OnlyChannelDeclaresServerRpc, which counted onlySendTo.Serverand would not have caught a fourth broadcast RPC. - Bulk traffic never shares the command channel's object (registered as
bulk_payload_on_command_channel): a 40 KB snapshot burst cannot sit in front of a strike envelope. - Late join, rejoin and the save file share one serialisation path; a bug in one is a bug in all three and is found once.
- Everything numeric is a
netcode.jsonfield, so the playtest re-tune is a data change plus one ADR amendment.
Negative
- The
OutcomeRpcbroadcast goes to every client including ones the outcome does not concern (no relevancy) — trivial at ≤ 5 players, deliberately not optimised. CommandRejectedadds a reason channel back to the owner that ADR-0002 §7 did not name; it is state-free but it is a new message kind to keep honest.- Two in-scene
NetworkObjects (HostCommitChannel,SessionNet) instead of one — a second thing that must always be spawned. - A 30 s disconnect timeout means a frozen remote body for up to 30 s on a real drop before despawn.
Risks
- V1 stage order could show incoming
NetworkTransformstate applied afterTick— thenHostKnownpositions inside a drain are one tick staler than assumed. Mitigation: the probe runs in the first NetcodeSession story;RangeSlackUnitsabsorbs one extra tick (0.12 u); the Amendments section records the measured order. - Relay adds a hop; the 2026-09-11 same-Mac run measured 249 ms because that link averaged 154 ms to an anycast address. Mitigation: the friend playtest CSV is the first real distribution; §8 values are provisional (V4). A NO-GO on feel re-tunes §2/§8, not the model.
- Snapshot growth (Tier 1
InstanceMetadata) could exceed a few hundred KB. Mitigation: chunks are bounded; the join simply takes longer; a compressed document is a one-line change behindISaveWriter. - NGO 2.x churn (2.13 already deprecated a flag ADR-0002 used). Mitigation:
Net_NoObsoleteRpcFlagsfails the build on the next such deprecation; the manifest pins 2.13.2. PlayerKeyGUID on disk is trivially copyable — a friend can impersonate another friend. Accepted: co-op with 2–5 friends, no economy, no anti-cheat requirement (security notes: the host still re-samples every gate).
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 6 | two rails, forbidden patterns | §5 rule + table; Net_NoOwnerWrittenNetworkVariables; no relevancy, no DA topology |
| TR-net-006/008 | one ordered channel; execution-phase order | §3 (per-object ordering, two objects on purpose); V1 records the stage |
| combat-system TR-combat-002/003/007/008 | intent 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/007 | swingsRemaining replicated; host proximity sanity check; host-confirmed flyouts | §5 NetworkList<NodeState> class (ADR-0010 details); §7; HarvestResolved outcome |
| crafting TR-craft-008 | commit host-side; stance client-predicted | CraftCommit command + CraftCommitted outcome carrying the mark row |
| enemy-ai TR-ai-001 | host-simulated; clients render replicated transform + telegraph | §5 EnemyNet + server-auth NetworkTransform |
| tide TR-tide-003 | t replicated on join/change | §5 row 1 |
| art-bible TR-tint-004 | flipbook 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/007 | representation classes | bounded by §5's rule; decided in ADR-0009/0010/0011 |
Performance Implications
- CPU (host): 50 drains/s; codec decode ≤ 1 µs/envelope; snapshot build once per join (main thread, ≤ 5 ms at MVP sizes — measured in the join story). No per-command allocation in steady state (pooled payload).
- Memory:
SessionStatemaps + pooled buffers < 100 KB;lastFullDocalready exists (ADR-0004). - Load time: Relay sign-in + allocation ≈ 1–2 s on a normal link (measured 2026-09-11); join adds the snapshot transfer (tens of KB).
- Network: ≈ 15 KB/s down / 2 KB/s up per client at 2 players + 8 enemies (§8); Relay free tier.
Migration Plan
UnityProject/Packages/manifest.json: addcom.unity.netcode.gameobjects2.13.2,com.unity.services.multiplayer2.2.1,com.unity.multiplayer.tools2.2.9 (pullscom.unity.transport2.7.3) — the versions the kit compiled against; commitpackages-lock.json.Driftlands.Runtime.asmdef: +Unity.Netcode.Runtime(already listed by ADR-0003),Unity.Networking.Transport,Unity.Services.Multiplayer,Unity.Services.Core,Unity.Services.Authentication,Unity.Services.Relay(assembly names verified when the package lands — story 001's first task).Assets/Driftlands/Data/netcode.json+NetConfig+ validation rules (ADR-0006 pattern);ManifestRules.RequiredFilesgains it (and bothTestJsonfixtures — the lesson from core-camera story-001).- NetcodeSession epic, in order: 001 packages +
ISessionLauncher/RelaySessionLauncher+IPlayerIdentity+ V7 tick check; 002HostCommitChannelrealSubmitRpc/OutcomeRpc+ codecs + fences (V2); 003SessionNetsnapshot +SessionReady(V3); 004 the V1 stage-order probe (documented playtest / two standalone builds); 005PlayerNet/EnemyNetvariables +TideClockAdapterNV (V5); 006 disconnect/rejoin round-trip (netcode-session AC 3). Sequencing risk, acknowledged: stories 002/003 assume the drain timing that story 004's V1 probe measures; if the probe moves the drain relative toNetworkTransformapply, the fix isRangeSlackUnits' one-tick allowance and an amendment here, not a rewrite of 002/003. - Amend the documents in the table above in the same commit as this ADR (registry + architecture.md + quick-spec Rule 1/7 + control-manifest note); ADR-0002's attribute text and ADR-0015's
PlayerKeyline. - After the friend playtest: one amendment here recording the CSV distribution and any §2/§8 re-tune; remove the provisional note from Status.
Validation Criteria
- EditMode: the seven §9 fences green, each with a companion negative (a fake fourth
[Rpc], anOwner-write variable, an oversize codec) proven red. - EditMode: codec round-trip identity per
CommandKind/OutcomeKind;SessionReadyCommandon a missing row creates it, on an existing row restores HP/State/timer (ADR-0015 rejoin rule);NetConfigvalidation rejects tick 10 and 120, chunk 8 000, slack −1 (finite-only, per the NaN lesson). - PlayMode (single process, host only): V5 — a variable written in
Drain()appears in the same tick's delta (observed through a secondNetworkManageris not possible in one process → recorded as a documented playtest, ADR-0002 Verification 5 precedent); V7 tick cadence. - Documented playtest / two standalone builds under Network Simulator: V1 stage order (logged), V2 ordering over Relay (interleaved placement envelopes → exactly one commits), V3 snapshot (40 KB fixture, byte-identical), join → strike →
SwingLandedon the joiner within RTT + 1 tick. - Friend playtest (Gate 0 confirmation):
gate0-rtt.csvfrom both machines attached to the amendment;Warps seencount and largest jump recorded; verdict form filled.
Related Decisions
- ADR-0002 host commit model (the channel this ADR puts bytes on); ADR-0015 combat model (the rows); ADR-0003 (fences); ADR-0004 (writers); ADR-0005 (
LandNet); ADR-0001 (t); ADR-0007 (InputFramenever crosses the wire); ADR-0014 (title-screen join-code UX consumesISessionLauncher). design/quick-specs/netcode-session-2026-08-27.md;production/playtests/gate-0-protocol.md;production/gate-checks/gate-0-provisional-pass-2026-09-11.md;prototypes/combat-netcode-feel-concept/(throwaway — never refactor into production;RelaySession.csis the reference for the Relay call sequence only).architecture.mdQQ-03 (closed by this ADR), QQ-04 (ship-time transport).
Amendments — 2026-09-11 (ADR-0009 player transform authority)
OutcomeKindgainsPlayerSnapped(§4/§5): host-commanded repositions (tide deposit, eviction backstop, death respawn) are outcomes the owner honours withTeleport; the host'sHostPositions.Overrideis truth from the moment of the event. Produced from tick steps 1–2, flushed at phase 4 before command outcomes (ADR-0002 amended).- §7's
HostKnownstaleness bound is stated by ADR-0009 §7: ≤ 1 RTT + 1 tick, raw last-received state, never the host's interpolated copy; every consumer is a single-tick predicate. - §8 interpolation baseline:
NetworkTransform.InterpolationBufferTickOffsetis a static applied to everyNetworkTransform; the effective buffer ismax(1, TickLatency + offset)withTickLatencyRTT-adaptive at runtime — the playtest CSV records it (ADR-0009 V5).
Amendments — 2026-09-11 (ADR-0010 inventory replication)
OutcomeEnvelopepayload is 128 B (MaxOutcomePayloadBytes ≤ 128) soCarrySync(≈ 90 B atbagSlots10;bagSlots ≤ 14is the wire-validated range) fits;CommandEnvelopestays 64 B.OutcomeKindgainsCarrySync(owner-addressed),EquipmentChanged.- §6
SnapshotDocumentcarries no CARRY, nosharedStorage, noharvestDiffs; the "ADR-0010 owns the merge" hand-off is discharged — withharvestDiffsout of the snapshot theLandNetlist is the only node truth a client sees. The snapshot header gainsitemIndexHash(mismatch →ConfigVersiondisconnect). - §5 rows for node state,
SharedStorageand CARRY are decided (table updated in place).StorageNet.Rowsis a mirror written after the drain by one host writer — not host-rail state.
Amendments — 2026-09-11 (ADR-0011 structure registry replication)
- §5 structure-registry row decided (table updated in place). §6
SnapshotDocumentdrops thestructurestable (the in-sceneBaseNetlist is the only registry a client sees). The snapshot header'sitemIndexHashbecomesdataHash= FNV-1a over the concatenation of each index table's own hash in a fixed order — items, structures, then any table a later ADR appends (ADR-0012 appends; never reorders).OutcomeKindgainsStructurePlaced{Instance, StructureIndex, X, Y, Placer}andStructureDemolished{…, Demolisher, RefundIsZero}.
Amendments — 2026-09-11 (ADR-0012 enemy simulation model)
- §5 enemy rows decided (table updated);
EnemyNetalso carriesArchetype(ushortindex) andActorId(EnemyActorIdNet{byte Socket, ushort Index}) set beforeSpawn().dataHashappends the enemy index table after structures (items, structures, enemies — fixed order). No newOutcomeKind: stagger/death feedback ridesSwingLanded{Victim, StaggerFired, Lethal}.
Editorial amendments — 2026-09-11 (TD-MANIFEST C3)
- §4's fixed-layouts sentence read
MaxOutcomePayloadBytes = 64while the §4 diagram,NetConfigand the ADR-0010 amendment all said 128 — corrected in place to 128. No decision changed.
Editorial amendments — 2026-09-12 (NetcodeSession story 002, installed-package facts)
- Assembly names (Migration Plan step 2 listed
Unity.Services.Relayas an assembly): Multiplayer Services SDK 2.2.1 ships noUnity.Services.Relayassembly —RelayService,IRelayService,RelayServiceException,RelayExceptionReasonandAllocationUtilslive inUnity.Services.Multiplayerunder theUnity.Services.Relay/Unity.Services.Relay.Modelsnamespaces.Driftlands.Runtime.asmdefreferencesUnity.Netcode.Runtime,Unity.Networking.Transport,Unity.Services.Core(1.18.0),Unity.Services.Authentication(3.6.1),Unity.Services.Multiplayer(2.2.1) — verified on disk inLibrary/PackageCache. ISessionLaunchershape: the shipped seam (HostAsync(maxPlayers, ct) → SessionResult,JoinAsync(code, ct),ShutdownAsync(), +ulong CurrentRttMs) is kept over the Key Interfaces sketch (Task<string> HostAsync(int),LeaveAsync);SessionResult.Kindcarries the reason the title screen maps to its grammar. The launcher drives the SDK throughIRelayGateway(real:RelayGateway, the only file namingUnity.Services.*/SetRelayServerData) and theNetworkManagerthroughINetworkHost(real:NetworkManagerHost) — dependency injection over the singleton so the outcome mapping is EditMode-testable; NGO'sStartHostneeds a live player loop.- Un-linked project:
UnityServices.InitializeAsync()throwsUnityProjectNotLinkedException : ServicesInitializationException(internal) whenApplication.cloudProjectIdis empty → mapped toSessionResultKind.NetworkUnavailable; the boot scene logs a warning (an error-level log fails every PlayMode test that loads the scene, and an un-linked project is a legitimate developer/CI state). RelayServiceException : RequestFailedException— caught first;RelayExceptionReason.JoinCodeNotFound(15009) /AllocationNotFound(15008) →NotFound, other reasons →NetworkUnavailable.- Steady-state allocation (§4, guardrail "0 B/command"): applies to the wire — the
CommandEnvelope/OutcomeEnvelopeserialisation and the codecWritepath allocate nothing. Decode constructs one Rulesrecordper command/outcome (ICommand/IOutcomeimplementations arerecordclasses by ADR-0003 §3; C# 9 has norecord struct) — one bounded object per envelope (≤ 16/tick by §7's flood bound), accepted; a pooled/struct command form is a register item, not a requirement. NetcodeSession story 004 pins the boundary with an explicit "decode allocates only the record" test (2026-09-12).
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)
IHostPositions(Rules.Commands) gains exactly one member,bool IsReadyToJoin(ulong clientId).Runtime/Net/HostPositions.cswrapsSessionState.HasSnapshotSent(006a's flag) — aSessionReadyreceived before that flag is set failsSessionReadyCommand.Execute's first gate withReasonCode.NotReady, before any row is touched.HostContext's member count is unchanged (18) — the readiness question lives on the existingPositionsseam rather than a new member.SessionReadyCommand.Executeis implemented (moved out of theRules/Commands/WireShells.csshell into its own file,Rules/Commands/SessionReadyCommand.cs): gate onIsReadyToJoin,ctx.Combat.GetOrCreate(Key), re-sample placement fromctx.Sockets/ctx.SoftClosedSockets(neverctx.Tide/ctx.Lands— neither member exists on the row this story reads),Broadcast(CarrySync)thenBroadcast(PlayerJoined)in that order,QueueSpawn(SpawnRequest.Player(...)). NoSocketId.Homeconstant exists yet — a player whose row has noOnLandis placed atVector2.Zero, pinned by test until the tide story gives this a real home position.HostCommitChannelgainsSubmitLocal(ICommand)— encodes the command throughCodecRegistry/OutcomeWire's envelope shape and calls the existingReceiveSubmit(NetworkManager.ServerClientId, env)path (same validator/queue, no RPC).GameBootstrap, onStartHost, marks the host's own clientId snapshot-sent and submits a localSessionReady— the host's player spawns through the exact sameExecutepath as a remote joiner's, per §6's "the host's own player takes the same path" decision. No parallel host-only spawn function exists (InstantiateAndSpawn(appears inRuntime/Presentationonly inPlayerSpawner.cs, fenced).Runtime/Net/HostComposition.cs(a Runtime static factory) builds and starts theHostTickOrchestratorin production for the first time —GameBootstrapcalls it and never constructsHostContext/ICommand/HostCommitQueuedirectly (the host-rail fence).Runtime/Net/PlayerSpawner.csis the soleSpawnRequestconsumer, spawning throughNetworkObject.InstantiateAndSpawn(neverInstantiate+ bareSpawn()— story-005's stale-Singletonscar).Runtime/Net/NullLandState.cs/Runtime/Net/EmptySocketLayout.csare non-speculative stand-ins forILandState/ISocketLayoutuntil land story 009 and the socket story compose the real ones;SessionReadyCommand.Executenever reaches either through the composed context today.- The player prefab (
Assets/Driftlands/Prefabs/Net/Player.prefab,NetworkObjectonly — story 007 addsPlayerNet) is generator-owned (BootSceneSetup.EnsurePlayerPrefab) and registered on the shippedNetworkPrefabsList; it is NOT a test-folder prefab, soNet_DefaultNetworkPrefabsListCarriesNoTestPrefab's existingAssets/Driftlands/Tests/path filter requires no change.