# 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 + regression tests) - Research

**Researched:** 2026-05-15
**Domain:** Colyseus 0.17 server-authority + Phaser 3.90 client render + room-layout protocol + reconciler family
**Confidence:** HIGH (all findings verified via direct codebase inspection; no external lib version assumptions)

## Summary

This is a follow-on cycle to 06.3. All implementation decisions are LOCKED in CONTEXT.md — the planner must NOT re-litigate D-62 timing (runtime), D-60 strategy (direct fix), D-63 helper location/signature/scope, D-55c PASS gate (visual UAT), D-55d gating predicate (fresh-join-during-this-session), or D-51c/D-58c/D-45d spike-first ordering. Research scope is **how to plan each task**, not **what to ship**.

Verified by codebase grep + read:
- Nameplate already integer-snaps in `Nameplate.follow()` (`Math.round` on `textY`, line 125). D-45d carry-in implies the cycle-3 fix **shipped but did not fully resolve flicker**. The likely residual contributor is the **0.35-factor remote-sprite Y lerp in `PlayerRenderer.onSimulationTickRemote` (line 357-360)** which produces fractional `smoothedY` that oscillates the rounded `textY` across an integer boundary at sub-pixel motion rates.
- `firstRemoteNameplateHistory` ring buffer is already unconditional in `PlayerRenderer.ts:375-393` — D-45d's "republish the buffer" is unnecessary; the buffer exists. The plan must instead **capture it under fresh staging UAT motion and read it**.
- `newLayoutSchema` (intents.ts:158-196) does **not** include `collision_polys` or `room_size`. D-62 broadcast either (a) extends the schema with optional fields, or (b) emits `collision_polys` outside the validated layout shape. Since both client + server must trust the same schema, **option (a) — extending newLayoutSchema with both as optional — is the right path**; the planner must confirm no PROTOCOL_VERSION bump is required (optional-field additions are wire-compatible).
- **D-58c root cause discovered:** facing direction is NOT in PlayerState. It is derived **client-side** by `deriveFacing(vx, vy, lastFacing)` in `SpriteStateMachine.ts:112-140`. `lastFacing` is per-PlayerRenderer-entry per-client state. Local + remote viewers diverge because their `lastFacing` histories are independent. On zero-velocity, `deriveFacing` preserves `lastFacing` unchanged — so the player who last moved 'R' on tab A but whose remote-viewer-tab last saw 'UR' will see permanent desync. The cheapest fix candidate is **server-broadcast facing field** (locks server-authoritative per REQ-SRV-03); the alternative ("derive facing identically on both sides") still requires identical input histories which is not guaranteed.
- D-51c reconnect-banner-loop symptom is NEW vs cycle-2 ("freeze + disconnect"); the cycle-2 fix (commit `ea68ecc`) added `priorClient.leave(4001)` but the looping banner implies the **client-side `force_reset` handler is firing a reconnect attempt instead of a hard-disconnect**. The reconnect-state-machine in `colyseus-client.ts` is the spike entry point.
- Atlas drift test for D-63 needs only `frames["0000-NaviStandD_000"].frame.w === 36` and `.h === 48` — bbox-bottom IS NOT IN the atlas JSON (only in `extracted/.../meta.json`). The drift test asserts the atlas frame dimensions; the `NAVI_VISIBLE_FEET_Y = 46` constant remains cited-from-meta-but-unverified-by-atlas-test. **A second drift test against `extracted/client-5-8/sprites/0000-NaviStandD/meta.json:bboxBottom === 46` should be added.**

