# Phase 06.4: cycle-5 gap-closure D-51c/D-55c/D-55d/D-58c/D-45d/D-60/D-61/D-62/D-63 (UAT 2026-05-14 + discuss-phase mid-UAT 2026-05-15) - Context

**Gathered:** 2026-05-15
**Status:** Ready for planning
**Derived from:** /gsd-discuss-phase 06.4 (4 selected gray areas) + mid-discuss operator re-UAT of 06.3 cycle-2 carry-ins on staging build `91f954e`. Builds on 06.3-CONTEXT.md, 06.3-VERIFICATION.md (verdict: NOT APPROVED), cycle-5 operator findings 2026-05-14, and discuss-phase UAT 2026-05-15.

<domain>
## Phase Boundary

Close the cycle-5 UAT findings from staging `91f954e`. Four shipped-in-cycle-2 fixes were retested live during discuss-phase: D-57b + D-58b PASS (drop from scope, add regression tests), D-51b + D-55b FAIL with NEW symptoms (re-opened as D-51c + D-55d). Combined with D-55c (origin math), D-60 (floor-tile cookie-resume gap), D-61 (wall_border drop), D-62 (mvp-room collision/size derivation), D-63 (permanent origin-convention mitigation), and two new findings (D-58c direction desync, D-45d nametag flicker carry-in), this phase ships the cycle-5 closure.

**In scope (final cycle-5 set):**

- **D-51c** — replace-prior-session eviction STILL broken on staging build `91f954e`. NEW symptom (discuss-phase UAT 2026-05-15): BOTH tabs freeze + show looping "reconnected" banner ~5s cadence. Worse than cycle-4's "freeze + disconnect" symptom — the cycle-2 fix (`ea68ecc`) appears to have introduced a reconnect-retry loop on top of the original eviction failure. Spike-first; possible scope escalation to Colyseus 0.17→0.18 surgery (per 06.3 caveat).

- **D-55c** — TeleIn / TeleOut Phaser origin misalignment with restored Navi sprite. Operator UAT 2026-05-14 confirms the teleport sprite renders far too RIGHT (more than half a Navi width) and slightly too LOW vs the restored Navi sprite. Root cause: `PlayerRenderer.ts:611-613` inline origin math `originX = legacy_originX / width` (= 15/64 = 0.234) ignores the Phaser-vs-legacy Navi origin convention shift. Legacy Navi (NaviStandD) is `(0, 0)` top-left, our Phaser Navi is `(0.5, 1)` bottom-center; the legacy effect spec places TeleIn at legacy (x, y) = (Navi_left_edge, Navi_top), but our sprite.x is Navi center + sprite.y is Navi feet. Y-axis: current `(legacy_originY + 48) / height` aligns to the 48 px sprite-rect-bottom; should align to NaviStandD bbox-bottom = 46 (visible feet line). Fix lands by replacing the inline math with the D-63 helper call.

- **D-55d (NEW — discuss-phase UAT 2026-05-15)** — TeleIn fires for EVERY remote player already present in the room on local client session start. Should fire only on FRESH joins (self spawn + remote player who joins during this session), not on the snapshot of pre-existing players the new client receives at onJoin. Already-present remotes must appear `Stand*` (no spawn anim). Direct fix; gate trigger on a "fresh-join during this session" predicate, not "first time the local client sees this player".

- **D-58c (NEW — discuss-phase UAT 2026-05-15)** — sprite *direction* state desync between local + remote. Local shows `StandingR`, remote shows `StandingUR` for the same player. Position reconciler (06.3 D-58 fix) converges (x, y) but not facing/direction. Spike: confirm direction propagation path (input → server state → s2c → SpriteStateMachine) and identify divergence point.

