---
phase: 06.4-cycle-5-gap-closure
plan: 10
subsystem: direction-state
tags: [d58c, server-broadcast-facing, sprite-state-machine, reconciler-snap, REQ-SRV-03, REQ-CLI-04, REQ-CLI-08]

# Dependency graph
requires:
  - phase: 06.4-cycle-5-gap-closure
    provides: PATTERNS (server-authoritative state-broadcast pattern; window.__rebno telemetry; reconciler hard-snap branch from D-58 cycle-2)
  - phase: 06.4-cycle-5-gap-closure
    provides: Plan 05 spike — confirmed lastFacing-divergence root cause + locked server-broadcast fix path
provides:
  - "PlayerState.facing — @colyseus/schema @type('string') field (default 'D'); additive, no PROTOCOL_VERSION bump"
  - "playerFacingSchema — zod enum for wire-side Direction validation (packages/protocol/src/intents.ts)"
  - "deriveFacing in @rebno/game-logic — shared pure fn server + client (lifted from apps/client/src/render/SpriteStateMachine.ts)"
  - "PlayerRenderer.setRemoteFacing(sid, facing) — overrides per-entry facing for remote players every snapshot"
  - "PlayerRenderer.setLocalFacing(sid, facing) — overrides local-renderer facing on reconciler-snap only (checker WARNING 5)"
  - "GameScene.onRemoteSnapshot wiring — consume server-broadcast facing for remotes"
  - "GameScene.onLocalSnapshot wiring — reconcile local facing on divergence-threshold snap (window.__rebno.lastReconcilerSnap telemetry)"
  - "apps/server/test/d58c-direction-broadcast.integ.test.ts — 3 integ tests for server-broadcast contract"
  - "apps/client/test/e2e/cli-08-direction-convergence.spec.ts — 3 e2e tests for dual-tab convergence + snap-reconciliation"
affects: [06.4-11+ wave 4 staging UAT, Phase 7 PAR-* (server-authoritative facing now codified)]

# Tech tracking
tech-stack:
  added: []
  patterns:
    - "Server-authoritative single-field broadcast via @colyseus/schema additive @type — wire-compat per RESEARCH §Pitfall 2"
    - "Shared pure-fn module in @rebno/game-logic (sprite-state-machine.ts) — server + client import same impl"
    - "Reconciler-snap detection via window.__rebno.lastReconcileReason transition (preSnapAt → postSnapAt diff) — couples to existing position-snap telemetry without changing reconciler.ts contract"
    - "Wire-side zod enum (playerFacingSchema) for narrow-from-string at the @colyseus/schema boundary"

key-files:
  created:
    - packages/game-logic/src/sprite-state-machine.ts
    - packages/game-logic/test/sprite-state-machine.test.ts
    - apps/server/test/d58c-direction-broadcast.integ.test.ts
    - apps/client/test/e2e/cli-08-direction-convergence.spec.ts
  modified:
    - packages/game-logic/src/index.ts
    - packages/protocol/src/state.ts
    - packages/protocol/src/intents.ts
    - packages/protocol/test/state.test.ts
    - packages/protocol/test/intents.test.ts
    - apps/client/src/render/SpriteStateMachine.ts
    - apps/server/src/RebnoRoom.ts
    - apps/client/src/render/PlayerRenderer.ts
    - apps/client/src/scenes/GameScene.ts

key-decisions:
  - "RESEARCH default applied: server-broadcast facing (REQ-SRV-03 alignment). Operator staging-telemetry confirmation deferred to Wave 4 milestone gate; orchestrator (mode=yolo) authorized this plan to ship before operator UAT capture."
  - "Move deriveFacing to @rebno/game-logic instead of duplicating server-side — shared pure fn, same impl byte-identical on both sides. Client re-exports via SpriteStateMachine.ts to preserve all existing import sites."
  - "playerFacingSchema lives in packages/protocol/src/intents.ts as a standalone zod enum (NOT embedded in a playerSnapshotSchema — none exists; the Colyseus @type schema IS the wire surface for state snapshots). Used by GameScene to narrow the broadcast string into Direction before setRemoteFacing / setLocalFacing."
  - "Local-snap reconciliation uses ReconcileEngine's existing lastReconcileReason telemetry (window.__rebno.lastReconcileAt + lastReconcileReason) for snap-detection — NO change to reconciler.ts contract or adapter shape. Steady-state local rendering preserved."

