DRIFTLANDS co-op survival on drifting tides — studio progress

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/, record production/gate-checks/gate-0-provisional-pass-2026-09-11.md); the provisional note and revisit clause this ADR was written under are discharged. Playtest observation carried to the register: occasional remote stutter at ≈ 150–180 ms RTT (interpolation tuning, ADR-0009 §6).

Date

2026-09-11

Engine Compatibility

Field Value
EngineUnity 6.3 LTS (6000.3.18f1) · NGO 2.13.2 (NetworkTransform, NetworkTransformState) · Unity 2D Physics (Rigidbody2D, Collider2D, TilemapCollider2D/CompositeCollider2D) · Input System via ADR-0007 only
DomainNetworking + Physics 2D (movement)
Knowledge RiskHIGH 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 Consulteddocs/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 UsedNetworkTransform.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 positionsNo — mechanism, forced by NGO's authority rules and TR-move-002unchanged
§6 interpolation (remoteInterpBuffer, Lerp vs SmoothDampening, PositionMaxInterpolationTime)Yes — "the other player warps" points here firstre-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 compensationcombat knob change, not a mechanism change

Documents Amended by This ADR

Document Amendment
ADR-0008OutcomeKind 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-0015Snap(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-0005SoftCloseDeposit(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-0003New 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.mdRule 6: EC21 observer asymmetry ratified (§5); knobs table gains snapAcceptRadius, snapGraceTicks, skinWidth; remoteInterpBuffer maps to NetworkTransform.InterpolationBufferTickOffset (global).
design/gdd/base-building.mdEC21's "ADR questions, not settled facts" sentence → settled by §5 (interpolated write; V2 verifies the mechanism).
architecture.mdRequired-ADR row 9 ✅; §2 PlayerMovement row loses its ⚠️; QQ-02 → verification item V2 of this ADR.
UnityProject/Assets/Driftlands/Data/Configs/GameConfigs.csMovementConfig placeholder gets its fields (§8); movement.json becomes a required file (both TestJson fixtures).

ADR Dependencies

Field Value
Depends OnADR-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)
EnablesADR-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)
BlocksPlayerMovement epic (every story); the camera's ISnapSource real emitter (core-camera story-003 left it on fakes); combat dash stories; BB EC21 story
Ordering NoteSecond 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

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
NetworkObjectDontDestroyWithOwner = false (default)body despawns on disconnect (ADR-0008 §6)
PlayerTransform : NetworkTransformAuthorityMode = 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 = falseADR-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)
Rigidbody2DbodyType = Kinematic, simulated = true, useFullKinematicContacts = false, interpolation = Noneexists 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 Playerthe shape the sweep casts
KinematicMover2Done per moving body; owns the sweep§3
PlayerMotorenabled only on the owner (IsOwner); reads IPlayerInput.Current§2
PlayerNetADR-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)

3. Collision — one sweep rule, pure, over an adapter

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)

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)

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

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_PrefabAuthoritythe 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_NoRigidbodyMotionno NetworkRigidbody2D/NetworkRigidbody/AnticipatedNetworkTransform component on any prefab or scene; no call to Rigidbody2D.MovePosition/AddForce/velocity = in Runtime (raw-text)
Move_TeleportOnlyFromSnapTeleport( and SetState( occur in exactly one method: PlayerMotor.OnSnapped (raw-text)
Move_NoCollisionCallbacksno OnCollision/OnTrigger method in Driftlands.Runtime.Movement or .Enemy (reflection)
Move_QueriesStartOutsidePhysics2DSettings.asset serialises m_QueriesStartInColliders: 0 (YAML read)
Move_LayerMatrixDynamicsManager.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 `MoveSpeedModifierStaminaEncumbrance; MovementConfig` exposes exactly the fields above; the reader rejects the banned tokens
Move_SnapReasonClosedSnapReason 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

Alternative C: owner authority, ownership handed to the host for the snap and back

Alternative D: FixedUpdate + Rigidbody2D.MovePosition (physics-solver movement)

Alternative E: NetworkRigidbody2D with UseRigidBodyForMotion

Alternative F: reject EC21's asymmetry — Teleport() for all three repositions

Alternative H: NGO's native push — SetState from the host → SetStateClientRpc(SendTo.Owner)

Alternative G: separate player and enemy movers

Consequences

Positive

Negative

Risks

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, persistsADR-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 11knockback = 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, EC11deposit 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-008execution-phase ordering pinned§7 staleness bound ≤ 1 RTT + 1 tick

Performance Implications

Migration Plan

Validation Criteria

Related Decisions

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

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