- **D-45d (NEW carry-in — discuss-phase UAT 2026-05-15)** — nametag still flickers vertically + intersects on REMOTE moving players, despite 06.3 Plan A (offset 16→1) shipped + 06.3 Plan B (flicker ring-buffer spike) executed. Root cause unresolved by 06.3 cycle-2/3 work. Spike + fix: re-examine the `firstRemoteNameplateHistory` ring buffer outputs from 06.3 staging build, identify residual flicker contributor.

- **D-60** — floor tiles only render after explicit logout-and-fresh-login. Working hypothesis: cookie-resume `onJoin` path doesn't trigger `sendRoomLayoutToClient` (server thinks client already received it on prior session). DIRECT FIX (no spike): always emit `room_layout` from `onJoin` regardless of session-resume vs fresh-auth path; idempotent on the client side.

- **D-61** — legacy `wall_border` block in `mvp-room/000.json:1970` still emits + renders gray border tiles, superseded by walkable-grid. Drop client render (`RoomRenderer.ts:273` block) AND drop server-side `wall_border` from the layout JSON. Schema keeps `wall_border` optional for legacy room compat (Phase 7 may reuse).

- **D-62** — server-side `mvp-room/000.json` missing `collision_polys` AND `room_size`, so `step()` falls into a no-op pass-through that lets the player drift multiple tiles past visual floor on reconciler snap. FIX: **server-runtime derivation from `tiles[]`** (operator-confirmed approach). New helper `deriveLayoutBounds(layout) → {collision_polys, room_size}` reads `tiles[]` + `tile_w/tile_h` + `width_tiles/height_tiles`, called on layout load in `RebnoRoom.onCreate` / registry subscribe. Server `step()` consumes derived polys. Broadcast in `s2c.room_layout` so client `RoomCollision` trusts server-authoritative payload (no client re-derivation drift). `mvp-room/000.json` stays as-is (no hand-authored polys). Schema keeps both optional. **Phase 7 deferred:** compile-time codification + mixed-walkable/abyss handling (when more rooms ship with non-uniform floors).

- **D-63** — permanent mitigation for the origin-convention footgun. Two parts:
  - **(a)** New file `apps/client/src/render/legacy-origin.ts` exports `phaserOriginForLegacyPlayerAttached({legacyOriginX, legacyOriginY, width, height}) → [originX, originY]` (object-in, tuple-out, matches roadmap signature; tuple destructures cleanly into `setOrigin(...)`). Exports hardcoded `NAVI_WIDTH_PX = 36` and `NAVI_VISIBLE_FEET_Y = 46` with source-cite comments to `extracted/client-5-8/sprites/0000-NaviStandD/meta.json`.
  - **(b)** Unit test in `legacy-origin.test.ts` loads `apps/client/public/atlas-mvp.json` and asserts NaviStandD frame width === 36 + bbox-bottom === 46 (drift detection on atlas regeneration).
  - **(c)** `CLAUDE.md` adds a new "Coordinate conventions" section pinning `legacy_x = phaser_x - NAVI_WIDTH_PX/2` (= 18) and `legacy_y = phaser_y - NAVI_VISIBLE_FEET_Y` (= 46, NaviStandD bbox-bottom — NOT 48 sprite-rect-bottom) + referencing the helper as the canonical entry for future player-attached effect ports.
  - **(d)** Refactor scope: ONLY `startTeleportAnim` (PlayerRenderer.ts:611-613) in 06.4. D-55c fixed BY replacing inline math with the helper call. Future ports (HexportIn/Out, ncol*, jokershell, watching) go through the helper when they land in Phase 7.

- **CLI-08 milestone mp4 capture** — still owed; carry-on-PASS again per 06.3 pattern.

**Out of scope (PASS — drop from cycle-5 fix scope; add regression tests):**

- **D-57b** — PASS (commit `432e0f8` reconciliation tween revert, no micro-freeze).
- **D-58b** — PASS (commit `432e0f8` idle dampening revert, position converges in idle). NOTE: position converges; *direction* doesn't (= new D-58c, in scope).
- **D-55b** — anim swap PASS (TeleIn/TeleOut sprite-sheet anim, not JoinIn fall-recovery). Trigger-gating bug (= D-55d, in scope).
- **D-51b** — FAIL → re-open as **D-51c** (in scope, new symptom).

