Skip to content

ADR-0001: Owner-authoritative player movement with server-side clamps

  • Date: 2026-08-22
  • Status: Accepted
  • Decision maker: Joao Miranda (product owner)
  • Ticket: RIOT-51

Prefabs/Player/Player.prefab carries two position replicators on the same GameObject, with opposite authority:

Component Line _ownerAuth Controller resolves to
PurrNet.NetworkTransform :167, flag at :187 1 the owning client
PurrNet.NetworkRigidbody :225, flag at :237 0 the server

NetworkIdentity.cs:266IsController(bool ownerHasAuthority) => ownerHasAuthority ? isController : isServer — is what makes these resolve differently on the same body. The result is not redundancy, it is two systems fighting:

  1. On the host, NetworkTransform.cs:413-430 calls Rigidbody.Sleep() every physics step for every remote player, because the host is not the NetworkTransform controller. NetworkRigidbody then reads that frozen _rigidbody.position and rebroadcasts it as authoritative with zero linearVelocity.
  2. On the owning client, NetworkRigidbody.cs:163-166 routes the owner down NonControllerTick, which soft-corrects toward the server’s echo of the client’s own position from ~1 RTT ago, and hard-snaps via MovePosition past the correction threshold. The owner is dragged backwards by a stale copy of itself.
  3. Both components delta-pack position and rotation independently, so every player costs double transform state on the wire.

The player prefabs are the only objects in the project carrying both components. All 28 item prefabs and every NetworkRigidbody in NewApartment.unity are uniformly _ownerAuth: 0.

The owning client is authoritative over its own raccoon’s movement.

  • Keep NetworkTransform with _ownerAuth: 1. Delete NetworkRigidbody from the player prefab.
  • The server does not simulate remote players. It validates the incoming transform stream and clamps or rejects values outside the thresholds below.
  • Items keep server authority. This decision covers the player only; the 28 item prefabs stay _ownerAuth: 0. That split is deliberate and is what RIOT-168 / RIOT-174 must be written against.

Player-vs-player physics: out of scope, deliberately

Section titled “Player-vs-player physics: out of scope, deliberately”

Fighting is a real mechanic, but it is targeted, not emergent. PlayerAttack.cs already works this way — it resolves a _bestAttackTargetRef to an IInteractable and acts on it.

Raccoons do not shove each other around as emergent rigidbody collision. Body-checking someone off a ledge is not a supported verb. Player-vs-player collision is cosmetic, resolved on each owner’s machine, and is permitted to disagree between clients.

This was asked and answered explicitly during the decision, because emergent shoving is the one requirement that would have invalidated owner-authority.

Knockback is a server-issued instruction, never a server-side force

Section titled “Knockback is a server-issued instruction, never a server-side force”

Because the server does not control the player’s rigidbody, it cannot apply knockback with AddForce. Any such call is corrected away by the owner within one tick.

Knockback is delivered as a networked event carrying an impulse vector, which the target’s own client applies through its VelocityCompiler. The server decides that knockback happens and how much; the owner executes it.

This is a hard constraint on the T3 damage work (RIOT-140, RIOT-54) and on RIOT-96.

⚠️ These are initial values, not measured ones. They are derived from the code and must be validated in RIOT-94 before being treated as final.

They are expressed in real units, post-de-inflation. They are meaningless until RIOT-75 lands: VelocityCompiler.cs:123-130 currently multiplies by Time.fixedDeltaTime in LateUpdate, a constant ÷50 (TimeManager.asset:6 Fixed Timestep: 0.02), compensated by prefab tuning inflated 40–50× (Player.prefab:515 _walkSpeed: 250 against PlayerWalking.cs:17 _walkSpeed = 5f; :529 _jumpHeight: 200 against PlayerJump.cs:12 _jumpHeight = 5f). Intended walk speed is therefore 5 m/s250 × 0.02.

Threshold Initial value Derivation
Max horizontal speed 12 m/s 5 m/s walk, plus headroom for knockback impulses and slides
Max vertical speed 25 m/s terminal fall velocity in a single-apartment map
Max per-tick position delta 1.5 m 12 m/s at the 20 Hz tick (InitializeScene.unity:197) is 0.6 m; margin for a knockback burst
NaN / infinity always reject not a clamp — a rejection, logged
Geometry raycast out of scope for v1 revisit only if wall-clipping is observed in play

Excluding the raycast is a conscious acceptance of risk: a modified client can walk through geometry. Under a friends-only lobby model that is tolerable. It stops being tolerable if the game ever ships public matchmaking, at which point this ADR is superseded.

Server-authoritative movement with client input RPCs. Rejected. The input channel does not exist in any form — InputController.cs:9 is a plain MonoBehaviour that has never emitted an RPC, so the server has never received an input frame in the history of this branch. It is hard- blocked by RIOT-39, since PlayerModule.cs:34-45 destroys movement components on every non-owner. And PurrNet 1.19.1 ships no prediction moduleColliderRollback is the only rollback machinery in the package and zero instances exist in the project. The honest cost is a prediction, reconciliation and rollback engine written by hand, in exchange for anti-cheat guarantees that matter in ranked competitive shooters and not in a four-player party game on a 20 Hz tick.

Unblocks: RIOT-61 (remove the losing replicator), RIOT-75 (de-inflate tuning), RIOT-67 (VelocityCompiler LateUpdate→FixedUpdate), RIOT-86 (GravityModule coroutine), RIOT-94 (the clamps themselves), RIOT-76 (the Destroy(this) calls in VelocityCompiler/GravityModule).

Closes as not-applicable: RIOT-122 (ColliderRollback / lag compensation) — there is no server-side hit validation of position to compensate for.

Cheating on position is possible within the clamp envelope. Accepted, see above.

The game ships public matchmaking, or emergent player-vs-player physics becomes a designed verb. Either reopens this decision, and the second one requires a full re-plan of T1 and T2.