patterns-established:
  - "Server-authoritative field broadcast via Colyseus additive @type — wire-compat with v3 clients (RESEARCH §Pitfall 2 + A2 holds, verified by colyseus-client.test.ts:131,141 + state.test.ts:11-12 passing UNCHANGED)."
  - "Local-renderer reconciliation contract for non-positional state — symmetric with position-snap convention from D-58 cycle-2 fix. Telemetry hook (window.__rebno.lastReconcilerSnap) lets e2e assert the snap-time reconciliation without harness-side reconciler API access."

requirements-completed: [REQ-CLI-04, REQ-SRV-03, REQ-CLI-08]

# Metrics
duration: 25min
completed: 2026-05-15
---

# Phase 06.4 Plan 10: D-58c Server-Broadcast Facing Summary

**D-58c fix — sprite-direction-state desync between local + remote viewers closed via server-authoritative `PlayerState.facing` broadcast + client trust on every remote snapshot + local-renderer reconciliation on reconciler-snap (checker WARNING 5).**

## Performance

- **Duration:** ~25 min (incl. workspace pnpm install + package rebuild in fresh worktree)
- **Started:** 2026-05-15T16:53:00Z
- **Completed:** 2026-05-15T17:02:00Z
- **Tasks:** 3 (sequential, atomic commits per task) + 1 operator checkpoint (deferred per executor scope)
- **Files created:** 4
- **Files modified:** 9

## Operator UAT Sign-Off Status

**DEFERRED to Wave 4 milestone gate (orchestrator mode=yolo authorization).**

Per executor objective: "Operator has NOT yet captured staging telemetry from spike plan 05 — orchestrator (mode=yolo) has authorized this plan to ship using RESEARCH default + locked CONTEXT decision per user choice. Document in SUMMARY: 'Operator staging-telemetry confirmation deferred to Wave 4 milestone gate; RESEARCH default applied.'"

The Plan 06.4-05 spike output classification table (06.4-05-SUMMARY.md "Spike Output Classification") has not been populated by operator UAT yet. The fix shipped here applies the RESEARCH §Pattern + REQ-SRV-03 alignment default. The Wave 4 staging UAT (per Plan 06.4-10 §Task 4 <how-to-verify>) will sign or reject the visual contract:

1. Two-Chrome-profile dual-tab walk-and-stop test for each cardinal + diagonal direction.
2. Read both viewers' `window.__rebno.localDirection` / `firstRemoteDirection` literals — must agree.
3. Network-throttle on profile 1 to force a divergence-threshold snap; confirm `window.__rebno.localDirection` reconciles to `window.__rebno.lastReconcilerSnap.serverFacing`.

If Wave 4 UAT rejects on a residual desync, the operator returns to Plan 06.4-10 for debug-and-amend (deriveFacing applied-side, s2c payload, or reconciler-snap wiring).

## Local-Facing Reconciliation Contract Sign-Off

**Implementation contract per checker WARNING 5 + CLAUDE.md Hard Rule 1:**

- Steady-state local-predicted facing: derived client-side via `SpriteStateMachine.deriveFrame` inside `PlayerRenderer.onSimulationTickLocal` (unchanged from pre-fix).
- On reconciler snap (divergence-threshold hard-snap path in `apps/client/src/prediction/reconciler.ts:66-93`): `GameScene.onLocalSnapshot` detects the snap via the existing `window.__rebno.lastReconcileReason` transition (`'divergence-threshold'` post-snap) and calls `PlayerRenderer.setLocalFacing(sid, server.facing)` to override the locally-derived value with the server-broadcast Direction. Symmetric with the position-snap convention from D-58 cycle-2 fix.
- Telemetry: `window.__rebno.lastReconcilerSnap = { serverFacing, t }` published at the snap callsite. The e2e Test 3 reads this and asserts `localDirection === lastReconcilerSnap.serverFacing` immediately post-snap.