**Out of scope (defer to Phase 7 / future):**

- Colyseus 0.17 → 0.18 upgrade — if D-51c spike points to seat-reservation-timeout fix needing the bump, escalate to its own phase per 06.3 caveat.
- BitmapText / sprite-font glyphs (durable D-45 alternative).
- Compile-time `collision_polys` codification in asset pipeline.
- Mixed-walkable / abyss / fall-tile room handling.
- All Phase 06.1/06.2/06.3 deferred carry-ins still defer (Hexport, JokerShell, multi-floor depth, etc.).
- `persistCharacter` FK failures (Phase 7).
- Prod provisioning (Phase 8).

</domain>

<decisions>
## Implementation Decisions

### D-51c — replace-prior-session eviction NEW symptom (spike-first)

**Approach:** Spike-first. Cycle-2 fix (`ea68ecc`) was supposed to address D-51b but UAT 2026-05-15 reveals a worse symptom — both tabs freeze + looping "reconnected" banner ~5s cadence. Reconnect-retry loop layered on top of the original eviction failure.

**Spike scope:**

- **Server pino log:** every `s2c.send` + `broadcast` + `client.leave(code)` inside the eviction window with code/recipient/sessionId/event-type timestamps. Add `roomId` + `prior_session_count` to onJoin start/end + every eviction-loop iteration. Catch seat-race (Colyseus 0.17 default seat-reservation timeout ≈ 2s).
- **Client console:** every Colyseus event (`onLeave`, `onError`, reconnect-state-machine transition) — `window.__rebno.lastEvictionEvents = [...]` ring buffer (last 20).
- **Reconnect-banner trigger trace:** identify what triggers the "reconnected" banner re-show — likely client-side reconnect-state-machine looping on a transient error code; pin the loop entry point.

**Single-cycle commit:** spike + fix in 06.4. CAVEAT: if spike points to Colyseus 0.17 grace-period interaction requiring a version bump, escalate to its own phase (do NOT bundle into 06.4).

### D-55c — TeleIn / TeleOut origin via D-63 helper

**Approach:** Replace inline origin math at `PlayerRenderer.ts:611-613` with `phaserOriginForLegacyPlayerAttached(...)` helper call from the new `legacy-origin.ts` module. Acceptance: D-55c verified PASS by visual side-by-side legacy screenshot — pixel-perfect alignment requires visual confirmation, not just algebra.

### D-55d — TeleIn trigger gating (direct fix)

**Approach:** Gate TeleIn trigger predicate on "fresh-join during this session", not "first time we see this player".

- **Self spawn:** TeleIn fires (GameScene.onLocalAdd path) — UNCHANGED.
- **Remote already-present at local session start:** TeleIn MUST NOT fire. New client receives existing players in the initial state snapshot; these players should appear in their `Stand*` state.
- **Remote joining during this session:** TeleIn fires (GameScene.onRemoteAdd path triggered by a join AFTER local client is established).

**Implementation candidate:** track a `localClientJoinedAt` timestamp; on `onRemoteAdd`, compare against the remote's `joinedAt` (or use a "first batch" flag set during initial state sync). The initial state-sync batch suppresses anim; subsequent remote-add events fire it.

**Regression test:** unit test for SpriteStateMachine + PlayerRenderer covering initial-snapshot-with-N-remotes path (no TeleIn anim) vs subsequent join (TeleIn anim).

### D-58c — direction desync (spike + fix)

**Approach:** Spike-first; thin scope.