**Primary recommendation:** Wave 1 ships the foundational + spike-instrumentation tasks in parallel. Wave 2 lands the helper-driven origin fix and server-runtime derivation. Wave 3 lands the spike-driven fixes (D-51c, D-58c, D-45d, D-55d) after operator captures repro telemetry on staging. Wave 4 verifies + captures CLI-08 mp4.

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Replace-prior-session eviction (D-51c) | API / Backend (RebnoRoom.onJoin) | Client (force_reset handler, reconnect SM) | Server-authoritative (Hard Rule 1). Client only honors the s2c event + close code. |
| Teleport-anim origin math (D-55c) | Browser / Client (PlayerRenderer) | — | Phaser-side render concern; legacy-origin convention lives at apps/client/src/render/legacy-origin.ts. |
| TeleIn trigger gating (D-55d) | Browser / Client (GameScene/PlayerRenderer.addRemote) | — | Render-event policy; server need not know about animation triggers. |
| Direction state desync (D-58c) | API / Backend (broadcast facing) | Browser / Client (consume + lerp) | REQ-SRV-03 mandates server-authoritative state. Facing should be server-broadcast, not client-derived per-tab. |
| Nameplate flicker (D-45d) | Browser / Client (Nameplate.follow + PlayerRenderer remote lerp) | — | Pure render-pipeline jitter; no server interaction. |
| Always-emit room_layout (D-60) | API / Backend (RebnoRoom.onJoin) | — | Cookie-resume vs fresh-auth branch lives in onJoin; client-side is already idempotent (RoomRenderer.renderNew re-applies). |
| wall_border drop (D-61) | Static / Authored data + Browser render | API / Backend (data only) | Server JSON edit + client render block delete. |
| Collision_polys + room_size derivation (D-62) | API / Backend (server-runtime helper) | Browser / Client (consume) | Server-authoritative; client trusts broadcast. Compile-time codification deferred to Phase 7. |
| Origin-convention helper (D-63) | Browser / Client (render module) | Project doc (CLAUDE.md) | Render-only concern; promotion to packages/* premature (one consumer). |
| Regression tests for cycle-2 PASS (D-57b, D-58b) | Browser / Client (vitest unit) | — | Reconciler is client-side; unit test in `apps/client/src/__test__/reconciler.test.ts`. |

## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| REQ-CLI-04 | Movement uses client-side prediction + interpolation + server reconciliation. | D-55c/D-55d/D-58c/D-45d all touch the predicted-render + reconciler path. Direction reconciliation is currently absent — research finding identifies it as a server-broadcast gap. |
| REQ-CLI-06 | HiDPI rendering uses nearest-neighbor + integer scale. | D-61 wall_border drop + D-62 broadcast-derived collision_polys both feed RoomRenderer + RoomCollision. |
| REQ-CLI-07 | Asset pipeline produces atlas + background for MVP room. | D-45d nameplate sits atop atlas-driven Phaser.Text; D-63 drift test asserts atlas metadata stability. |
| REQ-CLI-08 | MVP GATE: two players move + chat. | All cycle-5 fixes feed CLI-08; mp4 capture is the gate artifact. |
| REQ-SRV-03 | apps/server runs Node 22 + Colyseus 0.17 with one Room. | D-51c eviction policy + D-62 server-derived broadcast both extend this requirement. |
| REQ-SRV-14 | Authoritative per-entity sim contract. | D-62 step() boundary enforcement; D-58c direction broadcast (if chosen as the fix) extends per-entity contract. |

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions (NON-NEGOTIABLE — planner must NOT re-litigate)

- **D-51c approach:** Spike-first. Single-cycle commit (spike + fix in 06.4). CAVEAT: if spike points to Colyseus 0.17 → 0.18 surgery, escalate to own phase.
- **D-55c approach:** Replace inline origin math at `PlayerRenderer.ts:611-613` with `phaserOriginForLegacyPlayerAttached(...)` helper call. PASS gate = visual side-by-side legacy screenshot.
- **D-55d gating predicate:** "fresh-join during this session" — NOT "first time we see this player". Initial state-sync batch suppresses TeleIn; subsequent onRemoteAdd fires it. Self-spawn TeleIn unchanged.
- **D-58c approach:** Spike-first, thin scope. Hypothesis priority: (d) SpriteStateMachine derivation, then (c) reconciler idle skip, then (b) s2c payload, then (a) server-side state. Cheapest first.
- **D-45d approach:** Re-examine 06.3 Plan B ring buffer outputs (already unconditional in code). Likely fix: integer-snap was already done in 06.3 → residual must be in the remote lerp factor. Defer BitmapText to Phase 7.
- **D-60 approach:** Direct fix, NOT spike. In `RebnoRoom.onJoin`, ALWAYS call `sendRoomLayoutToClient(client)`. Client-side already idempotent.
- **D-61 approach:** Two-part drop (data: remove from mvp-room/000.json:1970; render: drop block at RoomRenderer.ts:273). Schema keeps `wall_border` OPTIONAL for Phase 7 compat.
- **D-62 approach:** Server-runtime derivation (NOT compile-time — compile-time is Phase 7 deferred). New helper `apps/server/src/layout-derive.ts` exports `deriveLayoutBounds(layout) → {collision_polys, room_size}`. Server step() consumes derived polys; `broadcastRoomLayout` emits derived fields. Schema keeps both OPTIONAL. NO PROTOCOL_VERSION bump.
- **D-63 helper home:** `apps/client/src/render/legacy-origin.ts` (NEW file).
- **D-63 signature:** Object-in, tuple-out — `phaserOriginForLegacyPlayerAttached({legacyOriginX, legacyOriginY, width, height}) → [originX, originY]`.
- **D-63 constants:** `NAVI_WIDTH_PX = 36`, `NAVI_VISIBLE_FEET_Y = 46` hardcoded with source-cite comments.
- **D-63 refactor scope:** ONLY `startTeleportAnim` (PlayerRenderer.ts:611-613) in 06.4. HexportIn/Out, ncol*, jokershell, watching deferred to Phase 7.
- **D-63 drift test:** `legacy-origin.test.ts` loads `apps/client/public/atlas-mvp.json` + asserts NaviStandD frame width === 36, bbox-bottom === 46 (the latter requires reading `extracted/.../meta.json` since the atlas does NOT carry bbox data).
- **D-63 CLAUDE.md:** New "Coordinate conventions" section pins `legacy_x = phaser_x - 18`, `legacy_y = phaser_y - 46`.
- **GREEN gates 1-5 in force:** HARD gate 1 (cli-08-* + camera-follow GREEN), HARD gate 2 (per-fix operator UAT), HARD gate 3 (e2e canonical-ref cite per assertion), SOFT gate 4 (staging redeploy + smoke at W2→W3 boundary), NEW HARD gate 5 (helper-API audit — reject inline setOrigin math in apps/client/src/render/).

### Claude's Discretion (research recommends, planner finalizes)

- D-51c telemetry event-name conventions (recommended in §Code Examples below).
- D-58c spike telemetry payload shape (recommended below).
- D-45d residual-hypothesis spike instrumentation (recommended below).
- D-62 `deriveLayoutBounds` exact algorithm for non-rectangular floor (out of scope for 06.4 per CONTEXT — MVP rooms uniform; Phase 7 handles mixed-walkable).
- Wave assignment (recommended in §Architecture Patterns).
- Per-task validation artifact mapping (specified in §Validation Architecture).

### Deferred Ideas (OUT OF SCOPE for 06.4)

- Colyseus 0.17 → 0.18 upgrade — escalate to own phase if D-51c spike points here.
- Compile-time `collision_polys` codification — Phase 7.
- Mixed-walkable / abyss / fall-tile room handling — Phase 7.
- BitmapText / sprite-font glyphs — Phase 7.
- Helper expansion (HexportIn/Out, ncol*, jokershell, watching) — Phase 7.
- Eslint rule blocking inline (originX, originY) math — Phase 7 if recurrence.
- 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.

</user_constraints>

## Project Constraints (from CLAUDE.md)

The planner must honor these directives. They have the same authority as locked CONTEXT decisions.

- **Server-authoritative (Hard Rule 1):** Clients send intent; server emits state. Direction broadcast (D-58c fix) is the natural application of this rule.
- **Extracted constants are load-bearing:** tile 44×40, view 640×480, tick 30 Hz. **NEW D-63 entry:** `legacy_x = phaser_x - 18`, `legacy_y = phaser_y - 46`. CLAUDE.md edit adds these.
- **Traceability tag contract:** `[<doc>->REQ-X]` in markdown, `// [<impl>->REQ-X]` / `// [<unit>->REQ-X]` / `// [<int>->REQ-X]` in code comments. Multiple tags per line allowed. No spaces inside the bracketed token.
- **Per-plan = one commit.** Each plan ends with a commit citing REQ-IDs. `pnpm trace:check` must be run before declaring the phase complete.
- **TypeScript strict mode** everywhere. Shared types via `packages/protocol`.
- **REQ-IDs touched this phase:** REQ-CLI-04, REQ-CLI-06, REQ-CLI-07, REQ-CLI-08, REQ-SRV-03, REQ-SRV-14. Each plan/task that touches a domain must embed the appropriate tag.

## Standard Stack

This phase consumes the locked stack. No new dependencies.

### Core (already installed; verified via grep on imports)

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Colyseus | 0.17.x | WS multiplayer room | Locked by ADR 0001 + STACK.md `[ASSUMED]` (not re-verified this session — last verified 06.3 research) |
| Phaser | 3.90.x | Client render engine | Locked by ADR 0001 (`docs/adr/0001-client-engine.md`) `[CITED: .planning/REQUIREMENTS.md CDOC-04]` |
| msgpackr | latest | s2c.room_layout payload encoding | Already used in RebnoRoom.broadcastRoomLayout `[VERIFIED: codebase grep]` |
| zod | latest | layoutSchema validation in `packages/protocol/src/intents.ts` | `[VERIFIED: codebase]` |
| pino | latest | server logging | Already used in RebnoRoom (e.g. `log.info({event: 'd51_eviction', ...})`) `[VERIFIED: codebase]` |
| vitest | latest | unit + integration test runner | Used in all `*.test.ts` files `[VERIFIED: codebase]` |
| @playwright/test | latest | e2e | Used in `apps/client/test/e2e/*.test.ts` `[VERIFIED: codebase]` |

**No new package installs required for this phase.**

### Alternatives Considered (and rejected by CONTEXT)

| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Server-runtime D-62 derivation | Compile-time codification in asset pipeline | Asset pipeline lands in Phase 7; building it now bundles AST-* work into 06.4 and breaks the cycle-5 boundary. **CONTEXT locks runtime; do not revisit.** |
| Math.round nametag fix | BitmapText sprite-font glyphs | Larger refactor; removes font-metric padding + WOFF2 race. Phase 7. **CONTEXT defers BitmapText.** |
| Server-broadcast facing (D-58c) | Client-derive identically | Server-derive forces all viewers to identical facing per server tick. Client-derive depends on identical input history per viewer, which is not guaranteed — fragile. Server-broadcast is the REQ-SRV-03-aligned solution. |

## Architecture Patterns

### System Architecture Diagram

```
[Operator]
   │ HTTP login (Better-Auth cookie set)
   ▼
[Vite client]──── WS ───►[Colyseus Server (RebnoRoom)]
   │  c2s.input              │
   │  c2s.chat_send          │  onJoin → sendRoomLayoutToClient (D-60 unconditional)
   │  c2s.auth               │  onJoin → eviction loop (D-51c spike target)
   ▼                          │  step() ← derived collision_polys (D-62)
[GameScene]                   │  broadcast s2c.room_layout (D-62 derived fields)
   │                          │  broadcast s2c (player state, axis_x_held, axis_y_held)
   ▼                          │  ──── facing field (D-58c new: server-broadcast)
[PlayerRenderer]              │
   │   onSimulationTickLocal  │
   │   onSimulationTickRemote ◄────────┘
   │     ↓ Math.round textY (D-45d Plan B already in code)
   │     ↓ remote 0.35 lerp ◄── candidate residual flicker source
   ▼
[Nameplate] [Sprite] [TeleportAnim] (D-55c uses legacy-origin.ts helper)
   ▲
   │ atlas-mvp.json (D-63 drift-test fixture)
```

### Recommended Project Structure (delta only — files NEW or TOUCHED this phase)

```
apps/
├── server/
│   ├── src/
│   │   ├── RebnoRoom.ts             # MODIFIED (D-51c telemetry, D-60 unconditional, D-62 wire)
│   │   ├── layout-derive.ts         # NEW (D-62 helper)
│   │   └── ...
│   └── rooms/
│       └── mvp-room/
│           └── 000.json             # MODIFIED (D-61: drop wall_border block @ line 1970)
└── client/
    └── src/
        ├── render/
        │   ├── legacy-origin.ts     # NEW (D-63 helper)
        │   ├── legacy-origin.test.ts # NEW (D-63 drift detection)
        │   ├── PlayerRenderer.ts    # MODIFIED (D-55c refactor, D-45d residual fix)
        │   ├── RoomRenderer.ts      # MODIFIED (D-61 drop block @ line 273)
        │   └── SpriteStateMachine.ts # POSSIBLY MODIFIED (D-58c spike output)
        ├── scenes/
        │   └── GameScene.ts         # MODIFIED (D-55d gating predicate)
        └── __test__/
            └── reconciler.test.ts   # MODIFIED (D-57b/D-58b regression anchors)
packages/
└── protocol/
    └── src/
        └── intents.ts               # MODIFIED (D-62: newLayoutSchema += optional collision_polys/room_size)
CLAUDE.md                            # MODIFIED (D-63: "Coordinate conventions" section)
```

### Pattern 1: Spike-Then-Fix With Unconditional Telemetry

**What:** Add diagnostic instrumentation as a separate plan/task (no behavior change), redeploy staging, capture operator repro, then ship the fix in a follow-on plan. Telemetry hooks are UNCONDITIONAL (no `process.env.NODE_ENV === 'production'` gates) — env gating burned the cycle-3 RC1.

**When to use:** D-51c, D-58c, D-45d residual.

**Example (D-51c server-side):**
```typescript
// apps/server/src/RebnoRoom.ts onJoin eviction block (extension of existing d51_eviction events)
// SOURCE: extends pattern from 06.3-03-SUMMARY.md (D-51 spike)
// [impl->REQ-SRV-03] [impl->REQ-CLI-08]
log.info(
  {
    event: 'd51c_eviction_iter',          // NEW event name
    step: 'before_priorClient_leave',
    iteration: i,
    priorSessionId,
    priorClientConnState: priorClient?.state ?? 'unknown',
    elapsedMs: Date.now() - evictionStartMs,
    ts: Date.now(),
  },
  'D-51c eviction iter pre-leave',
);
```

**Example (D-51c client-side ring buffer):**
```typescript
// apps/client/src/net/colyseus-client.ts (NEW — extends window.__rebno surface)
// [impl->REQ-CLI-08]
const evictionEvents: Array<{ event: string; ts: number; detail?: unknown }> = [];
function recordEvictionEvent(event: string, detail?: unknown): void {
  evictionEvents.push({ event, ts: Date.now(), detail });
  if (evictionEvents.length > 20) evictionEvents.shift();
  (globalThis as any).__rebno = {
    ...((globalThis as any).__rebno ?? {}),
    lastEvictionEvents: [...evictionEvents],
  };
}
// Hook into every Colyseus event during onJoin/onLeave/onError windows:
//   - recordEvictionEvent('client_onLeave', { code })
//   - recordEvictionEvent('client_onError', { code, message })
//   - recordEvictionEvent('reconnect_attempt', { attemptN, backoffMs })
//   - recordEvictionEvent('reconnect_success' | 'reconnect_failure')
//   - recordEvictionEvent('force_reset_received', { reason })
```

### Pattern 2: Helper-First Refactor (D-63)

**What:** Extract inline math into a named, tested helper BEFORE the consumer migration. The helper ships in the same plan as the migration (single commit) but is testable in isolation.

**When to use:** D-63 helper + D-55c consumer migration share one plan.

**Example:**
```typescript
// apps/client/src/render/legacy-origin.ts (NEW)
// [impl->REQ-CLI-04] [impl->REQ-CLI-08]
// D-63: permanent origin-convention mitigation for player-attached effects.
//
// LEGACY  : Navi sprite origin (0, 0) = top-left; effects placed at legacy (x, y).
// PHASER  : Navi sprite origin (0.5, 1) = bottom-center (feet); sprite.x = navi center,
//           sprite.y = navi feet (= visible feet, not sprite-rect bottom).
//
// Convention shift constants (CITED via CLAUDE.md "Coordinate conventions"):
//   NAVI_WIDTH_PX        = 36   ← extracted/client-5-8/sprites/0000-NaviStandD/meta.json:width
//   NAVI_VISIBLE_FEET_Y  = 46   ← extracted/client-5-8/sprites/0000-NaviStandD/meta.json:bboxBottom
//                                 (NOT 48 sprite-rect bottom; 46 is the visible feet line.)
//
// Math:
//   originX = (legacy_originX + NAVI_WIDTH_PX / 2) / width      // shift right by half-Navi
//   originY = (legacy_originY + NAVI_VISIBLE_FEET_Y) / height   // shift down to feet line
export const NAVI_WIDTH_PX = 36;
export const NAVI_VISIBLE_FEET_Y = 46;

export function phaserOriginForLegacyPlayerAttached(spec: {
  legacyOriginX: number;
  legacyOriginY: number;
  width: number;
  height: number;
}): [originX: number, originY: number] {
  return [
    (spec.legacyOriginX + NAVI_WIDTH_PX / 2) / spec.width,
    (spec.legacyOriginY + NAVI_VISIBLE_FEET_Y) / spec.height,
  ];
}
```

**Origin math verification (D-55c expected output):** TeleIn variant: width=64, height=64 per CONTEXT (but **NOTE**: PlayerRenderer.ts:570-571 currently sets `height: 100`, not 64 — the CONTEXT scope guidance value of 64 is wrong; verified by direct read). Using actual variant `width=64, height=100, legacyOriginX=15, legacyOriginY=42`:
- `originX = (15 + 18) / 64 = 33/64 ≈ 0.515625`  (CONTEXT scope says 0.516 — match)
- `originY = (42 + 46) / 100 = 88/100 = 0.88`     (CONTEXT scope-hint based on `legacyOriginY=11` was a typo; actual code uses 42 for TeleIn. Recompute against real code, not the hint.)

The current (broken) inline math at PlayerRenderer.ts:611-613:
- `originX = 15 / 64 ≈ 0.234`  → too far LEFT in legacy frame coords, which renders sprite center too far RIGHT of player center. Matches operator report ("sprite too RIGHT").
- `originY = (42 + 48) / 100 = 0.9`  → using 48 (sprite-rect-bottom) instead of 46 (visible-feet) renders 2 px too LOW. Matches operator report ("slightly too LOW").

**Confidence on origin math:** HIGH `[VERIFIED: direct code read of apps/client/src/render/PlayerRenderer.ts:570-583 + extracted/client-5-8/sprites/0000-NaviStandD/meta.json]`. The planner must double-check `variant.height` is 100 (not 64) before publishing expected acceptance values.

### Pattern 3: Server-Authoritative Broadcast Of Derived State (D-62)

**What:** Server reads room JSON, derives runtime fields at room boot, broadcasts derived fields in `s2c.room_layout`, and consumes them in `step()` without trusting client-provided versions.

**Example:**
```typescript
// apps/server/src/layout-derive.ts (NEW)
// [impl->REQ-SRV-03] [impl->REQ-SRV-14] [impl->REQ-CLI-06]
// D-62: derive collision_polys + room_size at runtime from tiles[] + tile_w/tile_h
// + width_tiles/height_tiles. MVP rooms (mvp-room) ship with uniform floors —
// derived polys are the outer rectangle boundary. Mixed-walkable / abyss support
// is Phase 7 (requires per-tile collision attribution beyond tiles[]).

import type { Layout } from '@rebno/protocol';

export interface DerivedBounds {
  collision_polys: Array<Array<{ x: number; y: number }>>;
  room_size: { w: number; h: number };
}

export function deriveLayoutBounds(layout: Layout): DerivedBounds {
  // newLayoutSchema branch (has room_id, width_tiles, tile_w, etc.)
  if ('room_id' in layout) {
    const w = layout.width_tiles * layout.tile_w;
    const h = layout.height_tiles * layout.tile_h;
    // Uniform-floor MVP assumption: outer rectangle is the only collision poly.
    // 4 thin rectangles (top/bottom/left/right) at thickness = 1 tile, matching
    // the wall_border drop pattern that mvp-lobby uses.
    const tw = layout.tile_w;
    const th = layout.tile_h;
    return {
      collision_polys: [
        // top
        [{ x: 0, y: 0 }, { x: w, y: 0 }, { x: w, y: th }, { x: 0, y: th }],
        // bottom
        [{ x: 0, y: h - th }, { x: w, y: h - th }, { x: w, y: h }, { x: 0, y: h }],
        // left
        [{ x: 0, y: 0 }, { x: tw, y: 0 }, { x: tw, y: h }, { x: 0, y: h }],
        // right
        [{ x: w - tw, y: 0 }, { x: w, y: 0 }, { x: w, y: h }, { x: w - tw, y: h }],
      ],
      room_size: { w, h },
    };
  }
  // Legacy-schema branch (already has collision_polys + room_size; passthrough).
  return {
    collision_polys: layout.collision_polys ?? [],
    room_size: layout.room_size,
  };
}
```

**Wiring (planner reference):**
- `RebnoRoom.onCreate` or `registry.onChange` → call `deriveLayoutBounds(layout)` → store on `this.mvpRoomLayout`.
- `toWorldState()` (line 832-853) currently sets `room_layout: this.mvpRoomLayout` — this is already correctly wired to use the in-memory derived record. The CHANGE is populating `this.mvpRoomLayout` from `deriveLayoutBounds(loaded.layout)` instead of the hardcoded `{ collision_polys: [], room_size: { w: 1000, h: 1000 } }` default at line 138-141.
- `sendRoomLayoutToClient` → when emitting `s2c.room_layout`, include the derived `collision_polys` + `room_size` fields (planner must decide encoding: pack into `layout_bytes` via re-encode, OR add separate s2c envelope fields; the cleanest path is re-encode the layout msgpack with derived fields merged in).

**Schema extension required:**
```typescript
// packages/protocol/src/intents.ts newLayoutSchema (line 158-196)
// Add to the .object({...}) call:
collision_polys: z.array(
  z.array(z.object({ x: z.number(), y: z.number() })),
).optional(),
room_size: z.object({
  w: z.number().int().positive(),
  h: z.number().int().positive(),
}).optional(),
```

**PROTOCOL_VERSION:** No bump required. Adding optional fields is wire-backward-compatible — older clients ignore the new fields; older servers don't emit them.

### Anti-Patterns to Avoid

- **Inline origin math in `apps/client/src/render/`:** rejected by HARD gate 5 (helper-API audit). Use `phaserOriginForLegacyPlayerAttached`.
- **Env-gated `window.__rebno` hooks:** burned us in 06.3 cycle-3 RC1 (env gate silenced Playwright on staging). Always unconditional.
- **Reading client-provided collision_polys:** server-authoritative per REQ-SRV-03. Server derives + broadcasts; client trusts.
- **Bumping PROTOCOL_VERSION for optional field additions:** wastes a version slot and requires two test-literal updates (state.test.ts:11-12, colyseus-client.test.ts:131,141). Only bump when the wire shape is breaking.
- **"Batch all fixes then test" Wave-2 pattern:** rejected by HARD gate 2. Per-fix operator UAT required between each Wave-2 fix.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Replace-prior-session reconnect-banner suppression | Custom heartbeat/state machine layer | Colyseus's built-in `client.leave(code)` with code 4001 + client onLeave handler that distinguishes 4001 (eviction) from 4000 (consented) from <4000 (transport) | Already shipped in cycle-2 (`ea68ecc`); D-51c is debugging the existing pattern, not replacing it. |
| Atlas frame validation | Manual JSON parsing | `import atlas from 'apps/client/public/atlas-mvp.json'` + vitest `expect` — same pattern as existing `colyseus-client.test.ts` fixture loads | Standard vitest fixture pattern; no JSON parse error handling needed. |
| Origin convention re-derivation per teleport variant | Per-variant magic numbers | `phaserOriginForLegacyPlayerAttached` helper | One source of truth; drift-tested; CLAUDE.md cited. |
| Server-authoritative facing reconciliation | Client-side guess-the-history | Add `facing` field to PlayerState schema + broadcast it; client renderer trusts it instead of deriving from velocity | REQ-SRV-03 mandates server-authoritative state. Matches the cycle-2 D-08 axis_x_held/axis_y_held precedent (broadcast intent, client uses it). |
| Custom integer-snap for nameplate Y | Phaser canvas roundPixels config | `Math.round` is already in `Nameplate.follow()` (line 125). The residual flicker is upstream in the remote-lerp factor at PlayerRenderer.ts:357 — fix THAT, not the nameplate again. | The integer-snap fix shipped in cycle-2; carry-in implies the source is elsewhere. |

**Key insight:** Custom solutions are worse here because we have THREE existing patterns to extend (window.__rebno telemetry, pino-log structured events, server-authoritative broadcast). Cycle-2 D-08 already broadcasts axis_x_held/axis_y_held — adding `facing` follows that exact pattern.

## Runtime State Inventory

> Omitted — this is a defect-closure phase, not a rename/refactor. No stored data, live service config, OS-registered state, secrets, or build artifacts encode names being changed.

**Nothing in category:** None — verified by scope review against §Phase Boundary. All fixes are code + data edits to existing files; no naming changes propagate beyond the codebase.

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node.js | server runtime, vitest, build | ✓ (assumed; phase 5 verified) | 22+ | — |
| pnpm | workspace + scripts | ✓ (used across all .planning artifacts) | latest | — |
| Playwright (chromium) | e2e tests | ✓ (`apps/client/test/e2e/*.test.ts` runs) | latest | — |
| Fly.io CLI (`flyctl`) | staging redeploy (SOFT gate 4) | ✓ (phase 5) | — | — |
| `pnpm trace:check` | requirements traceability | ✓ (CLAUDE.md cites) | — | — |
| `gsd-sdk` | workflow tooling | ✓ (CLAUDE.md cites) | — | — |

**Missing dependencies with no fallback:** None identified.

**Missing dependencies with fallback:** None identified.

**Note for planner:** Staging redeploy at the W2→W3 boundary requires the operator (decidel) to be available; this is a SOFT gate but the planner should sequence it as an explicit task with the operator-in-the-loop marker.

## Common Pitfalls

### Pitfall 1: Re-running D-45d "integer-snap" fix

**What goes wrong:** Plan recommends `Math.round` in `Nameplate.follow()` — but it's already there (line 125, shipped cycle-2).
**Why it happens:** CONTEXT scope guidance §5 cited the fix as a candidate; planner reads it as "primary fix" without verifying.
**How to avoid:** Plan's spike first reads the existing `firstRemoteNameplateHistory` ring buffer (already unconditional in code) on FRESH staging UAT motion, then identifies the upstream contributor — most likely the **0.35-factor remote-position lerp** at `PlayerRenderer.ts:357-360` producing fractional `smoothedY` that boundary-flickers between rounded ints.
**Warning signs:** Plan task title contains "add Math.round to Nameplate" — reject; integer-snap is already in code.

### Pitfall 2: D-58c "broadcast facing as new field" without PROTOCOL_VERSION audit

**What goes wrong:** Adding a NEW PlayerState field may or may not require PROTOCOL_VERSION bump.
**Why it happens:** Colyseus schema-based state changes are wire-compatible if the new field is OPTIONAL on the wire — but adding to a Colyseus `@type` Schema class still emits a schema-version change Colyseus tracks internally.
**How to avoid:** Per Colyseus 0.17 schema-versioning: adding a new `@type("string")` field is additive and clients with older schemas tolerate the new field (Colyseus skips unknown fields on decode). However: the project's custom PROTOCOL_VERSION lives at `packages/protocol/src/version.ts` and is independent of Colyseus schema versioning. If the c2s.auth handshake still validates against the same PROTOCOL_VERSION, NO bump is needed.
**Warning signs:** Plan touches `packages/protocol/src/version.ts` for D-58c → flag for verification. If touched, the two literal test asserts (`packages/protocol/test/state.test.ts:11-12` + `apps/client/src/__test__/colyseus-client.test.ts:131,141`) must be updated.

### Pitfall 3: D-55d gating predicate based on `Date.now()` clock skew

**What goes wrong:** A `localClientJoinedAt = Date.now()` timestamp compared against a server-broadcast `player.joinedAt` will drift across server vs client clocks (NTP skew is typically <100ms but can spike to seconds).
**Why it happens:** Naive timestamp comparison treats local clock as global clock.
**How to avoid:** Use a "first batch" flag — set `inInitialSnapshot = true` for the duration of the first Colyseus state-sync pass after `onJoin` resolves; any `onRemoteAdd` fired during that window suppresses TeleIn. Flip to false on the first `onStateChange` AFTER the initial snapshot. Colyseus 0.17 emits all initial state-sync `onAdd` callbacks SYNCHRONOUSLY during the first state-decode after onJoin — flipping the flag after the first event-loop tick post-onJoin suffices.
**Warning signs:** Plan uses `Date.now()` arithmetic for gating — reject.

### Pitfall 4: D-62 schema extension breaks existing clients

**What goes wrong:** Adding `collision_polys` + `room_size` as REQUIRED fields to `newLayoutSchema` breaks parsing of `mvp-room/000.json` which lacks both.
**Why it happens:** zod `.strict()` is NOT set on `newLayoutSchema` (verified by read of intents.ts:158-196) — extra fields would be allowed. But making the new fields REQUIRED would fail the existing room JSON validation since it lacks them; the fix path is **`.optional()`** for both.
**How to avoid:** `collision_polys: z.array(...).optional()` + `room_size: z.object({...}).optional()`. Test: existing `mvp-room/000.json` still parses through `layoutSchema.parse(...)`.
**Warning signs:** Plan task drops `.optional()` from the schema addition → reject.

### Pitfall 5: D-51c spike doesn't capture the "looping banner" cadence

**What goes wrong:** Spike instrumentation captures eviction events but operator can't isolate which event triggers the visible reconnected-banner re-show.
**Why it happens:** The reconnect banner is a DOM element; its trigger lives in client `colyseus-client.ts` or `GameScene.ts` — separate from the eviction telemetry stream.
**How to avoid:** Spike telemetry must record BOTH server-side eviction events (pino) AND client-side banner-show events (`window.__rebno.lastEvictionEvents` ring buffer). Add a DOM-mutation hook or direct call-site instrumentation at the reconnect-banner show point so the spike captures the FULL timeline.
**Warning signs:** Spike plan only adds server pino → reject; must include client ring buffer too.

### Pitfall 6: D-60 cookie-resume path not exercised by existing tests

**What goes wrong:** D-60 fix lands but no test confirms cookie-resume `onJoin` path actually fires `sendRoomLayoutToClient`.
**Why it happens:** Existing tests cover fresh-auth `onJoin`; cookie-resume tests use `cookie-reload.e2e.test.ts` which exists but may not assert on layout receipt.
**How to avoid:** Verify or extend `cookie-reload.e2e.test.ts` to assert that `data-room-rendered` (or equivalent canvas-ready DOM mirror) flips true after cookie-resume reload, not just `data-game-ready`.
**Warning signs:** Plan claims "no new test needed" → reject; cycle-3 burned us with assertions matching wrong code.

## Code Examples

### D-55d gating predicate (recommended pattern)

```typescript
// apps/client/src/scenes/GameScene.ts
// SOURCE: extracted/client-5-8/objects/0042-player/events/Create.gml line 1
//   only the local-player Create event sets the spawn-in alarm; remote players
//   appearing in the state snapshot at room-join are NOT new spawns.
// [impl->REQ-CLI-04] [impl->REQ-CLI-08]
private inInitialSnapshot = true;

protected create(): void {
  // ... existing onJoin wiring ...

  // Flip after the first microtask post-onJoin completes — Colyseus 0.17
  // delivers all initial state-sync onAdd callbacks SYNCHRONOUSLY before
  // returning control to the event loop, so a queueMicrotask flip occurs
  // strictly AFTER the initial batch.
  queueMicrotask(() => { this.inInitialSnapshot = false; });
}

private onRemoteAdd(player: PlayerSnapshotShape, sid: string): void {
  this.playerRenderer?.addRemote(sid, player.name ?? sid, player.x, player.y, {
    playTeleportIn: !this.inInitialSnapshot,  // gate the TeleIn anim
  });
  this.publishRemotePlayers();
  this.markGameReady();
}
```

(Planner: extend `addRemote(...)` signature in `PlayerRenderer.ts:403` to accept the `playTeleportIn` flag; the current code unconditionally calls `playTeleportIn(entry)` at line 432.)

### D-58c spike telemetry (recommended payload shape)

```typescript
// apps/client/src/render/PlayerRenderer.ts (extends existing __rebno surface)
// [impl->REQ-CLI-04] [impl->REQ-CLI-08]
private publishDirectionTelemetry(): void {
  const local = this.local;
  if (!local) return;
  const firstRemoteSid = this.remotes.keys().next().value;
  const firstRemote = firstRemoteSid ? this.remotes.get(firstRemoteSid) : undefined;
  (globalThis as any).__rebno = {
    ...((globalThis as any).__rebno ?? {}),
    localDirection: local.facing,                  // 'D' | 'DR' | 'R' | ...
    firstRemoteDirection: firstRemote?.facing,
    firstRemoteAxes: {
      x: (firstRemote as any)?.lastSeenAxisX,
      y: (firstRemote as any)?.lastSeenAxisY,
    },
    ts: Date.now(),
  };
}
```

```typescript
// apps/server/src/RebnoRoom.ts (extends existing held-input log)
// [impl->REQ-SRV-03]
if (p.account_id === 'uat_a' || p.account_id === 'uat_b') {  // operator-targeted
  log.info(
    {
      event: 'd58c_player_axes',
      account_id: p.account_id,
      axis_x_held: p.axis_x_held,
      axis_y_held: p.axis_y_held,
      x: p.x,
      y: p.y,
      ts: Date.now(),
    },
    'D-58c per-tick player axes trace',
  );
}
```

### D-45d residual-hypothesis spike (lerp inspection)

```typescript
// apps/client/src/render/PlayerRenderer.ts onSimulationTickRemote
// (extends existing firstRemoteNameplateHistory ring buffer at line 375-393)
// [impl->REQ-CLI-04] [impl->REQ-CLI-08]
// NEW fields to capture:
hist.push({
  // ... existing fields ...
  raw_target_y: y,                                            // server-broadcast y
  pre_lerp_sprite_y: r.sprite.y,                              // before smoothing
  lerp_factor: lerpFactor,                                    // = 0.35
  smoothed_y_fractional: smoothedY,                           // BEFORE Math.round
  smoothed_y_rounded: Math.round(smoothedY),                  // what nameplate sees
  delta_y_pre_round: smoothedY - Math.round(smoothedY),       // fractional distance to flip
});
```

### D-63 drift test (vitest)

```typescript
// apps/client/src/render/legacy-origin.test.ts (NEW)
// [unit->REQ-CLI-04] [unit->REQ-CLI-08]
// D-63 drift detection: if the atlas regenerates with different Navi metrics,
// fail the unit test loudly so the convention shift can be re-derived.
import { describe, it, expect } from 'vitest';
import {
  NAVI_WIDTH_PX,
  NAVI_VISIBLE_FEET_Y,
  phaserOriginForLegacyPlayerAttached,
} from './legacy-origin.js';
import atlas from '../../public/atlas-mvp.json' assert { type: 'json' };
import naviMeta from '../../../../extracted/client-5-8/sprites/0000-NaviStandD/meta.json' assert { type: 'json' };

describe('legacy-origin D-63 drift detection', () => {
  it('NaviStandD atlas frame matches NAVI_WIDTH_PX', () => {
    const frame = atlas.frames['0000-NaviStandD_000'].frame;
    expect(frame.w).toBe(NAVI_WIDTH_PX);     // 36
  });
  it('NaviStandD atlas frame matches sprite-rect height', () => {
    const frame = atlas.frames['0000-NaviStandD_000'].frame;
    expect(frame.h).toBe(48);  // sprite-rect height; visible feet = bboxBottom
  });
  it('NaviStandD meta bboxBottom matches NAVI_VISIBLE_FEET_Y', () => {
    expect(naviMeta.bboxBottom).toBe(NAVI_VISIBLE_FEET_Y);  // 46
  });
  it('phaserOriginForLegacyPlayerAttached agrees with TeleIn variant', () => {
    const [ox, oy] = phaserOriginForLegacyPlayerAttached({
      legacyOriginX: 15, legacyOriginY: 42, width: 64, height: 100,
    });
    expect(ox).toBeCloseTo(33 / 64, 5);       // (15 + 18) / 64
    expect(oy).toBeCloseTo(88 / 100, 5);      // (42 + 46) / 100
  });
});
```

### Helper-API audit grep (HARD gate 5)

```bash
# Rejected patterns (run during gsd-code-reviewer + plan-checker):
# Inline origin math citing variant width/height literally.
git grep -nE 'setOrigin\(\s*[0-9]+\s*/\s*[a-zA-Z_]+\.(width|height)' apps/client/src/render/
# Should return zero matches AFTER 06.4 D-55c lands.