Steady-state remote-player facing: server-broadcast value adopted on every `onRemoteSnapshot` via `setRemoteFacing` (no steady-state divergence possible — single source of truth).

## PROTOCOL_VERSION Literal Verification (A2 holds)

**No PROTOCOL_VERSION bump — additive optional @colyseus/schema field is wire-backward-compatible (RESEARCH §Pitfall 2 + Assumption A2).**

Verified:
- `packages/protocol/test/state.test.ts:11-12` — `expect(PROTOCOL_VERSION).toBe(3)` PASSES UNCHANGED.
- `apps/client/src/__test__/colyseus-client.test.ts:131,141` — `protocol_version: 3` literal in joinOrCreate handshake PASSES UNCHANGED.
- Full protocol test suite: 36/36 passed.
- Full client unit test suite: 205/205 passed (+ 4 pre-existing todo).

**A2-fails contingency NOT triggered.** Neither recovery path (a — PROTOCOL_VERSION bump + literal updates) nor (b — revert to client-derive-identically) was exercised.

## Accomplishments

- `PlayerState` (packages/protocol/src/state.ts) now carries `@type('string') facing: string = 'D'` — server-authoritative facing direction, additive wire-compatible. Eight valid values per the canonical Direction type: `D | DR | R | UR | U | UL | L | DL`.
- `deriveFacing` lifted from apps/client/src/render/SpriteStateMachine.ts into packages/game-logic/src/sprite-state-machine.ts (pure function — same input → same output; no Date.now / Math.random / I/O). Client re-exports preserve every existing import site (PlayerRenderer, scenes, tests). Server imports the same implementation for byte-identical derivation.
- `RebnoRoom.applyToColyseusState` writes `p.facing = deriveFacing(p.vx, p.vy, priorFacing)` on every per-tick state mutation. Existing `d58c_player_axes` pino event extended with the new `facing` field so operator dual-tab UAT can correlate broadcast value with client-published telemetry.
- `PlayerRenderer.setRemoteFacing(sid, facing)` — overrides per-renderer-entry facing for remote players on every snapshot; setup-time only — no steady-state divergence possible because deriveFrame's `lastFacing` input is now the server-broadcast value.
- `PlayerRenderer.setLocalFacing(sid, facing)` — overrides local-renderer facing on reconciler-snap only (per checker WARNING 5; symmetric with position-snap convention from D-58 cycle-2).
- `GameScene.onRemoteSnapshot` — narrows `player.facing` via `playerFacingSchema.safeParse` before calling `setRemoteFacing` (defense-in-depth against malformed broadcasts).
- `GameScene.onLocalSnapshot` — captures `pre-snap` reconcile-reason marker before `reconcile?.onServerSnapshot`; after the call, detects `'divergence-threshold'` transition and calls `setLocalFacing` with the server-broadcast value. Publishes `window.__rebno.lastReconcilerSnap` telemetry for e2e Test 3 assertion.
- New unit tests: 5 in @rebno/game-logic (deriveFacing semantics across all 8 octants + idle-preserve + sub-threshold) + 2 in @rebno/protocol (PlayerState.facing default + accept all values) + 4 in @rebno/protocol (playerFacingSchema validation — parse all 8 values + accept undefined + reject invalid strings + reject non-strings).
- New integ test: `apps/server/test/d58c-direction-broadcast.integ.test.ts` (3 tests covering walk-right → 'R' on both viewers / idle-preserve / walk-down → 'D').
- New e2e test: `apps/client/test/e2e/cli-08-direction-convergence.spec.ts` (3 tests: dual-tab walk-right convergence, idle-preserve, network-throttle reconciler-snap reconciliation).

## Task Commits

| Task | Commit  | Description                                                                 |
| ---- | ------- | --------------------------------------------------------------------------- |
| 1    | 7d79bd1 | Lift deriveFacing to @rebno/game-logic + add PlayerState.facing + zod enum + 11 unit tests |
| 2    | dd11a09 | Server writes p.facing = deriveFacing(...) every state mutation + 3 integ tests |
| 3    | ab49ba1 | Client setRemoteFacing/setLocalFacing wiring + reconciler-snap detection + 3 e2e tests |

## Files Created/Modified

