"Owner-only simulation field reads frozen state on network proxies"
▸ SYMPTOM
Any consumer that reads a per-player gameplay field across peers gets frozen state on network proxies. Three observed strike patterns:
- Overhead UI: a name tag, health bar, or marker anchored to a per-player position field freezes over a remote player's spawn position while their character model walks away normally. The local player's own tag is fine.
- Host-side validator: a host-side proximity check (round validator, range gate, scorer) comparing a remote player's claim against the same field rejects every claim because it is comparing against the frozen join-spawn position. The tell: a TOTAL failure rate (not intermittent) means the input is frozen, not laggy.
- Lifecycle write: an
OnStartorOnEnabledhook that writesWorldPosition = ownerField * unitsPerMetreunconditionally clobbers the correctly replicated host-side placement back to origin on a freshly joining client (the owner-only field still holds its default). The player materializes under the world.
▸ CAUSE
A network proxy (IsProxy == true, a remote player's character on your client or host) never runs the owner's tick code. Movement logic typically early-returns with if ( IsProxy ) return;, so any private position field written inside OnFixedUpdate is stuck at whatever value it held at spawn.
Meanwhile, the character GameObject's WorldPosition is network-replicated and engine-interpolated, so the parented visual model tracks the real player correctly. The disconnect: the model follows the replicated transform, but any consumer that reads the frozen private field sees stale data.
The root cause is reading (or writing from) a field that is:
- Not
[Sync]: it does not replicate across the network - Written only inside the owner's
OnFixedUpdate: a proxy never executes that code path
The class is two-directional: on networked entities an owner-only simulation field can be STALE when read on a proxy AND UNINITIALIZED when written from early lifecycle hooks, and both poison the replicated transform.
▸ FIX
Primary fix: redirect the field's getter so the default read is proxy-safe:
// The field getter checks IsProxy and redirects:
public Vector3 FeetPosition
{
get
{
// Proxy: read the replicated transform (always current)
if (IsProxy)
return WorldPosition / UnitsPerMetre;
// Owner: read the raw integrator field (unchanged)
return _feetIntegrator;
}
}This makes every call site proxy-safe by default: future consumers cannot re-hit the class.
For overhead UI specifically, anchor off the live networked transform (WorldPosition), the same interpolated source the visual model renders from:
// Wrong - freezes at spawn for proxies:
var tagPos = chr.FeetPosition; // a private, non-[Sync] field
// Right - follows the replicated, interpolated position:
var tagPos = chr.WorldPosition;For lifecycle hooks, adopt the replicated transform into local simulation state, never push local defaults over it:
// Wrong - clobbers replicated position with uninitialized default:
protected override void OnStart()
{
WorldPosition = _feetM * UnitsPerMetre; // _feetM is still zero
}
// Right - seed from the replicated position:
protected override void OnStart()
{
_feetM = WorldPosition / UnitsPerMetre; // adopt, don't overwrite
}When seeding from a replicated value that could be wrong, choose the safe default by consequence (in a fall-is-free game: too HIGH is free, too LOW is the bug, so unknown always seeds high).
▸ WHY IT WORKS
GameObject.WorldPosition on a network proxy is maintained by the engine's snapshot replication and interpolation system. It updates automatically every render frame from received network snapshots, regardless of whether the owner's gameplay code runs. By reading the same source the visual model uses for rendering, any consumer is guaranteed to track the visible character: they cannot diverge because they share the same data source.
The general rule: anything positioned over, validating against, or writing from a NETWORKED entity must read the replicated transform, never an owner-only simulation field. The getter-redirect pattern makes this the default for the field itself, so future call sites inherit proxy safety without per-site awareness.
- Broadened from UI-only to any cross-peer consumer. Added second strike (host validator) and third strike (lifecycle write direction). Added getter-redirect fix pattern.
- Published (overhead UI case).