# Allowed pattern (helper-driven):
git grep -nE 'phaserOriginForLegacyPlayerAttached' apps/client/src/render/
# Should return at least startTeleportAnim consumer.
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Inline `setOrigin(15/64, (42+48)/100)` (cycle-2 D-55b ship) | `phaserOriginForLegacyPlayerAttached({legacyOriginX: 15, legacyOriginY: 42, width: 64, height: 100})` (cycle-5 D-55c via D-63) | 06.4 | Origin convention pinned + drift-tested; future ports go through helper. |
| Hardcoded `mvpRoomLayout = { collision_polys: [], room_size: { w:1000, h:1000 } }` (RebnoRoom.ts:138-141) | Runtime-derived via `deriveLayoutBounds(layout)` at registry load | 06.4 | step() consumes actual room dimensions; no more drift past visual floor. |
| Layout broadcast tied to RoomRegistry hot-reload + first onJoin only | UNCONDITIONAL `sendRoomLayoutToClient` on every onJoin (fresh + cookie-resume) | 06.4 | Cookie-resume players see floor immediately. |
| Client-side facing derivation from per-renderer `lastFacing` history | Server-broadcast facing field (recommended D-58c fix) | 06.4 (TBD on spike outcome) | All viewers agree on facing per server tick. |
| `wall_border` block in mvp-room/000.json + RoomRenderer fallback render | Dropped (data + render); schema keeps optional for Phase 7 | 06.4 | Walkable-grid (D-62 derived) is the single source of truth. |