**Created:**
- `packages/game-logic/src/sprite-state-machine.ts` — pure `deriveFacing` + `VELOCITY_THRESHOLD` + `Direction` type (lifted from client; verbatim impl, no logic change)
- `packages/game-logic/test/sprite-state-machine.test.ts` — 5 unit tests pinning the lifted semantics
- `apps/server/test/d58c-direction-broadcast.integ.test.ts` — 3 integ tests
- `apps/client/test/e2e/cli-08-direction-convergence.spec.ts` — 3 e2e tests

**Modified:**
- `packages/game-logic/src/index.ts` — re-export new sprite-state-machine surface
- `packages/protocol/src/state.ts` — add `@type('string') facing: string = 'D'` to PlayerState
- `packages/protocol/src/intents.ts` — add `playerFacingSchema` zod enum + type
- `packages/protocol/test/state.test.ts` — 2 new tests for PlayerState.facing
- `packages/protocol/test/intents.test.ts` — 4 new tests for playerFacingSchema
- `apps/client/src/render/SpriteStateMachine.ts` — replace local `deriveFacing` + `VELOCITY_THRESHOLD` + `Direction` with re-export from @rebno/game-logic
- `apps/server/src/RebnoRoom.ts` — import deriveFacing; write `p.facing` every applyToColyseusState tick; surface facing in d58c_player_axes pino event
- `apps/client/src/render/PlayerRenderer.ts` — add `setRemoteFacing` + `setLocalFacing` methods
- `apps/client/src/scenes/GameScene.ts` — extend PlayerSnapshotShape with `facing`; consume server-broadcast facing in `onRemoteSnapshot`; detect reconciler-snap in `onLocalSnapshot` + call `setLocalFacing` + publish telemetry

## Decisions Made

- **Apply RESEARCH default + locked CONTEXT decision (server-broadcast facing per REQ-SRV-03)** — operator staging-telemetry confirmation deferred to Wave 4 milestone gate per orchestrator (mode=yolo) authorization. The plan-shipping decision is documented at the top of this SUMMARY's "Operator UAT Sign-Off Status" section.
- **Lift `deriveFacing` to @rebno/game-logic (NOT duplicate server-side)** — same pure fn, byte-identical derivation on both sides; client re-exports preserve every existing import site.
- **No `playerSnapshotSchema` zod object exists in packages/protocol/src/intents.ts** (the Colyseus `@type` schema IS the wire surface for state snapshots). Added `playerFacingSchema` as a standalone optional zod enum for wire-side Direction validation; clients use `playerFacingSchema.safeParse(player.facing)` before passing to `setRemoteFacing` / `setLocalFacing` (defense-in-depth against malformed broadcasts).
- **Reconciler-snap detection via existing telemetry (not adapter API change)** — `GameScene.onLocalSnapshot` reads `window.__rebno.lastReconcileAt` pre/post `reconcile.onServerSnapshot` and asserts the `'divergence-threshold'` transition. This avoids any change to `ReconcileEngine` / `ReconcilerSpriteAdapter` contract.
- **`p.facing as Direction` cast in server (not zod-narrow on the hot path)** — server-side `p.facing` is set by `deriveFacing` itself; the prior value cast is safe because the only writer is the server's own pure fn. Cast → `'D'` default is the only fallback path (impossible after Task 2 ships).

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 3 — Blocking issue] Plan §Task 1 referenced a `playerSnapshotSchema` zod object that does not exist in packages/protocol/src/intents.ts**

- **Found during:** Task 1 read_first phase.
- **Issue:** Plan instructions to "add the optional facing field to the player snapshot zod schema in packages/protocol/src/intents.ts" presumed a `playerSnapshotSchema` / player-shape zod object exists in `intents.ts`. None does — the Colyseus `@type` schema in `state.ts` IS the wire surface for state snapshots; `intents.ts` only carries C2S intent schemas (cInputSchema, cAuthSchema, etc.) + `legacyLayoutSchema` / `newLayoutSchema` for room layouts.
- **Fix:** Added `playerFacingSchema` as a standalone optional `z.enum` in `intents.ts` (with traceability tags + comments documenting the Colyseus @type pairing). `GameScene` uses it to narrow the broadcast string into the `Direction` type before passing to `setRemoteFacing` / `setLocalFacing`. This achieves the spirit of the plan (wire-side zod validation of the Direction enum) without inventing a fictional schema.
- **Files modified:** `packages/protocol/src/intents.ts`, `packages/protocol/test/intents.test.ts` (+ 4 new tests for playerFacingSchema).
- **Commit:** 7d79bd1.