- **Spike output:** `window.__rebno.localDirection` + `window.__rebno.firstRemoteDirection` literals; pino-log of server-side direction field on each player state mutation. Operator triggers desync (walk + stop) and reads both literals. Identify whether divergence is at (a) server doesn't update direction on stop, (b) s2c snapshot drops direction, (c) client reconciler discards remote direction, or (d) SpriteStateMachine derives direction differently for local-predicted vs remote-applied states.
- **Fix:** conditional on spike. Likely a missing direction-field broadcast or a client-side reconciler skip when `vx===0 && vy===0` (same family as 06.3 D-58 idle-convergence bug, applied to direction this time).

### D-45d — nametag flicker carry-in (spike + fix)

**Approach:** Re-examine 06.3 Plan B ring-buffer outputs from staging build `91f954e`. If the buffer wasn't populated (env-gated, dropped, or wrong scope), republish it unconditionally. Compare local-player nameplate vs first-remote nameplate during motion.

- **Hypotheses re-prioritized after 06.3 work:**
  - Sub-pixel Y jitter from prediction interpolation (Phaser Text snaps to integer Y on re-render but the `sprite.y` driver carries fractional values).
  - `Phaser.GameObjects.Text` re-render at fractional Y triggers internal text-cache invalidation visible as flicker.
  - Nameplate depth/sort interferes with sprite depth during motion frame swap.
- **Likely fix:** `nameplate.setPosition(Math.round(x), Math.round(y))` instead of fractional — strip sub-pixel jitter. Apply on every `follow()` call. Trade fractional-position accuracy for stable rendering.

### D-60 — always emit room_layout on onJoin (direct fix)

**Approach:** In `RebnoRoom.onJoin`, ALWAYS call `sendRoomLayoutToClient(client)` regardless of session-resume vs fresh-auth path. Idempotent: client `RoomRenderer` re-applies layout cheaply (single broadcast per session join). Operator confirms via cookie-resume UAT scenario on staging.

### D-61 — wall_border drop (data + render)

**Approach:** Two-part drop.

- **Data:** remove `wall_border` block from `apps/server/rooms/mvp-room/000.json:1970-`. Manifest signature regenerates.
- **Render:** drop the `wall_border`-handling block at `apps/client/src/render/RoomRenderer.ts:273`. Walkable-grid (from D-62 derivation) supersedes.
- **Schema:** `wall_border` stays optional in `newLayoutSchema` (`packages/protocol/src/intents.ts`) — Phase 7 rooms may reuse.

### D-62 — server-runtime collision_polys + room_size derivation

**Approach:** Server-side runtime derivation, server-authoritative broadcast.