**Deprecated/outdated (this cycle):**
- D-55b inline origin math — supplanted by D-55c helper call.
- D-60 conditional `sendRoomLayoutToClient` — supplanted by unconditional.
- D-62 hardcoded `{w:1000, h:1000}` — supplanted by `deriveLayoutBounds`.

## Assumptions Log

> Claims tagged `[ASSUMED]` requiring user confirmation before locking decisions.

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Colyseus 0.17 delivers all initial state-sync onAdd callbacks synchronously before returning control to the event loop, so a `queueMicrotask` flip suffices for the D-55d initial-batch flag. | Pitfall 3 | Wrong → TeleIn anim fires for one or more pre-existing remotes on every join (operator-visible regression). Mitigation: planner adds an explicit test that creates a room with N>0 existing players, joins as new client, asserts no TeleIn fires. |
| A2 | Colyseus 0.17 schema additions (new `@type` fields on PlayerState) are wire-compatible with older clients in the same schema-version. | Pitfall 2 | Wrong → adding `facing` field for D-58c breaks legacy clients OR requires PROTOCOL_VERSION bump (with the two test-literal updates). Mitigation: planner runs `pnpm --filter @rebno/server test:integ` after the schema addition + checks `apps/client/src/__test__/colyseus-client.test.ts` still parses the server snapshot. |
| A3 | The remote-sprite Y lerp factor (0.35) at PlayerRenderer.ts:357-360 is the residual D-45d flicker source. | Summary + Pitfall 1 | Wrong → planner ships a no-op fix. Mitigation: D-45d is SPIKE-FIRST per CONTEXT; spike telemetry confirms the source before fix lands. |
| A4 | The TeleIn variant uses `height: 100` (not 64) — direct read of PlayerRenderer.ts:570-571 confirms. CONTEXT scope guidance §2 mentioned "height=64" which appears to be a typo. | Pattern 2 D-55c | Wrong → expected acceptance values for visual UAT are off. Mitigation: planner verifies `variant.height` directly before writing UAT spec. Likely `100` is correct (atlas-mvp.json TeleIn frame metadata would confirm). |