**2. [Rule 2 — Auto-add missing critical functionality] Per-tick `d58c_player_axes` pino event extended with `facing` field**

- **Found during:** Task 2 implementation.
- **Issue:** Plan 06.4-05 (D-58c spike) established the `d58c_player_axes` pino event for operator UAT correlation. With Plan 06.4-10 introducing server-broadcast `facing`, the operator needs to see the broadcast value alongside `axis_x_held` / `axis_y_held` to confirm convergence. Without this, the spike telemetry that the operator was instructed to read remains a strict subset of the new authoritative state.
- **Fix:** Added `facing: p.facing` to the existing log.info payload in `applyToColyseusState`. Pure telemetry surface extension — no behavior change. Operator dual-tab UAT can now correlate the broadcast value with client-side `window.__rebno.localDirection` / `firstRemoteDirection`.
- **Files modified:** `apps/server/src/RebnoRoom.ts` (single-field event payload extension).
- **Commit:** dd11a09.

### A2-Fails Contingency

**NOT TRIGGERED.** Both wire-compat literal tests passed UNCHANGED after the schema-add:
- `packages/protocol/test/state.test.ts:11-12` — `PROTOCOL_VERSION === 3` PASS.
- `apps/client/src/__test__/colyseus-client.test.ts:131,141` — `protocol_version: 3` literal in joinOrCreate args PASS.

No PROTOCOL_VERSION bump, no fallback to client-derive-identically. RESEARCH default applied cleanly.

## Issues Encountered

- **Worktree had no `node_modules`** — fresh worktree checkout. Ran `pnpm install --frozen-lockfile` + `pnpm -r --filter "./packages/**" build` to make workspace packages resolvable. Normal worktree bootstrap, not a code issue.
- **Pre-existing Windows-worktree `seat reservation expired` blocks all server integ tests** — documented in `deferred-items.md §item 1` (Plan 06.4-03 discovery). Affects every integ test identically including `d58c-direction-broadcast.integ.test.ts`. Test compiles cleanly + tsc passes; the integ contract will be re-verified in 06.4-08 W2→W3 staging smoke (per `deferred-items.md §item 1` recommendation). Out of scope per executor scope-boundary rule.
- **Pre-existing 2/4 CRLF failure in `apps/server/test/admin-stubs.test.ts`** — documented in `deferred-items.md §item 2`. CRLF-vs-LF comment-strip regex mismatch on Windows worktree checkout; passes on main (LF). NOT D-58c-related; no action taken.

## Verification

| Check                                                                       | Expected             | Actual                          |
| --------------------------------------------------------------------------- | -------------------- | ------------------------------- |
| `cd packages/game-logic && pnpm test --run` → 0 exit                        | all pass             | **55/55 pass** (incl. 5 new deriveFacing tests) |
| `cd packages/protocol && pnpm test --run` → 0 exit                          | all pass             | **36/36 pass** (incl. 6 new facing tests; literal `PROTOCOL_VERSION === 3` UNCHANGED) |
| `cd apps/client && pnpm test --run` → 0 exit                                | all pass             | **205/205 pass + 4 todo** (incl. `colyseus-client.test.ts:131,141` PROTOCOL_VERSION literal UNCHANGED) |
| `cd apps/client && pnpm typecheck`                                          | 0 exit               | **0 exit**                      |
| `cd apps/server && pnpm typecheck`                                          | 0 exit               | **0 exit**                      |
| `grep -c "facing" packages/protocol/src/state.ts`                           | ≥ 1                  | **4** (field declaration + 3 in inline doc comment) |
| `grep "PROTOCOL_VERSION = " packages/protocol/src/version.ts`               | unchanged (= 3)      | **`export const PROTOCOL_VERSION = 3 as const;`** (UNCHANGED) |
| `grep -c "setRemoteFacing" apps/client/src/render/PlayerRenderer.ts`        | ≥ 1                  | **2** (method definition + comment reference) |
| `grep -c "setRemoteFacing" apps/client/src/scenes/GameScene.ts`             | ≥ 1                  | **3** (call site + 2 doc references) |
| `grep -c "setLocalFacing" apps/client/src/render/PlayerRenderer.ts`         | ≥ 1                  | **1** (method definition)       |
| `grep -c "setLocalFacing" apps/client/src/scenes/GameScene.ts`              | ≥ 1                  | **3** (call site + 2 doc references) |
| Server integ `d58c-direction-broadcast` runs                                | local pass           | BLOCKED on pre-existing Windows-worktree `seat reservation expired` (deferred-items.md §item 1) — re-run on staging |
| Client e2e `cli-08-direction-convergence`                                   | runs on staging      | Wave 4 staging UAT — not run locally (no staging deploy in executor scope) |
| Operator UAT signed PASS                                                    | deferred to Wave 4   | **DEFERRED** per orchestrator mode=yolo authorization |

