ADR-0009: Player Transform Authority — Owner-Authoritative NetworkTransform, the Kinematic Mover, Host-Commanded Snaps, and Remote Interpolation
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) · NGO 2.13.2 (NetworkTransform, NetworkTransformState) · Unity 2D Physics (Rigidbody2D, Collider2D, TilemapCollider2D/CompositeCollider2D) · Input System via ADR-0007 only |
| Domain | Networking + Physics 2D (movement) |
| Knowledge Risk | HIGH for NetworkTransform 2.x (owner authority, interpolation types, Teleport/SetState semantics, state hooks) — read from the 2.13.2 package source 2026-09-11 (Components/NetworkTransform.cs, Components/NetworkRigidbody2D.cs, Timing/NetworkTimeSystem.cs), facts marked (src). LOW for 2D physics queries (Rigidbody2D.Cast, layer matrix — unchanged since 2019). |
| References Consulted | docs/engine-reference/unity/VERSION.md, breaking-changes.md (NGO: NetworkTransform.Update override gone → OnUpdate), deprecated-apis.md, modules/physics.md (layer matrix, non-allocating casts), ADR-0008 §2/§5/§8 (tick, representation rule, interpolation baseline), ADR-0015 (dash/knockback displacement requests, Snap(DeathRespawn), crawl mode), ADR-0005 (SoftCloseDeposit/EvictionExpired), ADR-0007 (InputFrame.Move/Aim, IAimSource), ADR-0003 (IHostPositions, UnityInterop), design/quick-specs/player-movement-2026-08-27.md, camera-2026-08-27.md (Rule 3, ISnapSource), base-building.md EC21/AC-27, combat-system.md Rules 4/5, EC16, tide-scheduler.md Rule 9/EC1/EC11, UnityProject/Assets/Driftlands/Rules/Cameras/ISnapSource.cs (already shipped: SnapReason { TideDeposit, DeathRespawn }), architecture.md row 9, QQ-02 |
| Post-Cutoff APIs Used | NetworkTransform.AuthorityMode = AuthorityModes.Owner (src 1301/1324); Interpolate, PositionInterpolationType = InterpolationTypes.Lerp (LegacyLerp/Lerp/SmoothDampening, src 1110–1155), PositionMaxInterpolationTime (0.1 default), PositionThreshold (gates sending only — "setting to zero updates every tick", src 1490), UseUnreliableDeltas, SyncPositionX/Y/Z, UseHalfFloatPrecision (src); NetworkTransform.InterpolationBufferTickOffset — a static int (src 4677; global to every NetworkTransform); NetworkTimeSystem.TickLatency (default 1, src); Teleport(pos, rot, scale) — throws when called from a non-authoritative context (src 4134 doc: "Thrown when called from a non-authoritative context (client without ownership)"; "stick with the motion authority when invoking"); SetState(pos, rot, scale, teleportDisabled = true) (src 3998); protected virtual OnNetworkTransformStateUpdated(ref old, ref new) (src 3292; specialist: fires on every non-authority instance including the host's copy of an owner-authoritative player — same code path as any client) and public virtual OnUpdate() (src 4441 — non-authority must call base); **NetworkTransformState's position accessor is a member of an internal struct — its accessibility from Driftlands.Runtime is pending V1, not confirmed; SetState also has a native owner-push path (SetStateClientRpc, SendTo.Owner, src ~4097) and an owner-request path (SetStateServerRpc, InvokePermission = Owner, src ~4110, with an OnClientRequestChange hook) — considered in Alternative H, not used; NetworkRigidbody2D / NetworkRigidbodyBase.UseRigidBodyForMotion (src — not used**); NetworkObject.DontDestroyWithOwner default false (ADR-0008) |
| Verification Required | (V1) The host's non-authority PlayerTransform can read the raw last-received owner position from NetworkTransformState inside OnNetworkTransformStateUpdated — the struct's position accessor exists (src 653) but its accessibility from Driftlands.Runtime and its space (local vs world) are unverified; story 1 confirms or falls back (Risks). (V2) QQ-02: a one-cell (1 u) authoritative write without Teleport reaches observers as one delta and their Lerp interpolator slides it over the buffer — no receiver-side snap (the source shows no receive-side distance threshold; verify in play). (V3) Teleport() on the owner sets the teleport flag so observers apply without interpolation; calling it on a non-owner throws (doc) — the snap path never does. (V4) Rigidbody2D.Cast on a kinematic body against static BoxCollider2D walls and a CompositeCollider2D terrain blocker returns contacts non-allocating with a preallocated RaycastHit2D[8]. (V5) InterpolationBufferTickOffset = remoteInterpBuffer under Lerp yields a buffer of max(1, TickLatency + offset) ticks (src ~4250) where TickLatency is RTT-adaptive at runtime (NetworkTimeSystem rounds a measured average, floor 2 — src ~238), so the real buffer is adaptive + offset, never a fixed number; the friend playtest CSV records the observed TickLatency alongside RTT and decides offset 1 vs 2 (provisional). (V6) HostKnown(remote) staleness ≤ 1 RTT + 1 tick at 50 Hz (owner write → NT tick send → host receive). (V7) A dash into a wall stops at the wall's face (skin 0.01 u) on both host and client with no tunnelling at 22 u/s × 20 ms = 0.44 u per tick (the sweep is per rendered frame, finer still). |
Revisit clause (Gate 0)
| Section | At risk from a NO-GO? | If NO-GO |
|---|---|---|
| §1 authority model, §4 snap mechanism, §5 EC21, §7 host-known positions | No — mechanism, forced by NGO's authority rules and TR-move-002 | unchanged |
§6 interpolation (remoteInterpBuffer, Lerp vs SmoothDampening, PositionMaxInterpolationTime) | Yes — "the other player warps" points here first | re-tuned by amendment from the CSV + warp counter |
| §2 dash integration (owner-side, i-frames per EC16) | Partially — a NO-GO on "hits feel disconnected" is EC16's known trade-off; the fix is dash tuning (combat GDD), never lag compensation | combat knob change, not a mechanism change |
Documents Amended by This ADR
| Document | Amendment |
|---|---|
| ADR-0008 | OutcomeKind gains PlayerSnapped (§4) — the one host→owner "move yourself" message; IHostPositions.HostKnown is defined here (§7: raw last-received state, overridden by a pending snap); netcode.json unchanged. |
| ADR-0002 | §5 step 4: Broadcast is callable from tick steps 1–3 (via the tick's HostContext) as well as from Execute; the phase-4 flush drains everything buffered since the top of the tick — tick-step outcomes first (buffering order), then command outcomes (command order); "a command never observes its own broadcast" → "nothing observes a broadcast before the flush" (C1). |
| ADR-0015 | Snap(DeathRespawn, homeSpawn) is IPlayerSnapper.Snap(key, SnapReason.DeathRespawn, homeSpawn) (§4); the owner machine's dash request is IDisplacementSink.Displace(DisplacementRequest) (§2); EmitKnockbacks(IMovementDisplacement) becomes EmitKnockbacks(IDisplacementSink) on the enemy side (ADR-0012 implements it over the same mover). |
| ADR-0005 | SoftCloseDeposit(socket) and EvictionExpired(socket) have their consumer named: the host PlayerSnapService (§4) — deposit at depositPoint for every player whose host-known position is on that land; EC11's backstop is the same call for anyone still there at grace expiry. |
| ADR-0003 | New engine-free namespace Driftlands.Rules.Movement (Locomotion, Sweep, LocomotionMode, DisplacementRequest); new Runtime adapters Physics2DSweep : ISweepQuery, KinematicMover2D, PlayerMotor, PlayerTransform : NetworkTransform, HostPositions (finalised — IHostPositions signature unchanged, HostContext member count unchanged); Runtime asmdef references UnityEngine.Physics2DModule, UnityEngine.TilemapModule. |
design/quick-specs/player-movement-2026-08-27.md | Rule 6: EC21 observer asymmetry ratified (§5); knobs table gains snapAcceptRadius, snapGraceTicks, skinWidth; remoteInterpBuffer maps to NetworkTransform.InterpolationBufferTickOffset (global). |
design/gdd/base-building.md | EC21's "ADR questions, not settled facts" sentence → settled by §5 (interpolated write; V2 verifies the mechanism). |
architecture.md | Required-ADR row 9 ✅; §2 PlayerMovement row loses its ⚠️; QQ-02 → verification item V2 of this ADR. |
UnityProject/Assets/Driftlands/Data/Configs/GameConfigs.cs | MovementConfig placeholder gets its fields (§8); movement.json becomes a required file (both TestJson fixtures). |
ADR Dependencies
| Field | Value |
|---|---|
| Depends On | ADR-0008 (tick 50 Hz, NetworkTransform baseline, OutcomeRpc, PlayerKey, SessionReady spawn), ADR-0015 (dash/knockback requests, Snap(DeathRespawn), Downed mode), ADR-0007 (InputFrame), ADR-0005 (deposit events), ADR-0003 (IHostPositions, UnityInterop, purity) |
| Enables | ADR-0011 (structure colliders live on the Structure layer this ADR defines; EC21 correction trigger calls PlayerMotor.CorrectTo), ADR-0012 (enemies reuse KinematicMover2D + ISweepQuery; enemy NetworkTransform Server authority with the same interpolation baseline) |
| Blocks | PlayerMovement epic (every story); the camera's ISnapSource real emitter (core-camera story-003 left it on fakes); combat dash stories; BB EC21 story |
| Ordering Note | Second of the five. ADR-0012 must not re-decide collision or interpolation — it drives the mover and picks a send cadence. |
Context
Problem Statement
Movement is owner-authoritative and client-predicted (TR-move-002, prototype-confirmed) — which means the host cannot move a player's transform, yet three authority events must: the tide deposit at φ+W (tide Rule 9), the eviction backstop (EC11), and death respawn (combat). A fourth reposition, BB's occupied-cell correction (EC21), is owner-side and proposed with an observer asymmetry the movement spec must ratify or reject. Dash (combat Rule 4) and knockback (F5) are displacements that must respect the same walls ordinary movement does (BB AC-27), and enemies — host-moved — need the identical collision rule. Remotes must interpolate, never extrapolate (TR-move-004). NGO 2.13.2 adds a hard constraint the design docs did not know: Teleport() throws from a non-authoritative context, so every host-initiated snap has to be commanded to the owner. The camera epic already shipped ISnapSource with exactly {TideDeposit, DeathRespawn} on fakes; this ADR supplies the real emitter.
Constraints
- TR-move-002: no host correction of an owner's position at MVP; TR-move-004: interpolation only; TR-move-005: the snap list is closed; movement Rule 5: nothing modifies
moveSpeed(Downed is a mode); Rule 4: collision blocks, never damages; no player/player or player/enemy body collision. - ADR-0008: 50 Hz,
NetworkTransformsends at tick rate,Interpolate = true,Lerp,PositionThreshold 0.01,UseUnreliableDeltas; the one-tick state lag;RangeSlackUnitsis fixed, not RTT-tuned. - ADR-0015: dash i-frames are owner-authoritative from input, the host mirror wins if the dash has not arrived (EC16); the F5 argmax is one displacement request per enemy per tick;
Snap(DeathRespawn)is Movement's. - ADR-0003:
Rulesis engine-free — locomotion and the sweep rule are pure; Physics2D is an adapter. - Camera Rule 3: hard cuts only for
TideDeposit | DeathRespawn; EC21 is not a cut. - Gameplay values are data-driven: every knob is
movement.json.
Requirements
TR-move-002 (owner-authoritative, 6 u/s, instant), TR-move-003 (collision blocks, layers), TR-move-004 (remotes interpolated), TR-move-005 (closed snap list, EC21 asymmetry decided), TR-combat-008 (dash i-frames owner-side; host wins if not arrived), TR-bb-007 (walls block; EC21 owner-rail correction write mode), TR-cam-002 (hard cut on deposit/respawn — the emitter), TR-net-008 (execution-phase ordering: HostKnown staleness stated). Bounded, decided elsewhere: TR-ai-001 cadence (ADR-0012), structure collider instantiation (ADR-0011).
Decision
Alternative A: the player prefab carries PlayerTransform : NetworkTransform in AuthorityModes.Owner (position X/Y only, interpolated on non-owners, Lerp), a kinematic Rigidbody2D + collider used only for shape casts, and one KinematicMover2D driven on the owner by PlayerMotor from InputFrame each rendered frame through a pure Rules.Movement step (locomotion mode Normal/Crawl/Frozen, dash as a displacement request, per-axis sweep-and-slide with skinWidth); host-initiated snaps are OutcomeRpc(PlayerSnapped) messages the owner honours with Teleport() while the host treats the snap point as truth immediately; BB EC21 is a plain owner-side position write (observers slide, no camera cut — ratified); enemies reuse the same mover on the host; HostKnown is the raw last-received owner state, never the interpolated transform.
1. Components and authority
| Player prefab component | Setting | Why |
|---|---|---|
NetworkObject | DontDestroyWithOwner = false (default) | body despawns on disconnect (ADR-0008 §6) |
PlayerTransform : NetworkTransform | AuthorityMode = Owner; SyncPositionX/Y = true, SyncPositionZ = false, rotation/scale sync off (2D; facing is derived, §8); Interpolate = true; PositionInterpolationType = Lerp; PositionThreshold = 0.01; UseUnreliableDeltas = true; UseHalfFloatPrecision = false; InLocalSpace = false | ADR-0008 §8 baseline; owner writes transform directly and NT commits it at tick rate (src: authority path in OnUpdate returns early — CanCommitToTransform — and pushes state on tick) |
Rigidbody2D | bodyType = Kinematic, simulated = true, useFullKinematicContacts = false, interpolation = None | exists so Rigidbody2D.Cast sees the body's collider; never MovePosition — the solver moves nothing |
CapsuleCollider2D (or BoxCollider2D 0.8 × 0.8 u) | isTrigger = false, layer Player | the shape the sweep casts |
KinematicMover2D | one per moving body; owns the sweep | §3 |
PlayerMotor | enabled only on the owner (IsOwner); reads IPlayerInput.Current | §2 |
PlayerNet | ADR-0008 (PlayerActorNet) | — |
Enemy prefab: EnemyTransform : NetworkTransform in AuthorityModes.Server with the same interpolation settings on clients, the same kinematic Rigidbody2D + collider on layer Enemy, the same KinematicMover2D, driven by EnemyMotor on the host (ADR-0012). No NetworkRigidbody2D, no UseRigidBodyForMotion, no AnticipatedNetworkTransform anywhere (fence §9).
2. Locomotion — pure step, owner-driven, per rendered frame
Rules.Movement.Locomotion.Step(LocomotionState s, InputFrame in, LocomotionMode mode, float dt, MovementConfig cfg) → (LocomotionState s', Vector2 desiredDelta)
mode = Frozen → 0 (Dead; Crawl/Normal selection is ADR-0015's PlayerState → §2 table)
mode = Crawl → normalize(in.Move) × cfg.DownedCrawlSpeed × dt
mode = Normal → normalize(in.Move) × cfg.MoveSpeed × dt (accelTime 0: full speed the frame input arrives, stop the frame it ends)
active displacement d → d.Velocity × dt for d.RemainingSecs, THEN input again (dash: 22 u/s × 0.14 s from combat; input ignored during it; i-frames are combat's, not movement's)
LocomotionModeis chosen by the owner fromPlayerState(ADR-0015 owner machine):Idle/Windup/Active/Recovery → Normal,Downed → Crawl,Dead → Frozen,Dashing → Normal with the active displacement. The swing phases do not slow movement (combat's "swing-locked movement rules" are combat's to add later as a mode, never as a multiplier — Rule 5).accelTimeis honoured when > 0 as a first-order approach overaccelTimeseconds (a knob the spec keeps at 0; the code path exists so the playtest can try 40 ms without a code change).- Dash =
DisplacementRequest{Velocity = dir × dashSpeed, RemainingSecs = dashDuration, Kind = Dash}handed toIDisplacementSink.Displaceby the owner combat machine onDashingentry (ADR-0015 §2 "the dash displacement request to Movement").diris the movement input direction if non-zero, else the aim direction (combat's call; documented here because it is the only branch). During the displacement the sweep still applies: a wall stops the dash at its face (V7) and the remaining time is simply consumed — no bounce, no slide-along for dashes (a dash is a line; ordinary movement slides). - The host never reconstructs dash displacement (C5).
HostKnownduring a dash is the last replicated point on the dash path (≤ 1 RTT + 1 tick old), soEnemyAttackResolvermay resolve an enemy attack against a position the owner has already left — the same accepted direction as EC16, and the reason i-frames (state), not position, decide the hit.DisplacementRequestis never replicated. - Knockback (enemies only — the player-side CC ban stands) arrives on the host through the same
IDisplacementSinkon the enemy's mover:Kind = Knockback,Velocity = vector / knockbackSecs, integrated overknockbackSecs(combat GDD "distance/duration parameters per attack"); it slides like ordinary movement (a shoved enemy against a wall stays at the wall — "no terrain kills", combat Rule 11). UiCapturedframes arrive withMove = 0from the adapter (ADR-0007's one gate); Movement does not re-check it.
3. Collision — one sweep rule, pure, over an adapter
- Layers:
Player,Enemy,Structure,Terrain,WorldBounds. Collision matrix (Physics2Dlayer matrix, asserted by a fence fromProjectSettings/DynamicsManager.asset):Player↔Playeroff,Player↔Enemyoff,Enemy↔Enemyoff (separation steering is ADR-0012's job, not the solver's),{Player, Enemy} ↔ {Structure, Terrain, WorldBounds}on. Nothing else collides with anything (strike arcs are geometry in Rules, not triggers). - Blockers: structures are static
BoxCollider2Dper footprint onStructure, instantiated by ADR-0011 from the replicated registry on every machine (so a wall blocks the instant its host-confirmed placement lands, movement AC "BB AC-27 from movement's side" — the owner's next sweep sees it); terrain blockers (water, cliffs) are the land's authoredTilemapCollider2D+CompositeCollider2DonTerrain; world bounds anEdgeCollider2DonWorldBounds. Home island: same layers. Rules.Movement.Sweep.Move(Vector2 from, Vector2 delta, ISweepQuery q, float skin) → Vector2 to(pure given the adapter): move along X bydelta.X, cast the body shape (q.Cast(from, (dx,0), |dx| + skin)), stopskinshort of the first hit; then along Y from the reached point; return the reached point. Axis-separated sliding is deliberately naive and predictable (a diagonal into a wall slides along it; a corner stops both axes). No penetration resolution: a body that starts inside a collider (a wall placed on it) is EC21's case (§5), never the solver's —useFullKinematicContactsis off and noOnCollision*callback exists in the movement code (fence).ISweepQuery(Rules, engine-free):bool Cast(Vector2 origin, Vector2 direction, float distance, out float hitDistance).Physics2DSweep : ISweepQuery(Runtime):Rigidbody2D.Cast(dir, contactFilter{layerMask = blockers, useTriggers = false}, results, distance)into a preallocatedRaycastHit2D[8],hitDistance = min(results[i].distance); zero allocation per frame (V4).UnityInteropconverts at this one boundary (ADR-0003).Physics2D.queriesStartInCollidersisfalseproject-wide (Physics2DSettings.asset, fenced): with ittrue, a cast whose origin overlaps a collider (a wall placed on the player — exactly EC21's case — or a sloppy spawn point) reports a hit at distance 0 and the body self-blocks.Rigidbody2D.Castexcludes the body's own collider automatically. The PlayMode test covers a body that starts overlapped with a wall and must still move away (specialist note 9).- Enemy overlap is recoverable only by steering (C6). With
Enemy↔Enemyoff and no penetration resolution, two knockbacks can leave two enemies coincident for up toknockbackSecs. ADR-0012 owns separation strength and must state its behaviour from a fully-coincident start — a deterministic tiebreak onEnemyId, never random jitter (the prototype'sRandom.insideUnitCirclefallback is the anti-pattern). - Collision never damages (Rule 4): no code in
Driftlands.Runtime.Movement/.EnemydeclaresOnCollisionEnter2D/OnTriggerEnter2Dor anyOnCollision/OnTrigger(raw-text fence). Damage is commands-only (ADR-0002/0015) and cannot originate in a physics callback by construction.
4. Host-commanded snaps — the closed list, with the mechanism NGO forces
Teleporting an owner-authoritative transform from the host throws (src). So every host-initiated reposition is a message the owner honours:
host tick (Advance / CombatState.Tick / a command) owner (client or host-as-owner)
PlayerSnapService.Snap(key, reason, pos): PlayerMotor.OnSnapped(PlayerSnapped o):
HostPositions.Override(key, pos, until: NowTick + snapGraceTicks) ── host truth NOW _mover.CancelDisplacement(); _mover.ResetVelocity()
ctx.Broadcast(new PlayerSnapped(key, reason, pos)) ── phase 4 → OutcomeRpc ──► _nt.Teleport(pos, rot, scale) (teleport flag → observers do NOT interpolate)
ISnapSource.Snapped(reason, pos) (camera hard cut — Rule 3)
Override ends when the owner's next NT state lands within snapAcceptRadius of pos, or at the grace tick (unresponsive client → EC11's "hard teleport" is host truth from the moment of the event; the body catches up when it can)
- Triggers (the whole list — TR-move-005):
SoftCloseDeposit(socket)atφ+W(ADR-0005 → every player whose host-known position is on that land →depositPoint,SnapReason.TideDeposit);EvictionExpired(socket)(EC11 backstop → anyone still there → same point,TideDeposit); combatOnZeroHp → Dead(DeathRespawn,homeSpawn); rejoin/late-join placement is spawn-time (ADR-0008 §6:QueueSpawn(player, pos)— the authority teleports at spawn by construction, src doc), not a snap; a saved position on a now-Absent land at load (tide EC1) resolves in the same spawn-time path.SnapReasonstays exactly{TideDeposit, DeathRespawn}— the enum the camera epic shipped. - Who observes what: the owner cuts (Teleport + camera hard cut); observers receive the teleport-flagged state and apply without interpolation — the "interpolation cut for observers too" that BB EC21 contrasts itself against. The host's own player takes the same path locally (no RPC;
BroadcastreachesClientsAndHost). Broadcastfrom outside a command (C1, TD-ADR).SoftCloseDepositfires in tick step 1 (Advance) andDeathRespawnin step 2 (CombatState.Tick) — neither insideDrain().HostContextis built once at the top of the tick (ADR-0015 §1) and itsBroadcastbuffer is the same object the drain flushes in phase 4, so both land in the same tick'sOutcomeRpcbatch before every envelope outcome, in buffering order. ADR-0002 §5 step 4 is amended from "a command never observes its own broadcast" to "nothing observes a broadcast before the phase-4 flush", and the registry'soutcome_broadcastcontract from "sent only from drain phase 4 in command order" to "flushed only at phase 4: tick-step outcomes (steps 1–3, buffering order) then command outcomes (command order)".- The override is a position fact, not a gate (C2). A command already queued from an overridden player still runs under ADR-0002's tick-threshold drop rule (which reads no positions). A position-dependent gate —
ArcTest, harvest proximity,noPlayerOccupancy— then reads the overridden position and fails, which is the intended result: a< φ+Wstrike from a deposited player resolves and whiffs, it is not dropped and cannot hit. No command inspectsOverridedirectly. (EditMode test: strike withResolveTick < SoftCloseTickfrom a player overridden in step 1 → executes, zero hits.) - Why host truth first: gates in the same and next ticks (a strike, a harvest, a placement on the departed land) must see the deposit before the owner's transform has moved (one RTT away).
RangeSlackUnits(ADR-0008 §7) absorbs ordinary staleness; the override absorbs the snap. A client that never converges (cheating or dead) is the accepted co-op risk ADR-0008 already states. PlayerSnappedis anOutcomeKind(ADR-0008 amended): 16 BPlayerKey+ 1 B reason + 8 B position = 25 B ≤ 64.
5. BB Edge Case 21 — ratified: an interpolated write, not a cut
The correcting client (the owner, an owner-rail action per BB) computes FindCorrectionCell (pure, Rules.Build) and calls PlayerMotor.CorrectTo(cell.Center): a direct transform.position write on the authority — not Teleport(), not SetState(teleportDisabled: false). NT commits it as an ordinary delta at the next tick (src: PositionThreshold gates sending; there is no receive-side distance threshold in the 2.13 interpolator — V2 confirms in play), so observers slide the one cell over the buffer; the mover itself snaps (velocity reset, displacement cancelled); the camera follows smoothly (no ISnapSource event — the shipped SnapReason has no EC21 member, and this ADR keeps it that way). Accepted for the reason BB gives: a one-cell slide over ~1 tick reads as motion the wall's thunk explains. SetState is reserved for exactly nothing at MVP (fence: Teleport( and SetState( appear only in PlayerMotor.OnSnapped). OnSnapped calls Teleport(pos, transform.rotation, transform.localScale) — the live rotation and scale, never identity (specialist note 8); sprite facing is SpriteRenderer.flipX, never localScale.x = −1, so a teleport can never pop the facing.
6. Remotes — interpolation, never extrapolation (provisional, V5)
- Non-owner
PlayerTransform/EnemyTransforminstances:Interpolate = true,Lerp(src: "linear buffer queue consumption that maintains a consistent time to the next target"; never extrapolates);PositionMaxInterpolationTime = 0.1 s(default);NetworkTransform.InterpolationBufferTickOffset = cfg.RemoteInterpBuffer(default 1, range 1–3) set once at boot — it is a static (src 4677) and therefore applies to every NetworkTransform, players and enemies alike; the spec'sremoteInterpBufferknob is this value. Effective buffer =max(1, TickLatency + offset)ticks, andTickLatencyadapts to measured RTT at runtime (src: default 1, runtime floor 2 once measured) — so at a real 60 ms RTT expect ≥ 3 ticks (60 ms) with offset 1, not the 40 ms a static reading of the defaults suggests.LegacyLerpignoresTickLatencyentirely (src doc) — one more reason it is not used.SmoothDampeningis the one alternative the playtest may switch to by amendment (src: "computationally more expensive"; adjusts to rate of change — the option for "warps on direction reversal"). - The prototype's "not teleporty at 70–140 ms" was
LegacyLerpon NGO 2.13 defaults;Lerpis the successor with a time-consistent buffer. If the friend playtest's warp counter (RttLogger.WarpsSeen) disagrees, the amendment order is: offset 1→2, thenSmoothDampening, never extrapolation. - If
OnUpdateis ever overridden (it is not at MVP), non-authority must callbase.OnUpdate()(src remark).
7. Host-known positions — raw state, one RTT stale, snap-overridden
HostPositions : IHostPositions (ADR-0003's "reads NetworkTransform state") is finalised with ADR-0003's interface unchanged — HostKnown(ulong clientId) and All() → (ulong, Vector2): a clientId is a within-tick lookup handle that every drained command carries (OwnerClientId), not a row key (ADR-0002 §8/ADR-0015 §1 ban it as a key; the override table is keyed by PlayerKey internally and SessionState maps). HostContext gains no member (its member count is compiler-pinned) — C4: for the host's own player it reads the live transform; for a remote it reads the raw last-received authoritative position captured by PlayerTransform.OnNetworkTransformStateUpdated(ref old, ref new) on the host (V1) — never the host's interpolated copy of that player (which is a further remoteInterpBuffer behind); a pending §4 override wins until it expires. Staleness bound stated for every gate: ≤ 1 RTT + 1 tick (V6). BB's noPlayerOccupancy and placerHasExit, combat's ArcTest attacker position, harvesting's proximity check, and the tide deposit's "who is on this land" all read this and nothing else. Every HostKnown consumer is a single-tick predicate; none derives velocity, continuity or a delta across ticks — a snap override may therefore jump the value discontinuously between ticks with no consumer noticing (C3). A future consumer that needs continuity must say so and amend this ADR (registered: host_known_is_not_continuous). ADR-0015's revive-reach cancel is evaluated per tick from these values and is unaffected: a deposit moves reviver and target together, a death respawn moves only the dead reviver and cancels the channel — both correct.
8. Facing, aim, and what is not replicated
IAimSource.AimVector => _input.Current.Aim(ADR-0007 pass-through) on the owner; the owner's sprite faces the aim (flip X on sign, 8-way facing frame from the octant — presentation).- Aim is not replicated at MVP. Remote players face their last non-zero movement direction (derived on each machine from the interpolated transform's velocity). If the playtest wants remote aim-facing, it is one
NetworkVariable<byte> AimOctantonPlayerNetunder ADR-0008's rule — an amendment, not a redesign.AnimFramereplication is enemies-only (ADR-0008 §5); player flipbooks are derived locally on every machine fromPlayerActorNet.State+ velocity.
9. Config (movement.json) and fences
MovementConfig(float MoveSpeed /6; 5–8/, float AccelTime /0; 0–0.08 s/, float DownedCrawlSpeed /2; 1.5–3/, int RemoteInterpBuffer /1; 1–3/, float SnapAcceptRadius /1.0; 0.5–2/, int SnapGraceTicks /25; 5–100/, float SkinWidth /0.01; 0.005–0.05/, string[] BlockerLayers /["Structure","Terrain","WorldBounds"]/) — loaded and validated by ADR-0006's loader (finite, in range; schema-negative: the reader rejects any key containing modifier, stamina, encumbr, weight, penalty — the InventoryConfigReader pattern; movement AC 2). movement.json joins ManifestRules.RequiredFiles and both TestJson fixtures (core-camera story-001's lesson). dashSpeed/dashDuration stay in combat.json (combat owns the dash; movement only integrates the request).
| Fence (EditMode) | Asserts | ||
|---|---|---|---|
Move_PrefabAuthority | the player prefab's PlayerTransform serialises AuthorityMode = Owner, Interpolate = 1, SyncPositionZ = 0, rotation/scale sync 0; the enemy prefab's serialises AuthorityMode = Server (YAML read, like SceneShapeSignature) | ||
Move_NoRigidbodyMotion | no NetworkRigidbody2D/NetworkRigidbody/AnticipatedNetworkTransform component on any prefab or scene; no call to Rigidbody2D.MovePosition/AddForce/velocity = in Runtime (raw-text) | ||
Move_TeleportOnlyFromSnap | Teleport( and SetState( occur in exactly one method: PlayerMotor.OnSnapped (raw-text) | ||
Move_NoCollisionCallbacks | no OnCollision/OnTrigger method in Driftlands.Runtime.Movement or .Enemy (reflection) | ||
Move_QueriesStartOutside | Physics2DSettings.asset serialises m_QueriesStartInColliders: 0 (YAML read) | ||
Move_LayerMatrix | DynamicsManager.asset's layer collision matrix has Player↔Player, Player↔Enemy, Enemy↔Enemy off and every mover↔blocker pair on (YAML read) | ||
Schema_NoMoveSpeedModifier (extends ADR-0003's Schema_*) | no type in Rules/Runtime declares a member matching `MoveSpeedModifier | Stamina | Encumbrance; MovementConfig` exposes exactly the fields above; the reader rejects the banned tokens |
Move_SnapReasonClosed | SnapReason has exactly two members (TideDeposit, DeathRespawn) — EC21 never grows the list |
Architecture Diagram
OWNER (every rendered frame) HOST (50 Hz tick)
InputFrame ──► PlayerMotor: mode(PlayerState) ─► Locomotion.Step ─► Sweep.Move(ISweepQuery=Physics2DSweep) ─► transform.position
▲ IDisplacementSink.Displace(Dash) ◄── owner combat machine (Dashing entry)
▲ CorrectTo(cell) ◄── BB EC21 (owner rail; plain write → observers slide)
▲ OnSnapped(PlayerSnapped) ─► Teleport + ISnapSource.Snapped ◄── OutcomeRpc ◄── PlayerSnapService.Snap (deposit / eviction / death) + HostPositions.Override
PlayerTransform (Owner auth) ═══ tick-rate deltas (unreliable, Lerp on receivers, buffer = TickLatency + RemoteInterpBuffer) ═══► host: OnNetworkTransformStateUpdated → HostPositions (raw, ≤ 1 RTT + 1 tick)
► other clients: interpolated body (never extrapolated)
ENEMIES: EnemyMotor (host, ADR-0012) ─► same KinematicMover2D/Sweep ─► EnemyTransform (Server auth) ═══► clients interpolate; knockback = Displace(Knockback) into the same sweep
Key Interfaces
namespace Driftlands.Rules.Movement // engine-free (ADR-0003)
{
public enum LocomotionMode : byte { Normal, Crawl, Frozen }
public enum DisplacementKind : byte { Dash, Knockback }
public readonly record struct DisplacementRequest(Vector2 Velocity, float RemainingSecs, DisplacementKind Kind);
public readonly record struct LocomotionState(Vector2 Velocity, DisplacementRequest? Active, float AccelT);
public static class Locomotion { public static (LocomotionState next, Vector2 delta) Step(LocomotionState s, Vector2 move, LocomotionMode mode, float dt, MovementConfig cfg); }
public interface ISweepQuery { bool Cast(Vector2 origin, Vector2 direction, float distance, out float hitDistance); } // shape cast of THIS body against the blocker layers
public static class Sweep { public static Vector2 Move(Vector2 from, Vector2 delta, ISweepQuery q, float skin); } // X then Y, stop `skin` short
public interface IDisplacementSink { void Displace(DisplacementRequest r); } // dash (owner) / knockback (host, enemies)
public static class Facing { public static byte Octant(Vector2 v); } // 8-way sprite facing from aim (owner) or velocity (remotes)
}
namespace Driftlands.Runtime.Movement
{
public sealed class Physics2DSweep : ISweepQuery { /* Rigidbody2D.Cast, ContactFilter2D{layerMask, useTriggers=false}, RaycastHit2D[8] preallocated */ }
public sealed class KinematicMover2D : MonoBehaviour, IDisplacementSink { // the ONLY writer of transform.position for players and enemies
public void Drive(Vector2 move, LocomotionMode mode, float dt); // Step + Sweep + write
public void Displace(DisplacementRequest r); public void CancelDisplacement(); public void ResetVelocity();
public void CorrectTo(Vector2 pos); // EC21: direct write, no Teleport
}
public sealed class PlayerMotor : NetworkBehaviour, IAimSource { // enabled only on the owner
public Vector2 AimVector => _input.Current.Aim; // ADR-0007 pass-through, retained
internal void OnSnapped(PlayerSnapped o); // Teleport + camera cut — the single Teleport/SetState call site
}
public sealed class PlayerTransform : NetworkTransform { // AuthorityMode = Owner; host captures raw remote positions
protected override void OnNetworkTransformStateUpdated(ref NetworkTransformState oldState, ref NetworkTransformState newState) { /* host only: HostPositions.Record(owner, newState position) */ }
}
public sealed class HostPositions : IHostPositions { // ADR-0003 finalised
public Vector2 HostKnown(ulong clientId); // ADR-0003's shape, unchanged: clientId is a WITHIN-TICK lookup handle (valid for any command drained this tick), never a row key
public IReadOnlyList<(ulong clientId, Vector2 pos)> All(); // ADR-0003 §4 PlaceStructureCommand calls this — unchanged
internal void Override(PlayerKey key, Vector2 pos, long untilTick); // keyed by PlayerKey internally; SessionState maps key ↔ clientId (ADR-0008)
}
public sealed class PlayerSnapService : IPlayerSnapper { // host only; subscribes SoftCloseDeposit/EvictionExpired; called by CombatState for DeathRespawn
public void Snap(PlayerKey key, SnapReason reason, Vector2 pos); // Override + Broadcast(PlayerSnapped) — never touches a transform
}
public sealed class SnapSource : ISnapSource { public event Action<SnapReason, Vector2> Snapped; } // raised by PlayerMotor.OnSnapped on the owner — the camera's real emitter
}
namespace Driftlands.Rules.Cameras { public interface IPlayerSnapper { void Snap(PlayerKey key, SnapReason reason, Vector2 pos); } } // declared beside ISnapSource; ADR-0015's Snap(DeathRespawn) call target
namespace Driftlands.Data
{
/// movement.json — quick-spec knobs + this ADR's; schema-negative reader (no modifier/stamina/encumbrance/weight/penalty keys).
public sealed record MovementConfig(float MoveSpeed, float AccelTime, float DownedCrawlSpeed, int RemoteInterpBuffer,
float SnapAcceptRadius, int SnapGraceTicks, float SkinWidth, IReadOnlyList<string> BlockerLayers);
public readonly record struct PlayerSnapped(PlayerKey Key, SnapReason Reason, Vector2 Pos) : IOutcome; // ADR-0008 OutcomeKind.PlayerSnapped
}
Alternatives Considered
Alternative B: server-authoritative NetworkTransform + AnticipatedNetworkTransform on the owner
- Description: the host owns every player transform; the owner anticipates locally and NGO reconciles.
- Pros: host can
Teleport()anyone; one authority. - Cons: reconciliation is host correction of an owner's position — TR-move-002 forbids it at MVP; the prototype's "instant own movement" result was measured under owner authority; every strike/harvest/BB read would be one RTT fresher but the player's own hands one RTT laggier under packet loss.
- Rejection Reason: contradicts the confirmed model and Forbidden Patterns ("no remote-player prediction / no reconciliation").
Alternative C: owner authority, ownership handed to the host for the snap and back
- Description:
ChangeOwnership(host)→Teleport→ChangeOwnership(owner). - Cons: two ownership round trips per deposit; the owner's inputs during the handover are lost or fought over; NGO's own doc recommends staying with the motion authority; CHANGELOG notes ownership-change edge cases in
NetworkTransform. - Rejection Reason: a message the owner honours is one RPC and zero ownership churn; host truth is covered by the override.
Alternative D: FixedUpdate + Rigidbody2D.MovePosition (physics-solver movement)
- Cons: up to 20 ms extra input latency at 50 Hz; the solver resolves contacts by pushing, which BB EC21 explicitly forbids for a wall landing on a player; kinematic contacts against static colliders need
useFullKinematicContactsand still give normals, not free cells. - Rejection Reason: user decision 2026-09-11 — Update-driven sweep keeps "full speed the frame input arrives" literal and keeps the solver out of gameplay.
Alternative E: NetworkRigidbody2D with UseRigidBodyForMotion
- Rejection Reason: couples motion to the physics step and to NGO's rigidbody sync path; unverified behaviour on 6.3; nothing here needs dynamics.
Alternative F: reject EC21's asymmetry — Teleport() for all three repositions
- Rejection Reason: user decision 2026-09-11 — a one-cell pop on observers dramatises a nudge the wall already explains; keeping
SnapReasonat two members leaves the camera epic's shipped enum untouched.
Alternative H: NGO's native push — SetState from the host → SetStateClientRpc(SendTo.Owner)
- Description: the host calls
SetState(pos, …, teleportDisabled: false)on its non-authority instance; NGO routes it to the owner (SetStateClientRpc), who applies it as authority (specialist note 6; src ~4097). The mirror pathSetStateServerRpc(InvokePermission = Owner) withOnClientRequestChangeexists too. - Pros: zero custom code for the transport of the snap; NGO-maintained.
- Cons: carries no
SnapReason(the camera needs it), noPlayerKey, and ridesNetworkTransform's own message path with no ordering relation to the drain's outcomes — aPlayerDiedoutcome and its respawn snap could arrive in either order; the doc marksSetStateas "not resistant to network jitter"; nothing tells the host's gates the snap happened (the override in §4 is still needed). - Rejection Reason: the snap is an outcome of a host decision and belongs on the outcome channel with the decision's reason, in command order, through the codec fence.
OnClientRequestChangeis noted for ADR-0015's EC16 arbitration but not used — the mirror already decides i-frames from the command stream.
Alternative G: separate player and enemy movers
- Rejection Reason: user decision 2026-09-11 — one sweep rule, one test; ADR-0012 drives the mover instead of re-deciding collision.
Consequences
Positive
- Every reposition in the game has a named path: locomotion (owner), dash/knockback (displacement into the same sweep), EC21 (owner write), the three authority snaps (host-commanded, owner-honoured, host-truth-first). No fourth way to move a body exists.
- The camera epic's
ISnapSourcegets its real emitter without changing the enum it shipped against. - Walls block players, enemies and dashes through one pure function; ADR-0011 only instantiates colliders, ADR-0012 only drives the mover.
HostKnownhas a stated staleness bound and a stated override, so every host gate reads the same, documented number.
Negative
- A host-initiated snap is one RTT late on the owner's screen; for that RTT the owner may still be moving on the departed land (host truth says otherwise). Accepted and bounded by the override.
InterpolationBufferTickOffsetis global: players and enemies share one buffer knob. Fine at MVP; ADR-0012 cannot pick a different buffer without a per-instance mechanism NGO does not offer.- Aim is not replicated: remote players face their movement direction, not their aim — a visible simplification for an observer watching a stationary ally aim.
- Axis-separated sliding is not swept-circle physics: a diagonal into a corner stops dead rather than gliding; deliberate.
Risks
- V1 fails (the state struct's position accessor is not reachable from
Driftlands.Runtime, or is in local space). Mitigation: fall back to the host's interpolated copy plusremoteInterpBufferticks added to the staleness bound;RangeSlackUnitsalready covers one buffer at 6 u/s. - V2 fails (a one-cell write pops on observers under
Lerp). Mitigation: the asymmetry is ratified on the observer feel; ifLerppops, EC21 joins theTeleportpath by amendment with a named cost:SnapReasongainsCorrection, which firesMove_SnapReasonClosed, changes the shippedISnapSourcecontract three camera stories consume, and makes the camera cut on a one-cell nudge — the movement spec and camera Rule 3 would both need the user's assent (C8). - Tunnelling at high frame time: a 100 ms hitch with a 22 u/s dash is 2.2 u of sweep in one frame — still one cast, no tunnelling (the cast covers the whole delta). Terrain blockers thinner than
skinare an authoring error caught by the land template validator (ADR-0005/0006). - Global buffer knob: a playtest wanting smoother enemies but fresher players cannot have both. Arbitration (C7): players win — own-feel is Pillar 4; enemies get
SmoothDampeninginstead of a larger offset. The §6 amendment follows that procedure, not a preference.
GDD Requirements Addressed
| GDD / spec | Requirement | How This ADR Addresses It |
|---|---|---|
| player-movement Rules 1, 2, 5 (TR-move-002) | flat speed, instant, owner-authoritative, no modifiers (Downed = mode) | §2 Locomotion.Step + LocomotionMode; §9 schema-negative reader + fence |
| player-movement Rule 3 (TR-move-001) | aim decoupled, persists | ADR-0007; §8 pass-through; aim not replicated |
| player-movement Rule 4 (TR-move-003) | collision blocks never damages; no body collision | §3 layers, matrix fence, no-callback fence |
| player-movement Rule 6 (TR-move-004/005) | remotes interpolated; closed snap list; EC21 asymmetry decided | §6; §4 list; §5 ratified |
| combat Rule 4, EC16 (TR-combat-008) | dash as a displacement; i-frames owner-side, host wins if not arrived | §2 dash via IDisplacementSink; EC16 is ADR-0015's mirror rule — movement adds no lag compensation |
| combat F5 / Rule 11 | knockback = single displacement per enemy per tick; never into lethal terrain | §2 knockback into the same sweep (walls stop it) |
| base-building EC21, AC-27 (TR-bb-007) | walls block instantly; correction is a discrete owner write | §3 colliders on Structure; §5 |
| tide Rule 9, EC1, EC11 | deposit at depositPoint; rejoin placement; grace-expiry hard teleport | §4 triggers + host-truth override; spawn-time placement (ADR-0008 §6) |
| camera Rule 3 (TR-cam-002) | one hard cut on deposit/respawn | §4 SnapSource raised from PlayerMotor.OnSnapped |
| netcode-session TR-net-008 | execution-phase ordering pinned | §7 staleness bound ≤ 1 RTT + 1 tick |
Performance Implications
- CPU: one shape cast per axis per moving body per rendered frame (2 casts × ≤ 10 bodies) ≪ 0.1 ms;
Locomotion.Stepallocation-free (record structs). - Memory:
RaycastHit2D[8]per mover; nothing else. - Network: unchanged from ADR-0008 §8 (tick-rate NT deltas);
PlayerSnappedis 25 B, a few per hour. - Load time: none.
Migration Plan
MovementConfigfields +movement.json+ reader with the banned-token rule +RequiredFiles(+ bothTestJsonfixtures) — Data story.Rules.Movement(Locomotion,Sweep,Facing,ISweepQuery,IDisplacementSink) with EditMode tests: step at accelTime 0 and 0.04; crawl/frozen modes; dash consumes time and stops at a fake wall; sweep slides X-then-Y; corner stop — mutation-checked.- Runtime:
Physics2DSweep,KinematicMover2D, layers + matrix + fences;PlayerMotoron the boot-scene player once NGO is in the manifest (NetcodeSession story 001 first). PlayerTransform,HostPositions(V1/V6),PlayerSnapService+PlayerSnappedoutcome (ADR-0008 codec),SnapSourcewired intoCameraFollowRig(replaces the camera story-003 fake).- BB EC21 story calls
CorrectTo(V2 as its documented playtest); combat dash story callsDisplace. - Playtest amendment:
RemoteInterpBuffer/LerpvsSmoothDampeningfrom the CSV + warp counter.
Validation Criteria
- EditMode: the seven §9 fences green, each with a companion negative (a prefab with
AuthorityMode = Serveron the player, a strayTeleport(in another file, anOnTriggerEnter2Din the mover, a matrix with Player↔Enemy on). - EditMode (pure): movement AC 1 (full speed same frame; stop next frame); AC 2 schema-negative; AC 3 aim parity is ADR-0007's test, referenced; dash-into-wall stops at
wall − skin; knockback slides along a wall;Facing.Octanttable. - PlayMode:
Physics2DSweepagainst aBoxCollider2Dwall and aCompositeCollider2Dtilemap, zero allocation (AllocatingGCMemory+ control); a placed wall blocks on the very next frame; a body starting overlapped with a wall moves away without self-blocking (queriesStartInColliders = false). - Documented playtest / two standalone builds: V1, V2 (QQ-02), V3, V6, V7; own movement starts within 0–1 frames on host and client at simulated 70–140 ms (movement INTEGRATION AC); remote "not teleporty" on direction reversal.
- Friend playtest: warp counter + CSV → §6 amendment.
Related Decisions
- ADR-0008 (wire, tick,
OutcomeRpc,HostKnownconsumer list), ADR-0015 (dash/knockback requests,Snap(DeathRespawn), Downed mode), ADR-0005 (deposit events), ADR-0007 (InputFrame,IAimSource), ADR-0003 (purity,UnityInterop), ADR-0011/0012 (consumers of the layers and the mover). design/quick-specs/player-movement-2026-08-27.md,camera-2026-08-27.md,base-building.mdEC21,combat-system.mdRules 4/11 + EC16,tide-scheduler.mdRule 9/EC1/EC11;architecture.mdrow 9, QQ-02;prototypes/combat-netcode-feel-concept/Scripts/ClientAuthTransform.cs(the prototype's owner-authority NT subclass — reference only, never production).
Amendments — 2026-09-11 (ADR-0011 structure registry replication)
- §3's blocker set is instantiated by ADR-0011: one static
BoxCollider2Dper registry row onStructure, created in theBaseNetlist'sAddhandler followed byPhysics2D.SyncTransforms()so the same frame's sweep sees it. §5's EC21 correction is triggered from the listAddevent (the collider's own edge), never from theStructurePlacedoutcome, with the GDD's pins: re-check before the write, at most one correction in flight, N-E-S-W tie-break.
Amendments — 2026-09-11 (ADR-0012 enemy simulation model)
- The coincident-start separation tiebreak owed by §3 (TD C6) is delivered:
Rules.Enemy.Steering.TiebreakDirection(index)= unit vector atindex × 137.5°— deterministic, distinct per index, never random.EnemyMotordrivesKinematicMover2D.Drive(velocity, Normal, dt)each rendered frame on the host with seek + separation; attacking enemies hold their ground (SeparationWhileAttacking = 0); knockback arrives throughIDisplacementSink.