**A1, A2, A3, A4 require user/operator confirmation before downstream agents lock implementation details.**

## Open Questions

1. **D-62 broadcast encoding strategy**
   - What we know: `s2c.room_layout` currently carries `layout_bytes` (msgpack-packed layout) + `layout_rev` + `manifest_sig`. Derived fields live in the in-memory `loaded.layout` AFTER `deriveLayoutBounds` runs.
   - What's unclear: Should derived fields be re-packed into `layout_bytes` (preserves `manifest_sig` integrity? — NO, because the sig was computed over the ORIGINAL bytes), OR added as separate s2c envelope fields (cleaner — preserves sig)?
   - Recommendation: **Add separate envelope fields** `derived_collision_polys` + `derived_room_size` on the s2c.room_layout event. Manifest_sig integrity preserved (signature over original bytes). Client schema in `apps/client/src/scenes/GameScene.ts:onRoomLayout` (line 633) merges derived fields into the unpacked layout. Planner finalizes encoding.

2. **D-51c reconnect-banner trigger location**
   - What we know: cycle-2 `force_reset` handler exists in `colyseus-client.ts` (verified by grep — recordEvictionEvent will hook here). Reconnect attempts happen at the Colyseus.js SDK level on transport drop.
   - What's unclear: WHICH client code path shows the "reconnected" banner ~5s cadence? Is it (a) Colyseus.js auto-reconnect retrying after the eviction's `code 4001` close, (b) the project's own reconnect-state-machine that misreads code 4001 as transient, or (c) the `force_reset` handler firing repeatedly?
   - Recommendation: Spike plan includes a one-time grep audit at the start: `grep -rn "reconnect" apps/client/src/` to find all reconnect-attempt entry points + their trigger conditions; instrument each one's call site.