## User Setup Required

None — all changes are in-tree code + tests. No env vars, no external service config. Operator UAT at Wave 4 milestone gate (deploy + dual-tab walk-and-stop visual check + reconciler-snap throttle test).

## Next Phase Readiness

- D-58c implementation contract complete. Wave 4 staging UAT is the verification gate.
- No new blockers introduced.
- Plan 06.4-11+ ready to proceed (depends_on: [05] is satisfied; this plan's depends_on: [05] is also satisfied).
- A2-fails contingency NOT triggered — no PROTOCOL_VERSION coordination needed.

## Threat Flags

None — D-58c fix adds a single optional Colyseus @type field + a zod enum + a pure derive fn. No new network endpoint, no new auth path, no new file-access pattern, no new trust-boundary schema change beyond the (server-authoritative, defense-validated, optional, additive) `facing` field already specified in the plan.

## Self-Check

- [x] `packages/game-logic/src/sprite-state-machine.ts` — `deriveFacing` exported (`grep -l deriveFacing packages/game-logic/src/sprite-state-machine.ts` → match).
- [x] `packages/game-logic/test/sprite-state-machine.test.ts` — 5 tests pass (55/55 game-logic suite green).
- [x] `packages/protocol/src/state.ts` — `@type('string') facing: string = 'D'` present (4 matches; line ~33 declaration).
- [x] `packages/protocol/src/intents.ts` — `playerFacingSchema` exported (`grep -c "playerFacingSchema" packages/protocol/src/intents.ts` → ≥ 1).
- [x] `packages/protocol/src/version.ts` — `PROTOCOL_VERSION = 3` UNCHANGED (no bump).
- [x] `apps/server/src/RebnoRoom.ts` — `p.facing = deriveFacing(...)` present in applyToColyseusState (`grep -c "p\.facing =" apps/server/src/RebnoRoom.ts` → 1).
- [x] `apps/client/src/render/PlayerRenderer.ts` — `setRemoteFacing` method defined (2 matches); `setLocalFacing` method defined (1 match).
- [x] `apps/client/src/scenes/GameScene.ts` — `setRemoteFacing` call site present (3 matches); `setLocalFacing` call site present (3 matches).
- [x] `apps/server/test/d58c-direction-broadcast.integ.test.ts` exists with 3 integ tests.
- [x] `apps/client/test/e2e/cli-08-direction-convergence.spec.ts` exists with 3 e2e tests + canonical-ref citations on each assertion.
- [x] Commits 7d79bd1, dd11a09, ab49ba1 all exist on this branch (`git log --oneline | grep -E '7d79bd1|dd11a09|ab49ba1'` confirmed).
- [x] No file deletions in any of the three commits (verified via `git diff --diff-filter=D HEAD~3 HEAD` → empty).
- [x] Client typecheck + server typecheck + all unit tests pass (205+55+36 = 296 unit + protocol-literal-UNCHANGED + zero new failures).
- [x] PROTOCOL_VERSION literal tests pass UNCHANGED (A2 holds).

## Self-Check: PASSED

---
*Phase: 06.4-cycle-5-gap-closure*
*Completed: 2026-05-15*
