---
status: diagnosed
trigger: "Two-player smoke test FAILED post Phase 06.7 client-trust flip — 2nd joiner sees nothing from 1st; 1st sees 2nd's updates super slow with no anim change and no velocity applied"
created: 2026-05-17T00:00:00Z
updated: 2026-05-17T00:00:00Z
---

## Current Focus

hypothesis: Three layered root causes — (#1) monotonic_at_ms .int() vs fractional Phaser timestamp rejects most frames; (#2) PredictionEngine always returns vx=0/vy=0 due to BNO instant-set model in step(); (#3) remote render ignores broadcast anim_state and re-derives anim from vx/vy (which are 0 from #2)
test: Static code analysis of server handler + client dispatcher + remote-render + step()
expecting: All three are visible in current code; tests do not catch them because mocks bypass each defect
next_action: Return diagnosis to caller (find_root_cause_only mode)

## Symptoms

expected: Two players in BNCentral both see each other's sprite positions, velocity-driven movement, running animation when other moves; chat works for both
actual:
  - 2nd joiner sees NOTHING of 1st joiner's movement/sprite (sprite may not even be visible)
  - 1st joiner sees 2nd's updates SLOWLY with inconsistent timing
  - 1st joiner: facing updates work, but sprite never animates (running anim doesn't play)
  - 1st joiner: velocity (vx, vy) is never applied to remote sprite rendering
  - Chat works both directions (so connection + broadcast generally healthy)
errors: None (no console errors, no test errors — purely operator observation)
reproduction: HUMAN-UAT Test 4. Two browsers, two accounts, join BNCentral, walk W/D/S/A square, each observes other
started: After Phase 06.7 client-trust flip (plans 02 + 03). Pre-06.7 worked.

## Eliminated

- hypothesis: PlayerState fields not @type-decorated for sync (so anim_state / sprite_override never reach peers)
  evidence: packages/protocol/src/state.ts:57-58 — both fields ARE @type('number') / @type('string'). Colyseus state-sync will broadcast them.
  timestamp: investigation

- hypothesis: onJoin fails to deliver existing-player state to late-joiner
  evidence: Colyseus 0.17 delivers initial state-sync containing all entries in state.players to a new client automatically (no app code needed). The onMessageHandlers PlayerState fields are server-set in onJoin BEFORE state.players.set, so a late-joiner WILL receive 1st-joiner's PlayerState in their initial state-sync.
  timestamp: investigation

- hypothesis: PositionDispatcher never fires for 2nd joiner (dispatcher not attached)
  evidence: GameScene.onLocalJoin (apps/client/src/scenes/GameScene.ts:676) creates the dispatcher when `!this.positionDispatcher && this.room && this.prediction`. Local onLocalJoin runs unconditionally; dispatcher is wired before update() loop calls sendTick.
  timestamp: investigation

- hypothesis: Colyseus $(player).onChange() doesn't register correctly during initial state-sync
  evidence: bindHandlers in colyseus-client.ts:251 registers $(player).onChange synchronously inside players.onAdd; per Colyseus 0.17 docs onAdd auto-fires for existing items, registering listeners.
  timestamp: investigation

- hypothesis: server `applyToColyseusState` overwrites client-written PlayerState fields
  evidence: packages/game-logic/src/step.ts:68-83 — when input is undefined (always the case in RebnoRoom.tickLoop now), step() preserves x/y/vx/vy/last_input_seq exactly. applyToColyseusState rewrites those identical values — no overwrite.
  timestamp: investigation

## Evidence

- timestamp: investigation
  checked: packages/protocol/src/intents.ts line 125-137 (cPositionUpdateSchema definition)
  found: monotonic_at_ms uses `.finite().int().min(0).max(0xffffffff)` — STRICT integer required. Plan 01-SUMMARY notes this was deliberate ("closes codex review concern #7"). x/y/vx/vy all `.int()` too.
  implication: Any fractional monotonic_at_ms causes zod parse failure → server logs `invalid_intent` and DROPS the frame.

- timestamp: investigation
  checked: apps/client/src/prediction/position-dispatcher.ts:30-46 (PositionDispatcher.sendTick)
  found: `monotonic_at_ms` is passed through to the payload UNMODIFIED. x/y/vx/vy are Math.round'd. monotonic_at_ms is NOT rounded.
  implication: Whatever the caller passes goes directly to the wire.

- timestamp: investigation
  checked: apps/client/src/scenes/GameScene.ts:1193 (sim-tick call to sendTick)
  found: `this.positionDispatcher?.sendTick(_time)` — passes Phaser's `_time` (the first arg to `update(_time, dt)`), which is the rAF/performance.now-derived high-resolution timestamp. In modern browsers this is fractional ms (e.g. 1234.567).
  implication: Every position_update frame fails `monotonic_at_ms.int()` UNLESS the browser returns an integer ms (rare modes: 1ms-clamped non-cross-origin contexts, throttled background tabs). Hence "super slow with inconsistent timing" — only the occasional integer-tagged frame is accepted.

- timestamp: investigation
  checked: apps/client/src/__test__/position-dispatcher.test.ts
  found: All unit tests call `dispatcher.sendTick(1)`, `sendTick(2)`, `sendTick(1000)`, `sendTick(1234)` — INTEGER arguments. Tests never exercise the realistic fractional-timestamp path.
  implication: The defect ships green-tested. Server handler integration tests also use synthetic integer timestamps. Drift between test fixture and runtime call-site.

- timestamp: investigation
  checked: packages/game-logic/src/step.ts:243-251 (with-input branch of step)
  found: After integrating displacement, `newPlayers.set(...)` writes `vx: 0, vy: 0` ALWAYS. Comment line 240-242: "BNO has no velocity accumulation (ACCEL=0, FRICTION=0 — instant set/stop). vx/vy are kept at 0 in the BNO-faithful model".
  implication: PredictionEngine.predictTick() consumes step() and assigns `this.localState = next.players.get(...)`. After any predict tick, localState.vx and localState.vy are 0. PositionDispatcher.sendTick reads `local.vx, local.vy` → always sends vx=0, vy=0 on the wire.

- timestamp: investigation
  checked: apps/server/src/onMessageHandlers.ts:354-360 (position_update handler)
  found: When a frame DOES pass zod (rare due to issue #1), server writes `player.vx = parsed.data.vx` (=0) and `player.vy = parsed.data.vy` (=0). PlayerState.vx/vy stays at 0 indefinitely for every player.
  implication: Server's authoritative PlayerState.vx/vy is always 0. Colyseus state-sync broadcasts vx=0, vy=0 to every peer.

- timestamp: investigation
  checked: apps/client/src/scenes/GameScene.ts:1213-1226 (sim-tick remote loop)
  found: For each non-local player in state.players, calls `playerRenderer.onSimulationTickRemote(sid, p.vx ?? 0, p.vy ?? 0, p.x ?? 0, p.y ?? 0)`. Does NOT consume `p.anim_state`. Comment line 1209-1212: "Remote players consume PlayerState.vx / .vy directly. Pre-06.7 derived velocity from held-axis fields, but server Path A no longer writes those fields; position_update writes vx/vy as px/tick."
  implication: Remote-render derives running flag from vx/vy. Both are always 0 (from issue #2). deriveFrameWithAuthoritativeFacing returns a Stand-* frame → running anim NEVER plays for any remote player. The broadcast PlayerState.anim_state (which IS correctly authored per-tick by the dispatcher's `packAnimState(facing, this.getIsRunning())` from input axes — bug-free at the dispatcher) is COMPLETELY UNUSED on the consumer side.

- timestamp: investigation
  checked: apps/client/src/render/PlayerRenderer.ts:434-470 (onSimulationTickRemote internals)
  found: deriveFrameWithAuthoritativeFacing(renderVx, renderVy, ...) — when renderVx=0 and renderVy=0, isRunning=false → Stand frame. Confirmed by line 476 "isFirstRemote → frameKey published as 0000-NaviStandD_000-style key".
  implication: Even with correct broadcast anim_state, the renderer ignores it.

- timestamp: investigation
  checked: apps/client/src/prediction/position-dispatcher.ts:38 (getIsRunning callback origin)
  found: GameScene.ts:681-685 builds getIsRunning from inputDispatcher.axisX()/axisY() ≠ 0. This correctly reports running based on user input — the running-flag bit in anim_state IS authored correctly. The dispatcher's anim_state byte on the wire is correct.
  implication: anim_state is correct end-to-end on the wire. The defect is that the consumer never reads it.

## Resolution

root_cause:
  Three layered root causes account for ALL observed symptoms:

  #1 (PRIMARY — explains "super slow, inconsistent timing" + "2nd joiner sees nothing"):
  cPositionUpdateSchema (packages/protocol/src/intents.ts:125-137) requires
  `monotonic_at_ms: z.number().finite().int().min(0).max(0xffffffff)` — strict integer.
  GameScene.ts:1193 calls `positionDispatcher?.sendTick(_time)` passing Phaser's `_time`
  (a fractional high-resolution timestamp from rAF). PositionDispatcher passes the value
  through unmodified to the wire payload. Server zod-parses → fails `.int()` → drops the
  frame with `invalid_intent` warn. Effectively 100% of position_updates are rejected
  except in rare browser modes where performance.now() returns 1-ms-clamped integers
  (background tabs, certain incognito modes). On staging this produces the
  "super slow, inconsistent timing" symptom. For the 2nd joiner viewing the 1st (who
  was at spawn and hadn't moved much before the 2nd joined), most/all updates are
  dropped → 1st's sprite appears frozen at spawn coords → "sees nothing of 1st".

  #2 (explains "velocity is never applied"):
  PredictionEngine.getLocalState() returns the post-predictTick `localState`, which is
  the `PlayerSim` returned by step(). Per packages/game-logic/src/step.ts:243-251
  (with-input branch) and BNO instant-set model, step() ALWAYS returns vx=0, vy=0.
  PositionDispatcher.sendTick (apps/client/src/prediction/position-dispatcher.ts:37-38)
  reads `local.vx, local.vy` from getLocalState → always sends vx=0, vy=0 on the wire.
  Server stores vx=0, vy=0 in PlayerState. Colyseus broadcasts vx=0, vy=0 to peers.

  #3 (explains "running animation never plays"):
  GameScene.ts:1213-1226 sim-tick remote loop reads `p.vx ?? 0, p.vy ?? 0` (which from
  #2 are always 0 even when the source position_update wasn't rejected) and ignores
  `p.anim_state` entirely. PlayerRenderer.onSimulationTickRemote derives the running
  flag from renderVx/renderVy → Stand frame always. The broadcast anim_state byte
  (which IS correctly authored by PositionDispatcher.sendTick line 40
  `packAnimState(facing, this.getIsRunning())`) is unused on the consumer side.

fix:
  Fixes are NOT applied — caller requested find_root_cause_only.

  Suggested fix direction (for Plan 06.8 or 06.7-hotfix):

  Fix #1 — choose ONE:
    (a) Coerce monotonic_at_ms to integer at the dispatcher boundary:
        apps/client/src/prediction/position-dispatcher.ts line 42 →
        `monotonic_at_ms: Math.floor(monotonic_at_ms)`. Simplest, minimal-blast-radius.
    (b) Loosen the schema in packages/protocol/src/intents.ts line 135 to
        `z.number().finite().nonnegative().max(0xffffffff)` (drop `.int()`). Revisits
        codex review concern #7 — only acceptable if the u32 binary contract is
        relaxed for the JSON-over-msgpack wire path.
    Preferred: (a). Keeps the strict int() invariant in the protocol while making the
    actual call-site honor it. Add a regression test in position-dispatcher.test.ts that
    calls `dispatcher.sendTick(1234.567)` and asserts the payload's monotonic_at_ms is
    1234 (or 1235, whichever rounding is chosen).

  Fix #2: PositionDispatcher must send the wire vx/vy that represents CURRENT input
  intent, NOT the post-step PlayerSim's BNO-instant-set vx=0. Replace the
  `Math.round(local.vx) / Math.round(local.vy)` reads with the same intent-axes source
  as `getIsRunning` already uses:
    `vx: Math.round((this.inputDispatcher?.axisX() ?? 0) * RUN_SPEED_PX_PER_TICK)`
    `vy: Math.round((this.inputDispatcher?.axisY() ?? 0) * RUN_SPEED_PX_PER_TICK)`
  This will require injecting an axis-callback into PositionDispatcher (parallel to the
  existing `getFacing` and `getIsRunning` callbacks). Mirrors the pattern at
  GameScene.ts:1199-1200 for `onSimulationTickLocal`.

  Fix #3: Remote-render must consume anim_state (the canonical signal) instead of
  re-deriving from vx/vy. Either:
    (a) Decode p.anim_state with unpackAnimState(byte).running and pass it as an
        explicit `isRunning` arg to onSimulationTickRemote (new arg). Renderer uses the
        running flag directly instead of computing from vx/vy hypot.
    (b) Continue to consume vx/vy but ensure they correctly carry intent (after fix #2
        this becomes a non-issue because vx/vy will be non-zero during movement).
    Both are valid. (a) is more principled (single source of truth on the wire);
    (b) is a side-effect of fix #2 and may be sufficient by itself.

verification: (not applied — diagnose only)
files_changed: []

## Files Involved

- packages/protocol/src/intents.ts:125-137 — cPositionUpdateSchema with .int() on monotonic_at_ms (root cause #1 location)
- apps/client/src/prediction/position-dispatcher.ts:30-46 — sendTick passes fractional monotonic_at_ms through (root cause #1 propagation point); reads vx/vy from prediction (root cause #2)
- apps/client/src/scenes/GameScene.ts:1193 — calls sendTick(_time) with fractional Phaser timestamp (root cause #1 call site)
- apps/client/src/scenes/GameScene.ts:1213-1226 — remote sim-tick loop reads vx/vy only, ignores anim_state (root cause #3)
- packages/game-logic/src/step.ts:243-251 — BNO instant-set model returns vx=0/vy=0 always (root cause #2 source)
- apps/client/src/prediction/predictor.ts:113-114 — predictTick assigns localState from step's output, propagating vx=0/vy=0 (root cause #2 propagation)
- apps/client/src/__test__/position-dispatcher.test.ts — unit tests use integer time args (1, 2, 3, 1000, 1234), masking root cause #1