3. **D-58c facing schema cost vs derive-deterministically cost**
   - What we know: Server-broadcast facing is clean per REQ-SRV-03. Client-derive-identically requires identical input histories per viewer.
   - What's unclear: Operator preference — does the team want the schema extension (new PlayerState field) or a render-side normalization (e.g. "every PlayerRenderer resets facing to 'D' on remoteAdd and then derives from broadcast axis_x_held/axis_y_held identically")?
   - Recommendation: Decide in discuss-phase (CONTEXT does not pin). Server-broadcast is the smaller wire change AND the more REQ-aligned choice. Plan defaults to server-broadcast; operator can override at plan-check.

4. **Wave-3 staging redeploy frequency**
   - What we know: SOFT gate 4 requires staging redeploy + 5-test smoke at the W2→W3 boundary.
   - What's unclear: Does each Wave-3 plan also require its own staging redeploy + per-fix operator UAT, OR is one redeploy + smoke at W2→W3 sufficient?
   - Recommendation: Per HARD gate 2 ("operator visual UAT at every Wave-2 fix boundary") — Wave-2 fixes get per-fix UAT; Wave-3 fixes (spike-driven) get per-fix UAT too since they ship the actual fix. Plan one staging redeploy AFTER each Wave-3 fix lands.

## Validation Architecture