- **New helper:** `apps/server/src/layout-derive.ts` exports `deriveLayoutBounds(layout) → {collision_polys, room_size}`. Reads `tiles[]` + `tile_w / tile_h` + `width_tiles / height_tiles`. For MVP rooms (uniform floor): `room_size = {w: width_tiles * tile_w, h: height_tiles * tile_h}`; `collision_polys = [outer rectangle boundary]`.
- **Wiring:** call on layout load in `RebnoRoom.onCreate` / registry subscribe. Server `step()` consumes derived polys (no schema change needed — derived fields populate the existing in-memory layout record). `broadcastRoomLayout` emits derived `collision_polys` + `room_size` in the `s2c.room_layout` payload.
- **Client:** `RoomCollision.fromLayout()` consumes server-broadcast `collision_polys` directly (no client re-derivation). Server-authoritative — eliminates drift.
- **Schema:** newLayoutSchema keeps `collision_polys` + `room_size` OPTIONAL (since they're now derived, not authored). No PROTOCOL_VERSION bump needed (broadcast shape adds derived fields the client already tolerates from legacyLayoutSchema).
- **Regression test:** unit test for `deriveLayoutBounds` with mvp-room fixture; integration test that step() respects derived polys (player input pushing past floor edge blocks at the derived boundary).

### D-63 — permanent origin-convention mitigation

**Approach:** Four-part shipped together.

- **(a) Helper module** — new file `apps/client/src/render/legacy-origin.ts`:
  ```ts
  export const NAVI_WIDTH_PX = 36;  // extracted/client-5-8/sprites/0000-NaviStandD/meta.json
  export const NAVI_VISIBLE_FEET_Y = 46;  // NaviStandD bbox-bottom (NOT 48 sprite-rect)
  export function phaserOriginForLegacyPlayerAttached(
    spec: { legacyOriginX: number; legacyOriginY: number; width: number; height: number }
  ): [originX: number, originY: number] { ... }
  ```
  Math: `originX = (legacy_originX + NAVI_WIDTH_PX / 2) / width`; `originY = (legacy_originY + NAVI_VISIBLE_FEET_Y) / height`.
- **(b) Drift-detection unit test** — `legacy-origin.test.ts` loads `apps/client/public/atlas-mvp.json` + asserts NaviStandD frame width === 36 + bbox-bottom === 46. Fails CI if atlas regenerates with different Navi metrics.
- **(c) CLAUDE.md doc** — new "Coordinate conventions" section. Pins the convention shift: `legacy_x = phaser_x - 18`, `legacy_y = phaser_y - 46`. References the helper as canonical entry for player-attached effect ports. Notes: 46 = NaviStandD bbox-bottom (visible feet), NOT 48 sprite-rect-bottom.
- **(d) Refactor consumer** — `startTeleportAnim` (PlayerRenderer.ts:611-613) replaces inline math with helper call. D-55c fixed by this refactor + visual UAT confirmation.

### Regression tests for cycle-2 PASS items (anti-revert)

- **D-57b regression:** unit test in `reconciler.test.ts` asserts the tween path is NOT used (or use a flag check) — prevents future reintroduction of the cycle-2 reconciliation-tween-jitter pattern. Or, an e2e Playwright test that walks for 30s and asserts no >100ms frame gaps.
- **D-58b regression:** unit test for the reconciler's idle-state convergence path with `vx===0 && vy===0` (position converges within N ticks of a remote-position update). Locks the fix in.

### GREEN gate uplift — carries forward from 06.3 (CROSS-CUTTING)

All four 06.3 gates remain in force:

- **HARD gate 1:** All `cli-08-*` + `camera-follow` Playwright suites GREEN on staging pre-merge.
- **HARD gate 2:** Operator visual per-fix UAT at every Wave-2 fix boundary.
- **HARD gate 3:** E2e assertion-correctness review — each Playwright assertion's expected value cites canonical ref.
- **SOFT gate 4:** Staging redeploy + 5-test smoke at Wave-2 → Wave-3 boundary.
- **NEW gate 5 (this cycle):** Helper-API audit. The plan-checker / code-reviewer rejects any new `setOrigin(\d+ / variant\.width, ...)` inline math in `apps/client/src/render/`. Forces future ports through the D-63 helper.

### Claude's Discretion

- D-63 helper home: `apps/client/src/render/legacy-origin.ts` (delegated; render-only concern, packages/* promotion premature, PlayerRenderer.ts already large).
- D-63 constants source: hardcoded + atlas drift test (delegated; runtime simple, CI drift catch).
- D-63 helper signature: object-in, tuple-out per roadmap (delegated; one consumer, tuple destructures into `setOrigin(...)`).
- D-63 refactor scope: only `startTeleportAnim` in 06.4 (user-confirmed).

</decisions>

<canonical_refs>
## Canonical References

**Downstream agents MUST read these before planning or implementing.**

### Phase carry-in
- `.planning/phases/06.3-cycle-4-gap-closure-d-40-d-45-d-51-d-53-d-54-d-55-d-56-d-57-/06.3-CONTEXT.md` — 06.3 decisions (telemetry pattern, replace-prior-session policy, GREEN gate uplift, e2e canonical-ref discipline). Many patterns carry forward verbatim.
- `.planning/phases/06.3-cycle-4-gap-closure-d-40-d-45-d-51-d-53-d-54-d-55-d-56-d-57-/06.3-VERIFICATION.md` — cycle-4 verdict + per-finding closure status. Re-check Plan B (D-45 flicker) ring-buffer outputs.
- `.planning/phases/06.2-gap-closure-d-50-d-53-uat-2026-05-13/06.2-CONTEXT.md` — replace-prior-session shape + window.__rebno telemetry baselines.

### Server (D-51c, D-60, D-62)
- `apps/server/src/RebnoRoom.ts:107-200` — onCreate / onJoin top-level, room state schema.
- `apps/server/src/RebnoRoom.ts:131-141` — `mvpRoomLayout` hardcoded default (NOTE: currently unused by step() — D-62 derivation replaces it).
- `apps/server/src/RebnoRoom.ts:214-222` — `broadcastRoomLayout()` (D-60 + D-62 broadcast surface).
- `apps/server/src/RebnoRoom.ts:240-275` — `sendRoomLayoutToClient` (D-60 always-emit target; pino log already in place).
- `apps/server/src/RebnoRoom.ts:409-495` — replace-prior-session eviction block (D-51c spike scope).
- `apps/server/src/RebnoRoom.ts:827` — `step()` consuming layout (D-62 must derive collision_polys before step() is called).
- `apps/server/src/RoomRegistry.ts` — room registration; D-62 derivation wires here.
- `apps/server/rooms/mvp-room/000.json` — missing collision_polys + room_size. Line 1970 = `wall_border` block (D-61 drop target).
- `apps/server/rooms/mvp-lobby/000.json` — REFERENCE shape that HAS collision_polys (line 3) + room_size (line 12).

### Protocol (D-61, D-62 broadcast shape)
- `packages/protocol/src/intents.ts:115-153` — legacyLayoutSchema (has collision_polys; reference shape).
- `packages/protocol/src/intents.ts:155-200` — newLayoutSchema (collision_polys + room_size stay OPTIONAL post-D-62). `wall_border` stays optional for Phase 7 legacy compat.
- `packages/protocol/src/version.ts` — PROTOCOL_VERSION. **Bump checklist (carry from 06.2-09):** if D-51c fix bumps PROTOCOL_VERSION, MUST update (1) `packages/protocol/test/state.test.ts:11-12` and (2) `apps/client/src/__test__/colyseus-client.test.ts:131,141`. NOTE: D-62 derivation does NOT require a bump (broadcast shape adds optional fields).

### Client render (D-55c, D-55d, D-58c, D-45d, D-61, D-62, D-63)
- `apps/client/src/render/PlayerRenderer.ts:506-516` — TeleIn/TeleOut variant metrics + frame counts.
- `apps/client/src/render/PlayerRenderer.ts:538-630` — `startTeleportAnim` (D-55c refactor target — replace inline origin math at 611-613 with helper call).
- `apps/client/src/render/PlayerRenderer.ts:517-526` — `playTeleportIn` + `playTeleportOut` entry points (D-55d trigger gating target).
- `apps/client/src/render/RoomRenderer.ts:273` — wall_border render block (D-61 drop).
- `apps/client/src/render/RoomCollision.ts:23-60` — walkable grid derivation (D-62 consumes server-broadcast collision_polys; no local re-derivation).
- `apps/client/src/render/Nameplate.ts:42,98-100` — NAMETAG_OFFSET_Y + `follow()` formula (D-45d target; integer-Y snap candidate).
- `apps/client/src/render/SpriteStateMachine.ts` — direction state derivation (D-58c spike target).
- `apps/client/src/scenes/GameScene.ts` — `onLocalAdd`, `onRemoteAdd` triggering TeleIn (D-55d gating logic).
- `apps/client/public/atlas-mvp.json` — NaviStandD frame metadata (D-63 constants source + drift-test fixture).
- `apps/client/src/render/legacy-origin.ts` — NEW FILE (D-63 helper).

### Legacy / extracted (read-only spec sources)
- `extracted/client-5-8/sprites/0000-NaviStandD/meta.json` — Navi width = 36, height = 48, bbox-bottom = 46. D-63 constants cite this.
- `extracted/client-5-8/objects/0042-player/events/Draw.dnd.json` — legacy player draw + depth_set (D-45d depth z-order if hypothesis flips).
- `extracted/client-5-8/objects/0042-player/events/Create.gml` — legacy spawn-in semantics (D-55d gating predicate reference).

### Project / constants
- `CLAUDE.md` — extracted constants (tile 44×40, view 640×480, tick 30 Hz). **D-63 adds new "Coordinate conventions" section** pinning `legacy_x = phaser_x - 18`, `legacy_y = phaser_y - 46`.
- `decomp/wiki/00-overview.md` — extraction conventions reference.

### External-system gotchas (carry from 06.3)
- Worktree merge cwd drift: `cd` to repo root BEFORE `git merge $WT_BRANCH`.
- Staging deploy: `vite build` FIRST, re-copy atlas AFTER (vite wipes public/).
- CI rebuilds dist/ from source; local stale dist pass ≠ CI pass.
- `__rebno` test-infra hooks MUST be unconditional (no env gate).
- CSP: `img-src` needs `blob:` for Phaser atlas PNG.

</canonical_refs>

<code_context>
## Existing Code Insights

### Reusable Assets
- **`window.__rebno` telemetry pattern** (06.2-01..03) — publish diagnostic state as `window.__rebno.fieldName` for operator + Playwright. Unconditional hooks (env-gate burned us in cycle-3 RC1).
- **Replace-prior-session policy** (`RebnoRoom.ts:409-495`) — D-51c spike investigates WHY the policy still produces both-tabs-freeze + reconnect-loop despite cycle-2 fix.
- **`force_reset` s2c event** (`{type: 'force_reset', reason: 'replaced_by_new_session'}`) — client banner DOM overlay reuse. D-51c spike checks whether this fires correctly.
- **SpriteStateMachine extension pattern** (06.1-04) — fractional sub-tick frame advance + state transition table. D-55d gating extends here.
- **Playwright multi-tab fixture** (06.3 D-51 spike) — two browser CONTEXTS (different Chrome profiles) for D-51c repro.
- **TeleIn/TeleOut sprite-sheet anim** (06.3 D-55b shipped) — variant config at `PlayerRenderer.ts:506-516`. D-55c reuses; D-55d gates trigger only.
- **`broadcastRoomLayout` pino log** (`RebnoRoom.ts:240-275`) — `d40_layout_broadcast` event already emits tilesLength, widthTiles, heightTiles, hasCollisionPolys, wallBorder. D-62 derivation surfaces here once it lands.

### Established Patterns
- **e2e canonical-ref discipline (06.3)** — every Playwright assertion's expected value cites source (legacy GML / CLAUDE.md / SPEC doc / operator measurement). Plan checklist enforces.
- **Server-authoritative state** (REQ-SRV-03) — D-62 follows; server derives + broadcasts collision_polys, client trusts.
- **Atomic per-plan commits** (project-wide) — each plan = one commit, REQ-ID cites.

### Integration Points
- `RebnoRoom.onCreate` → registry.subscribe → `deriveLayoutBounds` → in-memory layout record → `step()` + `broadcastRoomLayout` (D-62).
- `RebnoRoom.onJoin` → `sendRoomLayoutToClient(client)` UNCONDITIONALLY (D-60).
- `RebnoRoom.onJoin` → eviction block (D-51c spike telemetry inserts here).
- `GameScene.onRemoteAdd` → gate predicate → `PlayerRenderer.playTeleportIn` (D-55d).
- `PlayerRenderer.startTeleportAnim` → `phaserOriginForLegacyPlayerAttached(...)` helper (D-55c via D-63).
- `RoomCollision.fromLayout` → consumes server `collision_polys` directly (D-62 cleans up any local re-derivation).
- `Nameplate.follow` → integer-snap on x/y (D-45d candidate fix).
- `SpriteStateMachine` → direction-state mutation path (D-58c spike target).

</code_context>

<specifics>
## Specific Ideas

- **Origin convention canonical doc:** `CLAUDE.md` "Coordinate conventions" section is the single authoritative reference. D-63 helper code links back via comment cite.
- **Drift-detection unit tests** (D-63 atlas-match; D-62 derive-helper fixture) > runtime atlas reads — cheaper, deterministic, CI-fail-loud.
- **Direction desync hypothesis preference (D-58c):** check (d) SpriteStateMachine direction derivation first (cheapest spike), then (c) reconciler skip on idle, then (b) s2c payload, then (a) server-side state update. Fan-out from cheapest to most expensive.
- **Nametag integer-snap candidate (D-45d):** if `Math.round` on x/y inside `Nameplate.follow()` kills the flicker, that's the shipping fix — defer BitmapText refactor to Phase 7.

</specifics>

<deferred>
## Deferred Ideas

- **Colyseus 0.17 → 0.18 upgrade** — escalate to own phase if D-51c spike points here.
- **Compile-time `collision_polys` codification** in asset pipeline (build-step writes derived polys into room JSON, manifest-signed). Phase 7 with AST-* asset-pipeline work; landed at Phase 7 because legacy rooms also need mixed-walkable/abyss handling at the same time.
- **Mixed-walkable / abyss / fall-tile room support** — `tiles[]` alone insufficient; needs richer source (legacy room metadata + per-tile collision attribution). Phase 7.
- **Compile-time `room_size` codification** — bundled with collision_polys codification.
- **BitmapText / sprite-font glyphs** — durable D-45 alternative (removes Phaser Text font-metric padding + WOFF2 font-load race + sub-pixel flicker). Larger refactor; Phase 7.
- **Helper expansion** — D-63 helper currently covers `phaserOriginForLegacyPlayerAttached` for the player-attached case. HexportIn/Out, ncol*, jokershell, ChtCmd*-driven transitions, watching/spectator overlay all need it when ported. Add per-effect at Phase 7.
- **Eslint rule blocking inline (originX, originY) math** in render/ — too brittle for 06.4; revisit if Phase 7 sees recurrence.
- **Phase 7 carry-forward (cumulative):** falls/abyss, ice/movement tiles, Hexport, JokerShell, multi-floor depth, depth-registry consolidation, all rooms beyond mvp-lobby + mvp-room, `persistCharacter` FK failures, prod provisioning, full chat surface, modernized admin UI, `.bnu` migration, MIDI→OGG, BMP fonts, content-hash manifest.

</deferred>

<traceability>
## Requirements Touched

- **REQ-CLI-04** — animation contract (D-55c origin math, D-55d trigger gating, D-58c direction reconciliation, D-45d nameplate stability).
- **REQ-CLI-06** — renderer / atlas (D-61 wall_border drop, D-62 collision derivation client-side trust).
- **REQ-CLI-07** — UI rendering (D-45d nameplate; D-60 floor render).
- **REQ-CLI-08** — CLI-08 hard milestone re-gate; mp4 capture this cycle.
- **REQ-SRV-03** — authoritative server state (D-51c evict, D-62 server-derived collision_polys broadcast).
- **REQ-SRV-14** — per-entity sim contract (D-62 step() boundary enforcement).

</traceability>

---

*06.4-CONTEXT.md written from cycle-5 UAT verdict (2026-05-14 NOT APPROVED) + 4 selected gray-area decisions (D-63 helper API, D-62 derivation strategy, D-60 spike-vs-direct, re-confirm workflow) + mid-discuss-phase operator re-UAT (2026-05-15) of 4 cycle-2 carry-ins surfacing 3 new defects (D-51c, D-55d, D-58c, D-45d). Resume in a fresh session via `/gsd-plan-phase 06.4` against this CONTEXT.*