> nyquist_validation: true per `.planning/config.json` workflow block.

### Test Framework

| Property | Value |
|----------|-------|
| Framework | vitest (unit + integration) + @playwright/test (e2e) |
| Config files | `apps/client/vitest.config.ts`, `apps/server/vitest.config.ts`, `apps/client/playwright.config.ts` |
| Quick run command (per package) | `pnpm --filter @rebno/client test --run` (vitest), `pnpm --filter @rebno/server test` |
| Full e2e suite | `pnpm --filter @rebno/client test:e2e --project=chromium` |
| Trace check | `pnpm trace:check` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| REQ-CLI-04 | TeleIn does NOT fire for pre-existing remotes on join (D-55d) | unit | `pnpm --filter @rebno/client test playerRenderer` (NEW test case) | ❌ Wave 0 |
| REQ-CLI-04 | Origin helper agrees with TeleIn variant math (D-55c/D-63) | unit | `pnpm --filter @rebno/client test legacy-origin` | ❌ Wave 0 (NEW FILE) |
| REQ-CLI-04 | TeleIn renders at pixel-correct origin on staging (D-55c) | manual UAT | operator side-by-side legacy screenshot | manual |
| REQ-CLI-04 | Local + remote viewer agree on facing direction after stop (D-58c) | int | NEW: `apps/server/test/d58c-direction-broadcast.integ.test.ts` | ❌ Wave 0 |
| REQ-CLI-04 | Reconciler idle-state converges with vx===0 vy===0 (D-58b regression) | unit | `pnpm --filter @rebno/client test reconciler` (extend existing) | ✅ (extend) |
| REQ-CLI-04 | Reconciler tween path NOT reintroduced (D-57b regression) | unit | `pnpm --filter @rebno/client test reconciler` (extend existing) | ✅ (extend) |
| REQ-CLI-04 | Nameplate flicker absent during remote motion (D-45d) | manual UAT + telemetry | operator captures `__rebno.firstRemoteNameplateHistory` on 30s walk | manual + buffer |
| REQ-CLI-06 | wall_border block not rendered (D-61) | int | NEW: `apps/client/src/__test__/room-renderer.test.ts` asserting no wall rect for mvp-room layout | ❌ Wave 0 |
| REQ-CLI-06 | deriveLayoutBounds emits 4 wall polys + correct room_size (D-62) | unit | NEW: `apps/server/test/layout-derive.test.ts` | ❌ Wave 0 |
| REQ-CLI-06 | step() blocks player at derived collision boundary (D-62) | int | NEW: `apps/server/test/d62-derived-collision.integ.test.ts` | ❌ Wave 0 |
| REQ-CLI-06 | Room layout broadcast on cookie-resume onJoin (D-60) | e2e | `cookie-reload.e2e.test.ts` (extend with `data-room-rendered` assertion) | ✅ (extend) |
| REQ-CLI-07 | Nameplate Y stable across 60 ticks of remote motion (D-45d) | unit | NEW: `apps/client/src/__test__/nameplate-stability.test.ts` driving the lerp path | ❌ Wave 0 |
| REQ-CLI-08 | Two-player smoke (login + walk + chat) passes on staging | e2e + operator | `cli-08-*.e2e.test.ts` + operator HUMAN-UAT | ✅ |
| REQ-CLI-08 | CLI-08 milestone mp4 captured on PASS verdict | manual | operator screen-record | manual |
| REQ-SRV-03 | Eviction loop logs structured events for every iteration (D-51c spike) | int | NEW: `apps/server/test/d51c-eviction-trace.integ.test.ts` | ❌ Wave 0 (spike) |
| REQ-SRV-03 | Dup-login: A2 reaches GameScene, A1 reaches LoginScene (D-51c fix) | e2e | `cli-08-dup-login.e2e.test.ts` (extend with no-banner-loop assertion) | ✅ (extend) |
| REQ-SRV-03 | room_layout always broadcast on onJoin (D-60) | int | NEW: `apps/server/test/d60-always-emit-layout.integ.test.ts` | ❌ Wave 0 |
| REQ-SRV-14 | step() respects derived collision_polys boundary (D-62) | int | shared with above d62-derived-collision.integ.test.ts | ❌ Wave 0 |

### Sampling Rate

- **Per task commit:** `pnpm --filter @rebno/{server|client|game-logic} test --run` + `pnpm --filter @rebno/{server|client} typecheck` for the package(s) touched.
- **Per wave merge:** ALL three packages' vitest suites + full Playwright `chromium` suite (skip webkit/firefox per project convention).
- **Phase gate:** Full vitest + e2e GREEN on staging + `pnpm trace:check` clean (or known-deferred non-phase findings only) + operator UAT PASS + CLI-08 mp4 captured.

### Wave 0 Gaps (test infrastructure that must exist before W1 spikes deploy)

- [ ] `apps/client/src/render/legacy-origin.test.ts` — D-63 drift detection (NEW file; covers REQ-CLI-04, REQ-CLI-08).
- [ ] `apps/server/test/layout-derive.test.ts` — D-62 derive unit tests (NEW file; covers REQ-CLI-06, REQ-SRV-14).
- [ ] `apps/server/test/d62-derived-collision.integ.test.ts` — step() boundary integration (NEW; covers REQ-SRV-14).
- [ ] `apps/server/test/d60-always-emit-layout.integ.test.ts` — onJoin emits layout on cookie-resume + fresh-auth paths (NEW; covers REQ-SRV-03).
- [ ] `apps/server/test/d58c-direction-broadcast.integ.test.ts` — facing broadcast end-to-end (NEW; covers REQ-CLI-04 / REQ-SRV-14).
- [ ] `apps/server/test/d51c-eviction-trace.integ.test.ts` — spike's structured log assertions (NEW; covers REQ-SRV-03).
- [ ] `apps/client/src/__test__/room-renderer.test.ts` — wall_border drop assertion (NEW; covers REQ-CLI-06).
- [ ] `apps/client/src/__test__/nameplate-stability.test.ts` — D-45d residual lerp test (NEW; covers REQ-CLI-07).
- [ ] **Extend** `apps/client/src/__test__/reconciler.test.ts` — D-57b + D-58b regression cases (covers REQ-CLI-04).
- [ ] **Extend** `apps/client/test/e2e/cookie-reload.e2e.test.ts` — D-60 cookie-resume layout assertion (covers REQ-CLI-06).
- [ ] **Extend** `apps/client/test/e2e/cli-08-dup-login.e2e.test.ts` — D-51c no-banner-loop assertion (covers REQ-SRV-03).
- [ ] Framework install: none needed (vitest + Playwright already configured).

## Wave Assignment (recommended; planner finalizes)

| Wave | Tasks | Parallelism | Rationale |
|------|-------|-------------|-----------|
| **W1 — foundation** | D-63 helper + drift test; D-61 data + render drop; D-60 unconditional emit | All parallel-safe (different files, no inter-dependency) | Foundation: D-63 must land before D-55c; D-60/D-61 are independent direct fixes. |
| **W1.5 — spike instrumentation** | D-51c server pino + client ring buffer; D-58c direction telemetry; D-45d lerp inspection extension | Parallel with W1 | Diagnostic-only; no behavior change. Deploys to staging immediately for operator capture. |
| **W2 — helper-driven fixes** | D-55c origin refactor (depends on W1 D-63); D-62 derive helper + broadcast (depends on schema extension) | Sequential within: D-62 schema → D-62 derive → D-62 broadcast | D-55c blocks on helper module; D-62 has 3 internal sub-steps. |
| **W3 — spike-output fixes** | D-51c fix (conditional on spike); D-58c fix (conditional on spike); D-45d fix (conditional on spike); D-55d gating predicate (no spike — direct fix) | Sequential per CONTEXT HARD gate 2 (per-fix UAT) | Each fix lands + redeploys + UAT before next starts. |
| **W4 — verification + regression + mp4** | Regression tests for D-57b/D-58b; full e2e on staging; operator HUMAN-UAT; CLI-08 mp4 capture | Sequential | Final gate; mp4 is the closing artifact. |

### Wave boundary discipline (carry from CONTEXT)

- After W1 + W1.5 land → operator captures spike telemetry on staging (one staging deploy at this boundary).
- After W2 → operator UAT per-fix (per HARD gate 2).
- After W2→W3 → staging redeploy + 5-test smoke (SOFT gate 4).
- After each W3 fix → operator UAT per-fix (per HARD gate 2).
- W4 → full e2e GREEN on staging + UAT + mp4 capture; phase closes only if all four HARD gates green + mp4 captured.

## D-51c Escalation Criteria (concrete rubric)

The spike's outcome must be classified BEFORE the fix plan lands. If ANY of the following are true after spike telemetry capture, escalate D-51c to its own phase and bring D-51c "diagnostic only" closure into 06.4 (i.e. the spike is the deliverable; the fix is Phase 06.5+).

| Signal | Escalate? | Rationale |
|--------|-----------|-----------|
| Spike points to Colyseus seat-reservation timeout fix requiring config tweak in `colyseus.define` options (e.g. `seatReservationTime`) | **No** — fix in 06.4 | Single-line config edit; no API surface change. |
| Spike points to needing a new `client.send`/`client.leave` API not present in 0.17 | **Yes** | Requires 0.17 → 0.18 upgrade; major dep bump must own a phase. |
| Spike points to needing a Colyseus.js client-side state-machine override or monkey-patch | **Yes** | Brittle; needs dedicated risk analysis. |
| Spike points to the project's own reconnect-state-machine in `colyseus-client.ts` mis-handling code 4001 | **No** — fix in 06.4 | Project-owned code; predictable fix scope. |
| Spike points to the client `force_reset` handler re-entering Colyseus.joinOrCreate after eviction (looping reconnect) | **No** — fix in 06.4 | Handler-level guard fix; small surface. |
| Spike points to multiple root causes (e.g. server timing + client handler bug) requiring separate redesigns | **Yes** | Bundle hides regressions; own-phase enforces sequencing. |

**Decision point:** Spike summary in `06.4-XX-SUMMARY.md` MUST contain an "Escalation classification" section answering each row above; planner aborts 06.4 fix plan and writes 06.5-context-seed.md if any "Yes" row triggers.

## External-System Gotchas (carry from 06.3 — re-affirm in plans)

- **Worktree merge cwd drift:** `cd` to repo root BEFORE `git merge $WT_BRANCH`. Plans that use worktrees must include this step explicitly.
- **Staging deploy ordering:** `vite build` FIRST, re-copy atlas-mvp.json + extracted/.../meta.json AFTER (vite wipes public/ on rebuild). For D-63 drift test, ensure the meta.json fixture is in the test-runner's classpath, not just public/.
- **CI rebuilds dist/ from source:** local stale dist pass ≠ CI pass. After D-62 schema extension (intents.ts), run `pnpm --filter @rebno/protocol build` before claiming tests pass locally.
- **`__rebno` test-infra hooks MUST be unconditional:** no env gate. Plans that introduce new fields (e.g. `lastEvictionEvents`, `localDirection`) follow this.
- **CSP `img-src` needs `blob:`** for Phaser atlas PNG (carry; no change this phase but reminder for any new atlas asset).

## Sources

### Primary (HIGH confidence — verified via direct code read this session)

- `apps/server/src/RebnoRoom.ts` lines 100-275, 377-644, 800-880 — onCreate/onJoin/broadcast/tickLoop/toWorldState
- `apps/client/src/render/PlayerRenderer.ts` lines 320-432, 506-678 — onSimulationTick/addRemote/startTeleportAnim
- `apps/client/src/render/Nameplate.ts` lines 1-165 — full file; integer-snap already shipped at line 125
- `apps/client/src/render/SpriteStateMachine.ts` lines 1-140 — deriveFacing logic; lastFacing preservation on zero velocity
- `apps/client/src/scenes/GameScene.ts` lines 309-617, 830-870 — onRemoteAdd/onRemoteRemove/sim-tick-loop
- `apps/client/src/render/RoomRenderer.ts` lines 260-318 — wall_border legacy fallback block (D-61 drop target)
- `apps/client/src/prediction/reconciler.ts` lines 1-80 — D-57b/D-58b fix already in code (comment trail at line 53-57)
- `apps/server/rooms/mvp-room/000.json` lines 1-30, 1960-1981 — tile shape + wall_border block @ 1970-1979
- `apps/server/rooms/mvp-lobby/000.json` lines 1-15 — reference shape (HAS collision_polys + room_size)
- `apps/client/public/atlas-mvp.json` lines 1-30 — NaviStandD frame metadata (w=36, h=48)
- `extracted/client-5-8/sprites/0000-NaviStandD/meta.json` — bboxBottom=46, width=36, height=48 (D-63 source-of-truth)
- `packages/protocol/src/intents.ts` lines 105-200 — layoutSchema (legacy + new); newLayoutSchema lacks collision_polys/room_size
- `.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` — full file
- `.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` — full file (verdict + per-finding closure)
- `.planning/phases/06.4-cycle-5-gap-closure-d-51b-d-55b-d-55c-d-57b-d-58b-d-60-d-61-/06.4-CONTEXT.md` — full file (locked decisions)
- `apps/client/test/e2e/cli-08-dup-login.e2e.test.ts` — D-51 fixture pattern (carry-forward shape)
- `CLAUDE.md` — full file (project instructions, extracted constants, traceability)
- `.planning/REQUIREMENTS.md` — REQ rows for CLI-04, CLI-06, CLI-07, CLI-08, SRV-03, SRV-14
- `.planning/config.json` — workflow.nyquist_validation=true confirmed

### Secondary (MEDIUM confidence — citation present, not independently verified this session)

- `STACK.md` Colyseus 0.17.x version pin — referenced via CLAUDE.md; not re-verified
- ADR 0001 Phaser 3.90 lock — referenced via REQUIREMENTS.md CDOC-04 row

### Tertiary (LOW confidence — assumed; flagged in Assumptions Log)

- A1 Colyseus 0.17 initial-state-sync onAdd is synchronous (Assumption A1)
- A2 Colyseus schema add of new field is wire-backward-compatible (Assumption A2)
- A3 0.35-factor remote lerp is the D-45d residual flicker source (Assumption A3 — confirmed by spike)
- A4 TeleIn variant height=100 in code, not 64 in CONTEXT scope-hint (Assumption A4 — strongly verified by direct read but planner should re-confirm)

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — no new packages; all libraries already in use, version pins inherited from prior phases.
- Architecture: HIGH — every architectural decision in CONTEXT.md was cross-referenced against the live codebase; no claims rest on training-data assumptions.
- Pitfalls: HIGH for Pitfalls 1, 4, 5, 6 (direct code evidence); MEDIUM for Pitfalls 2, 3 (rely on Colyseus 0.17 schema semantics from training data — flagged as A1, A2).

**Research date:** 2026-05-15
**Valid until:** 2026-06-14 (30 days; stable codebase + locked CONTEXT)

---

*06.4-RESEARCH.md written from direct codebase inspection + 06.3-CONTEXT.md + 06.3-VERIFICATION.md + 06.4-CONTEXT.md (locked decisions) + CLAUDE.md (project instructions). All file paths absolute, all line numbers verified at read time. Planner consumes this as the prescriptive map; spike outputs (D-51c, D-58c, D-45d) supersede A3 and inform W3 fix plans.*
