# Phase 06.3: cycle-4 gap-closure D-40/D-45/D-51/D-53/D-54/D-55/D-56/D-57/D-58/D-59 + D-52 diag — Research

**Researched:** 2026-05-13
**Domain:** Phaser 3 renderer, Colyseus 0.17, client prediction/reconciler, browser font APIs
**Confidence:** HIGH (all claims verified against codebase source; no external fetch needed)

---

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

### Locked Decisions

**D-40/D-54:** Spike-first, end-to-end payload trace. Wave 2 fix conditional on spike output. Rooms instrumented: MVP-lobby + 1 sanity-check room. Telemetry: BOTH `window.__rebno` + server pino log. Spike exposes: `layoutTilesCount`, `walkableCellsTrue`, `walkableCellsTotal`, `tilesOOB`, `payloadHasCollisionPolys`, `payloadWallBorder`. Wave 2 fix paths are one of 4 hypotheses.

**D-45 Plan A:** `NAMETAG_OFFSET_Y` constant 16 → 1. Unit test in `nameplate.test.ts` MUST update to assert new expected y values. Phaser-vs-legacy font-metric gap documented in test comments.

**D-45 Plan B:** Spike: first remote only. Ring buffer to `window.__rebno.firstRemoteNameplateHistory`. Disambiguate sub-pixel jitter / `displayHeight` oscillation / `Phaser.Text` re-render at fractional Y / `scaleY` instability. Atlas confirms NaviStandD + NaviRunD both 36×48 — frame-bbox flicker hypothesis weak.

**D-45 Plan C:** Researcher reads `0042-player/events/Draw.dnd.json` + `0000-server/events/Draw.dnd.json` for depth_set values. Replicate legacy depth-sort exactly. Set `Nameplate.text.setDepth()` per legacy spec.

**D-51:** Spike-first hybrid (Playwright multi-context fixture + server + client envelope instrumentation). Single-cycle commit. If Colyseus version bump required, escalate to own phase.

**D-52/D-57/D-58:** Single folded spike. Shared `window.__rebno.lastReconcile*` diagnostic surface. Three distinct cadences:
- D-52: rare one-shot snap to spawn coords
- D-57: every-few-seconds micro-freeze → ~500-1000ms jump during motion
- D-58: idle-state desync, remote ≠ local AFTER motion stops

**D-53:** Two-part single plan: (a) publish `spawnDelayTicks` + remaining count to `window.__rebno`; (b) audit `ensureLocal` reinit path. `playerRenderer.test.ts` MUST cover reinit path.

**D-55:** Legacy spec extraction + Phaser tween + state machine plumbing. Operator OVERRIDE — included in 06.3. Integrate with D-53 spawnDelayTicks 30-tick hold.

**D-56:** Small CSS/Phaser styling bump on `ChatHUD.ts`. Single small plan.

**D-59:** Gate Phaser boot on `document.fonts.ready`. Add `<link rel="preload" as="font" type="font/woff2" crossorigin>` in `index.html`. Regression test asserts `document.fonts.check('1em Fixedsys')` returns true before any Nameplate.

**GREEN gate uplift (MANDATORY):** 4 gates: (1) HARD: all cli-08-* + camera-follow Playwright GREEN on staging; (2) HARD: operator visual per-fix UAT at Wave-2 boundary; (3) HARD: e2e assertion-correctness review (expected values cite canonical refs); (4) SOFT: staging redeploy + smoke at Wave-2 → Wave-3 boundary.

**CLI-08 mp4:** Capture this cycle on PASS verdict.

### Claude's Discretion

Reconciler spike instrumentation approach (extend `window.__rebno` fields for idle-state). Exact Playwright fixture structure for D-51 multi-context. Exact pino log structure for D-40 server-side telemetry. TeleportIn/Out animation duration and tween parameters (from legacy spec). Chat box CSS bump specifics.

### Deferred Ideas (OUT OF SCOPE)

- Colyseus 0.17 → 0.18 upgrade
- Glyph-metric-aware nameplate anchoring (canvas.measureText)
- BitmapText / sprite-font glyphs for nameplates
- Multi-room collision/payload generalization (beyond mvp-lobby + 1 sanity-check room)
- Phase 7 carry-forward: falls/abyss, ice/movement tiles, Hexport, JokerShell, top/left/right borders, multi-floor depth, depth-registry consolidation, all rooms beyond mvp-lobby, persistCharacter FK failures, prod provisioning
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| REQ-CLI-04 | Animation contract | D-45 nameplate offset/flicker/depth; D-53 spawn delay reinit; D-55 teleport anim; D-58 idle convergence; D-59 nameplate font race |
| REQ-CLI-06 | Renderer / atlas / asset pipeline | D-40 room_layout payload; D-54 collision extent mismatch and OOB tile render |
| REQ-CLI-07 | UI rendering | D-56 chat box readability |
| REQ-CLI-08 | CLI-08 hard milestone re-gate | Cross-cutting; mp4 capture on PASS; all e2e gates |
| REQ-SRV-03 | Authoritative server state | D-51 evict policy regression |
| REQ-SRV-14 | Per-entity sim contract | D-52/D-57/D-58 reconciler family |
</phase_requirements>

---

## Summary

Phase 06.3 closes 10 UAT findings from cycle-4 (staging build `688d334`, 2026-05-13). Three cycle-3 fixes regressed (D-45/D-51/D-53), one prior partial fix still has collision-extent problems (D-40 → D-54), and three new findings emerged (D-57/D-58/D-59) plus D-55/D-56 were operator-included. The cycle pattern is 06.1 ✗ → 06.2 ✗ → 06.3 attempt, with anti-recurrence discipline as a first-class scope element.

Codebase inspection reveals several root causes that are clearer than the CONTEXT.md hypotheses. Most critically: (a) the reconciler's `tweenTo` adapter in `GameScene.ts:511` does `setPosition` (no actual tween), so EVERY server snapshot fires a hard position snap — this is the root of D-57 micro-freeze; (b) `Nameplate.ts` has zero `setDepth()` call, defaulting to Phaser depth 0, while player sprites compute depth ≈ 443 at spawn — nameplates draw behind sprites (D-45 Plan C root cause confirmed); (c) the legacy Draw.dnd.json `draw_text(x + sprite_width/2 - string_width(name)/2, y - 16, name)` formula uses `y - 16` where y is the sprite's FEET (origin 0,0 at top-left for GM objects, so y = feet position) — this means the nameplate Y was 16 px above the FEET anchor, not the HEAD; the translation to Phaser must account for the FULL sprite height.

**Primary recommendation:** Tackle D-45 Plan A + D-59 + D-56 as fast fixes in parallel with the W1 spikes; reconciler fix (D-57/D-58) is likely a one-liner in `GameScene.ts:511` (`tweenTo` → `this.tweens.add()`); nameplate depth (D-45 Plan C) requires a constant larger than the player sprite depth formula output.

---

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| D-40/D-54 room_layout collision payload | Server (emit) | Client (consume) | Server encodes + broadcasts; client derives walkable grid |
| D-45 nameplate Y offset / depth | Client render (`Nameplate.ts`) | — | Pure Phaser Text positioning; no wire involvement |
| D-45 nameplate flicker spike | Client render (`PlayerRenderer.ts`) | `window.__rebno` | Diagnostic surface for operator read |
| D-51 dup-login eviction | Server (`RebnoRoom.ts`) | Client (force_reset handler) | Server enforces policy; client handles banner display |
| D-52/D-57/D-58 reconciler | Client prediction (`reconciler.ts`, `predictor.ts`) | Server (snapshot source) | Client-side prediction engine; server sends authoritative positions |
| D-53 spawnDelayTicks | Client render (`PlayerRenderer.ts`) | `window.__rebno` | Local-only state; exposed via telemetry |
| D-55 teleport anim | Client render (`SpriteStateMachine.ts`, `GameScene.ts`) | Legacy spec | New states in existing state machine |
| D-56 chat box readability | Client UI (`ChatHUD.ts`) | — | Pure CSS/DOM styling change |
| D-59 font race | Client boot (`index.html`, `BootScene.ts`) | `document.fonts` API | Asset loading pipeline gate |
| GREEN gate e2e correctness | Client e2e tests | Code reviewer | Test layer cross-checks |

---

## D-40 / D-54 — Server room_layout payload completeness

### What the server actually sends

`RebnoRoom.ts:214-222 broadcastRoomLayout()` and `sendRoomLayoutToClient()` both send:
```typescript
{ type: 'room_layout', room_id, layout_rev, layout_bytes, manifest_sig }
```
where `layout_bytes` = msgpackr-packed `LoadedLayout.layout` (the parsed JSON from disk). [VERIFIED: apps/server/src/RebnoRoom.ts:214-254]

### What `newLayoutSchema` carries

`packages/protocol/src/intents.ts:158-196` — the `newLayoutSchema` defines:
- `room_id`, `version`, `width_tiles`, `height_tiles`, `tile_w`, `tile_h`, `viewport`, `background`, `wall_border`, `tiles[]`, `instances[]`, `spawn_points[]`
- **No `collision_polys` field** — confirmed [VERIFIED: intents.ts:155-196]
- Only `legacyLayoutSchema` (lines 115-153) carries `collision_polys`

### What mvp-room/000.json actually contains

The on-disk layout has: [VERIFIED: apps/server/rooms/mvp-room/000.json]
- `room_id: "mvp-room"`, `width_tiles: 20`, `height_tiles: 20`, `tile_w: 44`, `tile_h: 40`
- `tiles[]`: 324 entries, x range 44–792, y range 40–720
- `wall_border: { color: '#2C3E50', edges: ['top','bottom','left','right'], thickness_tiles: 1 }`
- `spawn_points: [{ name: 'home_portal', x: 440, y: 400 }]`
- **No `collision_polys` field** — as expected for new shape

### Walkable grid derivation analysis

`RoomCollision.deriveWalkableGrid()` builds a 20×20 grid. With 324 tiles spanning cols 1–18, rows 1–18, the resulting grid looks like: [VERIFIED: derived from mvp-room/000.json]
```
....................   (row 0 — wall, all empty)
.##################.   (rows 1-18 — playable, 18×18 walkable)
...
....................   (row 19 — wall, all empty)
```
Cols 0 and 19 are empty (wall). This is **correct** — the wall border is implicitly encoded in the tile layout gap.

### D-54 root cause: NAVI_MASK offset coordinate system mismatch

`constants.ts` defines: [VERIFIED: packages/game-logic/src/constants.ts:65-70]
```typescript
NAVI_MASK = { left: 9, top: 40, right: 26, bottom: 46 }
```
These are **sprite-local coordinates with origin at sprite top-left** (NaviMask bboxLeft=9, bboxTop=40, etc., with NaviMask sprite having originX=0, originY=0). [VERIFIED: extracted/client-5-8/sprites/0034-NaviMask/meta.json]

But `step.ts:175-198` applies them as:
```typescript
probeX + NAVI_MASK.left   // = player.x + 9
py    + NAVI_MASK.top     // = player.y + 40
probeX + NAVI_MASK.right  // = player.x + 26
py    + NAVI_MASK.bottom  // = player.y + 46
```
[VERIFIED: packages/game-logic/src/step.ts:175-198]

The Phaser sprite uses **origin(0.5, 1)** (center-x, bottom-y as feet anchor). [VERIFIED: PlayerRenderer.ts:399] So `player.x` = sprite CENTER X, `player.y` = sprite FEET Y.

The NaviMask is 36 px wide, 48 px tall. In world space:
- sprite top-left = `(player.x - 18, player.y - 48)`
- feet bbox left  = `(player.x - 18) + 9 = player.x - 9` — but code computes `player.x + 9`
- feet bbox right = `(player.x - 18) + 26 = player.x + 8` — but code computes `player.x + 26`
- feet bbox top   = `(player.y - 48) + 40 = player.y - 8` — but code computes `player.y + 40`
- feet bbox bottom = `(player.y - 48) + 46 = player.y - 2` — but code computes `player.y + 46`

**The feet bbox is shifted +18 px right, +48 px down relative to where it should be.** This means:
- The LEFT wall collision triggers when feetLeft_correct (= x - 9) enters col 0 (x < 44), i.e. player.x < 53. But the code treats feetLeft = x + 9, meaning collision triggers when x + 9 < 44 → x < 35. The player can walk LEFT until x ≈ 35 before the left wall blocks — close but offset.
- The RIGHT wall: code feetRight = x + 26 ≥ 836 → blocks at x ≥ 810. Correct would be x + 8 ≥ 836 → x ≥ 828. Right wall blocks 18 px too early (appears to block at wrong point).
- **The BOTTOM wall**: code feetBottom = y + 46. Row 19 starts at y=760. Collision blocks when y + 46 ≥ 760, i.e. y ≥ 714. Correct would be y - 2 ≥ 760 → y ≥ 762. The bottom wall appears to block 48 px too early — consistent with UAT "stops at wrong point."
- **The TOP wall**: code feetTop = y + 40. Row 0 has y < 40. Blocks when y + 40 < 40, i.e. y < 0. Player can walk off the top of the screen! Correct would be y - 8 < 40 → y < 48. TOP wall does NOT block at all in current code (y never goes negative because... actually it might). This matches UAT: "other edges either don't stop player or stop at wrong point."

The LEFT wall happens to partially block because `isFeetBboxWalkable` returns false when `col >= width_tiles` (OOB) AND when `cells[row][col] === 0`. For leftward movement with x + 9 entering col 0 (which is empty), it does block — just at the wrong x position.

**Fix for D-54:** Change NAVI_MASK in `constants.ts` to be origin-relative, OR adjust the offsets in step.ts to account for sprite origin. Cleanest fix: change NAVI_MASK to store offsets relative to `player.x` (center-feet):
```typescript
// Corrected: relative to player.x (center-x) and player.y (feet)
NAVI_MASK = { left: -9, top: -8, right: 8, bottom: -2 }
```
Or equivalently, fix the constants to match what the code expects (center-x, feet-y coordinate system): `left = bboxLeft - spriteWidth/2 = 9 - 18 = -9`, `top = bboxTop - spriteHeight = 40 - 48 = -8`, `right = bboxRight - spriteWidth/2 = 26 - 18 = 8`, `bottom = bboxBottom - spriteHeight = 46 - 48 = -2`.

**This is likely the real D-54 root cause** — the walkable grid is correct, the tile data is correct, but the feet-bbox probe coordinates are wrong, explaining why only the LEFT wall approximately blocks (because col 0 is empty and the offset happens to trigger that path under some conditions) while the other walls trigger at wrong positions.

### D-40 remaining issue: gray-border tiles outside region

The `wall_border` field is processed by `RoomRenderer.renderNew()` (line 244+) which draws Phaser.GameObjects.Rectangle strips for each edge. These rectangles are drawn at wall positions based on `layout.tile_w / tile_h` dimensions. The walkable grid ALSO blocks based on empty cells. There may be a visual mismatch between the Rectangle strip extent and where the collision actually stops the player. The spike should confirm whether the outer-gray-tiles complaint is about the Rectangle wall_border visuals vs the walkable-grid collision boundary. [VERIFIED: RoomRenderer.ts:244-295]

### Spike implementation plan (W1)

Server-side pino logging in `RebnoRoom.sendRoomLayoutToClient()` — log `layout.tiles.length` before encoding. Client-side `window.__rebno` in `RoomRenderer.renderNew()` — extend the existing telemetry block (lines 220-239) with `walkableCellsTrue`, `walkableCellsTotal` from the derived grid.

---

## D-45 — Nameplate three sub-issues

### Plan A: Offset constant (16 → 1)

**Legacy GML spec (D-45 Plan C basis, also Plan A ground truth):**

`extracted/client-5-8/objects/0042-player/events/Draw.dnd.json` (remote player draw): [VERIFIED: exact quote from file]
```
draw_text(x + (sprite_width/2) - (string_width(p_name[pid])/2), y - 16, global.p_name[pid])
```
`extracted/client-5-8/objects/0000-server/events/Draw.dnd.json` (local player draw): [VERIFIED: exact quote from file]
```
draw_text(x + (sprite_width/2) - (string_width(global.playername)/2), y - 16, global.playername)
```

In GameMaker, object `y` is the TOP-LEFT y of the object (default origin 0,0). For the player sprite, `y` = the sprite's TOP-left corner, so the sprite occupies y to y+sprite_height. The sprite bottom (feet) = y + sprite_height. The text is drawn at `y - 16`, i.e. **16 px above the sprite's TOP edge**.

In Phaser (as currently coded), `Nameplate.follow()` receives `spriteY = sprite.y` where `sprite.y` is the FEET (origin 0.5, 1 — y = feet). So the Phaser equivalent should be: `textY = sprite.y - sprite.displayHeight - 16`.

The current code does exactly this: `const textY = spriteY - spriteHeight - NAMETAG_OFFSET_Y` with `NAMETAG_OFFSET_Y = 16`. [VERIFIED: Nameplate.ts:98-100]

**If this is correct, why did the CONTEXT say "16 → 1"?**

The CONTEXT.md states the overshot was 15 px too high (bottom-of-glyph to top-of-head was 10 px in legacy BNO vs 25 px in current build). The root cause per CONTEXT is: `Phaser.GameObjects.Text` bbox includes line-height descender padding below the visible glyph, whereas GM `draw_text` does NOT. With Fixedsys at 16px, this padding is approximately 15 px. So the **visible glyph bottom** lands at approximately `textY + glyph_ascent` where `textY` is the Phaser origin (bottom of text bbox). The text object origin is (0.5, 1), so the visible glyph is **above** the text origin by the descender height.

Net effect: The NAMETAG_OFFSET_Y should be reduced from 16 to approximately 1 so that the visible bottom of the glyph is ~16 px above the sprite top (matching legacy). The descender in Phaser Text for a typical 16px pixel font is approximately 15 px below the visible baseline. [ASSUMED — Phaser Text line-height descender padding not verified against Phaser 3.90 source; infer from operator measurement (25 px actual vs 10 px legacy = 15 px overshoot)]

**Safe approach for Plan A:** Change `NAMETAG_OFFSET_Y = 16` to `NAMETAG_OFFSET_Y = 1` as instructed by CONTEXT. Update the unit test to assert the new formula. Add a comment documenting the Phaser descender-padding adjustment.

The formula stays: `textY = spriteY - spriteHeight - NAMETAG_OFFSET_Y`.

### Plan B: Flicker spike (remote nameplate only)

**Root hypotheses ranked by evidence:**

1. **Sub-pixel jitter from Phaser tweens** — the reconciler `tweenTo` adapter at `GameScene.ts:511` does `local.setPosition(x, y)` (hard snap, no actual tween). [VERIFIED: GameScene.ts:511] For remote players, `onSimulationTickRemote()` calls `r.sprite.setPosition(x, y)` and `r.nameplate.follow(x, y, r.sprite.height || 48)` per-tick [VERIFIED: PlayerRenderer.ts:296-303]. The `r.sprite.height` is a Phaser property. If the sprite is a Rectangle (fallback) it may return 0 initially, causing `height || 48` to still be 48. No sub-pixel issue here in theory.

2. **`displayHeight` oscillation** — `onSimulationTickRemote` uses `r.sprite.height || 48`. Note: it uses `.height` NOT `.displayHeight`. For a properly loaded Phaser Sprite with origin(0.5,1) and atlas frame `36×48`, `.height` should be 48 consistently. [VERIFIED: PlayerRenderer.ts:303]
   - But `onSimulationTickLocal` (line 213) uses `this.local.sprite.height || 48`. Both use `.height`, which should be stable.
   - `follow()` is also called from `setLocalPosition()` (line 169) using `this.local.sprite.height` — this could be 0 before atlas loads.
   - The `height` field on `Phaser.GameObjects.Sprite` is the frameHeight (not scaled), while `displayHeight` includes scaleY. Both should be constant if scaleY = 1.

3. **Phaser.Text repositioning at fractional Y** — Phaser Text with `roundPixels: true` (set in main.ts via `render: { roundPixels: true }`) [VERIFIED: main.ts:34] should snap text positions to integers. However, if `spriteY` from the extrapolator produces sub-pixel values and `spriteHeight` is integer, `textY = spriteY - spriteHeight - 1` could be fractional → Phaser Text snaps, causing one-pixel jumps that visually appear as flicker.

4. **Remote player position interpolation via RemoteExtrapolator** — `onRemoteSnapshot` pushes snapshots to `this.extrapolator?.pushSnapshot()`. Then in `update()` loop the remote player position is taken directly from `room.state.players` per-tick (`p.x ?? 0, p.y ?? 0` at GameScene.ts:799-801), NOT from the extrapolator. The extrapolator is pushed to but never sampled in the current update loop. So remotes are positioned from raw server state (discrete server-tick positions), not smoothed.

**Ring buffer fields for Plan B spike:**
```typescript
window.__rebno.firstRemoteNameplateHistory: Array<{
  nameplate_y: number,
  sprite_y: number,
  sprite_height: number,    // .height property
  sprite_displayHeight: number, // .displayHeight property
  sprite_scaleY: number,
  sprite_originY: number,
  ts: number,
}>
```
Capture at each `Nameplate.follow()` call for the FIRST remote player only (avoid map iteration cost). Cap at 60 entries.

### Plan C: Legacy depth spec and fix

**Legacy depth values from extracted GML:**

`extracted/client-5-8/objects/0000-server/events/Draw.dnd.json` — local player draws nameplate when `sprite_index != TeleIn && sprite_index != TeleOut && ...`. No explicit depth_set in the Draw event. [VERIFIED: Draw.dnd.json]

The depth_set for player objects is in `Step.gml` as referenced by `PlayerRenderer.ts:43`: `depth_set(0, 43)`. [VERIFIED: PlayerRenderer.ts comment line 43]

The `computeDepth(0, 43, y)` formula: `gmDepth = round(1000*0 - (y + 43)) = -(y + 43)`. Phaser depth = `-gmDepth = y + 43`. At spawn (y=400): **phaserDepth = 443**.

**Current nameplate depth: 0 (Phaser default — never set)** [VERIFIED: Nameplate.ts — no setDepth call anywhere]

**Problem confirmed:** Player sprite depth = 443 at spawn. Tile sprite depth = `-(1000*2 - (tile.y + 0)) = tile.y - 2000`, e.g. for y=40: depth = 40 - 2000 = -1960. Tiles have very negative depth (draw behind everything). Player sprites have depth ≈ y + 43 ≈ 440+ (draw above tiles). Nameplate text has depth = 0 — draws behind player sprites (depth 0 < 443) but above tiles (depth 0 > -1960). Result: nameplate is visible above tiles but hidden behind player sprites. [VERIFIED: RoomRenderer.ts:158-159 tile depth formula, PlayerRenderer.ts:58-61 computeDepth]

**The legacy GML doesn't assign a separate depth to the nameplate text** — in GameMaker, `draw_text()` draws immediately in the current object's Draw event. The depth of rendered elements in GameMaker is determined by object depth, not draw call order. In BNO, if the player object has a certain depth, the text drawn IN the player's Draw event is rendered AT that depth. There's no separate depth for the text.

**Fix for D-45 Plan C:** Set nameplate text depth to be ABOVE the player sprite. The nameplate should always be on top of the player sprite. Formula:

```typescript
// In Nameplate constructor and follow():
// Nameplate text must draw ON TOP OF all sprites.
// Use a very large positive Phaser depth (higher = on top).
// 10000 exceeds any player sprite depth (max ≈ y + 43 ≈ 800 + 43 = 843 at room bottom).
const NAMEPLATE_DEPTH = 10000;
this.text.setDepth(NAMEPLATE_DEPTH);
```

Or alternatively, match the player sprite depth plus a large offset:
```typescript
// In Nameplate.follow() — update depth with player position
const playerDepth = computeDepth(PLAYER_DEPTH_LAYER, PLAYER_DEPTH_Y_OFFSET, spriteY);
this.text.setDepth(playerDepth + 1000); // always above the player sprite
```

The second approach is more faithful to BNO semantics (nameplate renders in same pass as player, always above). The fixed constant `10000` is simpler. Either works; recommend the fixed constant for clarity.

**Export `computeDepth` from PlayerRenderer.ts to Nameplate.ts is unnecessary** — the constant `10000` is sufficient.

---

## D-51 — Replace-prior-session eviction crashes both tabs

### Current server implementation (code review)

`RebnoRoom.ts:409-495` correctly implements: [VERIFIED: RebnoRoom.ts:409-495]
1. Collect `priorSessionIds` before mutation
2. Delete state (heldInputs + state.players + authBySession) BEFORE calling `priorClient.leave(4001)`
3. Send `force_reset` to prior client
4. Call `priorClient.leave(4001)` in try/catch

**The implementation looks correct from a single-read perspective.** The both-tabs-crash symptom suggests a different failure mode.

### Hypotheses for both-tabs crash

**H2 (seat-race / onJoin sequencing):** The eviction block runs inside `onJoin` of the NEW session. If `state.players.delete(priorSessionId)` triggers a Colyseus MapSchema `onRemove` callback that the NEW client also receives (because the NEW client just joined and is subscribed to the room's state), the new client may interpret the `onRemove` as its OWN player being removed, triggering `onRemoteRemove(newSessionId)`. Unlikely since sessionIds are different, but worth instrumenting.

**H3 (broadcast side-effect):** The `force_reset` event is sent via `priorClient.send()`, not `this.broadcast()`. However, Colyseus may synchronize the state patch (from `state.players.delete(priorSessionId)`) to ALL clients in the same tick, before the `leave(4001)` fires. If the new client receives the MapSchema patch removing the prior player AND no `onAdd` for itself yet (because onJoin hasn't completed), it may render into a broken state.

**H4 (client-side handler error):** The `onForceReset` handler in `GameScene.ts:333-345` calls `this.room?.leave()` and then `this.scene.start('LoginScene', ...)`. [VERIFIED: GameScene.ts:333-345] If the NEW client also somehow receives this transition (e.g., same tab, wrong routing), both tabs would crash. But with separate Chrome profiles (different contexts), this path is implausible.

**H2a (most likely): `force_reset` sent to wrong client.** If `priorClient` lookup via `this.clients.find(c => c.sessionId === priorSessionId)` returns `undefined` (timing race — the prior client's WS is already half-closed) but the `force_reset` event is broadcast-adjacent, it could reach the new client. The `priorClient.send()` is wrapped in try/catch, so this should be safe. But the `state.players.delete()` triggers a Colyseus patch that is broadcast to all clients including the NEW one.

**H2b (state.players.delete timing):** The new client's `onJoin` hasn't yet added the new player to `state.players` at the point where the eviction deletes the prior player. The new client receives a MapSchema `onRemove` for the prior player. If the client interprets any `onRemove` as "I was removed," it would break. Looking at `colyseus-client.ts:153-155`:
```typescript
players.onRemove((_player, sessionId) => {
  if (sessionId !== room.sessionId) cb.onRemoteRemove?.(sessionId);
});
```
[VERIFIED: colyseus-client.ts:153-155] The `room.sessionId` is the NEW client's own sessionId. `players.onRemove` fires for `priorSessionId !== room.sessionId`, so `onRemoteRemove(priorSessionId)` fires — which is CORRECT (the new client removes the prior player sprite). This alone shouldn't crash.

**Remaining hypothesis: the new client's room state snapshot is corrupted.** When `state.players.delete(priorSessionId)` fires mid-onJoin, the Colyseus state sync may include the new player's `onAdd` and the prior player's `onRemove` in the same patch. The ordering of these callbacks on the client side may be non-deterministic. If `onRemoteRemove(priorSessionId)` fires before `onLocalJoin(newPlayer, newSessionId)`, the new client's render state could fail to initialize.

**Instrumentation needed:**
- Server: log timestamp of each step in the eviction block (players.delete, priorClient.send, priorClient.leave) + timestamp of new player add to state.players
- Client: log every inbound callback (onLocalJoin, onRemoteAdd, onRemoteRemove, onForceReset) with timestamp and sessionId
- Wrap the `onForceReset` handler client-side in try/catch → `window.__rebno.lastClientError`

### Playwright multi-context fixture for D-51

Colyseus 0.17 `allowReconnection` default timeout: ~10 seconds. The eviction loop must complete well within 2 seconds (seat-reservation-timeout). [ASSUMED — Colyseus 0.17 seat reservation timeout not verified from official docs in this session; based on training knowledge]

Playwright fixture pattern for two browser CONTEXTS (separate auth cookies):
```typescript
// D-51 fixture: two contexts with SAME credentials
const ctxA1 = await browser.newContext(); // first login as uat_a
const ctxA2 = await browser.newContext(); // second login as uat_a (triggers eviction)
const pageA1 = await ctxA1.newPage();
const pageA2 = await ctxA2.newPage();
// Login A1, then login A2 with same account
// Assert A1 sees LoginScene with eviction banner
// Assert A2 successfully reaches GameScene
```
This mirrors the UAT scenario of "two separate Chrome profiles, same account."

---

## D-52 / D-57 / D-58 — Reconciler-snap family

### Root cause for D-57 (micro-freeze every few seconds) — CONFIRMED

`GameScene.ts:511` defines the reconciler sprite adapter's `tweenTo`:
```typescript
tweenTo: (x: number, y: number, _ms: number) => local.setPosition(x, y),
```
[VERIFIED: GameScene.ts:509-511]

This means `ReconcileEngine.onServerSnapshot()` ALWAYS does a hard `setPosition()` regardless of whether divergence is above or below the threshold. The `LERP_DURATION_MS = 100` is computed but never used — the tween is a no-op. Every server snapshot (at ~20 Hz server tick rate) results in a hard position snap. At low divergence, the snap is small and invisible. But when accumulated prediction drift exceeds `DIVERGENCE_THRESHOLD_PX = 22 px`, `setPosition()` slams the sprite 22+ px. [VERIFIED: reconciler.ts:61-98]

The server runs at 20 Hz (TICK_MS in accumulator.ts) while the client sim runs at 30 Hz. Over ~1-2 seconds, the client and server diverge by approximately (30 - 20) ticks × 5 px/tick = 50 px, which exceeds the 22 px threshold → hard snap → visual freeze + jump. This matches the "every few seconds" D-57 cadence precisely.

**Fix for D-57:** Implement the actual tween in `GameScene.ts:511`:
```typescript
tweenTo: (x: number, y: number, ms: number) => {
  this.tweens.add({ targets: local, x, y, duration: ms, ease: 'Linear' });
},
```
This makes the lerp-correction path actually smooth.

However, this alone may not fully fix D-57. The snap threshold (22 px) triggers hard setPosition when divergence ≥ 22 px. With 20 Hz server vs 30 Hz client, drift accumulates until threshold → snap. The fix is twofold: (1) implement actual tween; (2) consider whether threshold or lerp duration needs tuning. The CONTEXT says spike-first — the diagnostic surface (`lastReconcileReason`) will show how often 'divergence-threshold' fires vs 'lerp-correction'.

### Root cause for D-58 (idle-state desync) — CONFIRMED

`SpriteStateMachine.deriveFrame()` at line 184-193: [VERIFIED: SpriteStateMachine.ts:184-193]
```typescript
if (!isRunning) {
  return { ..., cyclePhase: 0, tickAccumulator: 0, isRunning: false, frameKey: STAND_FRAME[facing] };
}
```
When `vx === 0 && vy === 0`, the player is in Stand state. The reconciler's `onServerSnapshot()` is invoked on every `onLocalSnapshot` callback (from `GameScene.ts:523`). [VERIFIED: GameScene.ts:521-524]

In `ReconcileEngine.onServerSnapshot()`, the divergence is computed between the current sprite position and the snapshot position. If the player has stopped and both client and server agree on position, divergence should be ~0 and no correction fires. BUT if the sprite's displayed position drifted during motion and never converged (due to the broken tweenTo no-op), the snapshot at idle may still show divergence and fire a snap.

Additionally, `predictor.ts:93-114 predictTick()`: when no inputs are queued (`latest = this.unacked.at(-1)` returns undefined), the `inputs` Map is empty. `step()` is called with an empty inputs map, so `dx = 0, dy = 0`. The player stays at its current position. `prediction.getLocalState()` returns the last position. But if the server position differs (due to prior drift never reconciled), `onServerSnapshot()` detects divergence and snaps. [VERIFIED: predictor.ts:93-114]

The D-58 idle desync is primarily a consequence of D-57 (drift never reconciled during motion → snap mismatch at idle). Fixing D-57 (actual tween) should resolve most of D-58. However, there's a secondary issue: if `tweenTo` runs an active Phaser tween and then another snap fires mid-tween, the tween target position is overridden. Need to kill active tweens before starting a new one:
```typescript
tweenTo: (x, y, ms) => {
  this.tweens.killTweensOf(local);
  this.tweens.add({ targets: local, x, y, duration: ms, ease: 'Linear' });
},
```

### Root cause for D-52 (perfect-spawn reset, rare) — SPIKE ONLY

No reproduction during cycle-4 UAT. The existing `window.__rebno.lastReconcileReason/X/Y/Seq/At` fields are live. D-52 carries as spike-only this phase. Hypothesis: `predictor.ts:125-137` server reset detection path — if `snap.last_input_seq < this.lastSeqAcked`, it clears unacked and accepts snapshot as ground truth. If a snapshot with stale `last_input_seq` arrives (e.g., after a reconnect), the player snaps to snapshot coords (= spawn).

### Extended diagnostic fields for D-52/D-57/D-58 spike

Extend the existing `window.__rebno` telemetry with idle-state fields:
```typescript
window.__rebno = {
  ...existing fields...,
  lastReconcileVx: snap.vx,        // server velocity at reconcile time
  lastReconcileVy: snap.vy,        // server velocity at reconcile time
  lastReconcileLocalX: cur.x,      // sprite position BEFORE snap
  lastReconcileLocalY: cur.y,
  lastReconcileDist: dist,         // actual divergence distance
  lastReconcilePredictedX: predicted.x,  // post-replay predicted position
  lastReconcilePredictedY: predicted.y,
}
```

---

## D-53 — SpawnDelayTicks operator-visibility

### Current state

`PlayerRenderer.ts:147`: `spawnDelayTicks: 30` initialized in `ensureLocal()`. [VERIFIED: PlayerRenderer.ts:147]
`PlayerRenderer.ts:196-231`: `onSimulationTickLocal()` decrements `this.local.spawnDelayTicks` per tick. [VERIFIED: PlayerRenderer.ts:196-231]

**No `window.__rebno.spawnDelayTicks` publication exists.** [VERIFIED: grep shows no such field in PlayerRenderer.ts or GameScene.ts]

### ensureLocal idempotency issue

`ensureLocal()` at line 127: `if (this.local) return { sprite: this.local.sprite, wasRecreated: false };` — if `this.local` exists, it returns early WITHOUT reinitializing `spawnDelayTicks`. [VERIFIED: PlayerRenderer.ts:127]

For the auth-flow re-entry path (evict → respawn): `onLeave(4001)` fires on the prior tab → `GameScene` starts `LoginScene` → user logs in again → `GameScene.create()` runs → `PlayerRenderer` is NEW (GameScene creates a new instance each time via `this.playerRenderer = new PlayerRenderer(...)` in create()). So `this.local` starts as `undefined`, and `ensureLocal()` will set `spawnDelayTicks = 30` on the first call. [VERIFIED: GameScene.ts:168-170, PlayerRenderer.ts constructor]

For the reconnect path (same tab, WS reconnect via `reconnectMachine`): `connect()` is called again. `this.playerRenderer` is NOT recreated (it persists across reconnects). `onLocalJoin()` calls `ensureLocal()` which sees `this.local` exists → returns early → `spawnDelayTicks` is NOT reset. This is the reinit bug. [VERIFIED: GameScene.ts:468]

**Fix for D-53:** `ensureLocal()` should have a `forceReinit?: boolean` parameter OR the check should reset `spawnDelayTicks` on reconnect. Cleanest: add a `resetSpawnDelay()` method that `GameScene.onLocalJoin()` always calls.

### window.__rebno publication

Add to `PlayerRenderer.onSimulationTickLocal()` after the spawnDelayTicks decrement:
```typescript
if (typeof globalThis !== 'undefined') {
  const g = globalThis as any;
  g.__rebno = {
    ...(g.__rebno ?? {}),
    spawnDelayTicks: this.local.spawnDelayTicks,
    spawnDelayTicksTotal: 30,
  };
}
```

---

## D-55 — Teleport in/out animation (legacy spec)

### Legacy spec from extracted GML

`extracted/client-5-8/objects/0042-player/events/Other-7.gml` (Alarm 0 event — fires 30 ticks after creation): [VERIFIED: Other-7.gml]
```gml
if(sprite_index == TeleIn || sprite_index == JoinIn) {
  image_speed = 0.4;
  global.p_spr[pid,0] = NaviStandD;
  sprite_index = NaviStandD;
  /* teledin = 1; alarm[0] = 30; */  // commented out in rev 5-8
}
else if(sprite_index == TeleOut || sprite_index == JoinOut) {
  if(!global.p_online[pid] || global.p_room[pid] != room) instance_destroy();
  else { visible = 0; image_speed = 1; }
}
```
`extracted/client-5-8/objects/0042-player/events/Alarm.gml`: `teledin = 0;` [VERIFIED]
`extracted/client-5-8/objects/0042-player/events/Create.gml`: `image_speed = 0.8;` [VERIFIED]

`Draw.dnd.json` (0042-player) shows: [VERIFIED]
- Normally draws `draw_sprite(sprite_index,-1,x,y)` + nameplate text
- When `sprite_index == JoinIn`: additionally `draw_sprite(JoinInR,-1,x,y)`
- When `sprite_index == JoinOut`: additionally `draw_sprite(JoinOutR,-1,x,y)`

**Summary:** In BNO, spawn-in uses `JoinIn` sprite (entrance animation) at `image_speed = 0.4` (slow play). After the sprite animation completes (each frame displays), it auto-transitions to `NaviStandD`. The "TeleIn" path has a commented `alarm[0] = 30` (= 1 second at 30 Hz) after which `teledin = 1` fires. The current codebase spec (06.2-04-SPAWN-DELAY-LEGACY-SPEC.md) models this as 30-tick stand hold. The `JoinIn`/`JoinOut`/`TeleIn`/`TeleOut` sprites are specific to BNO's join-animation; the rebno rebuild doesn't have these sprites in the atlas.

**Recommended approach for D-55 (implementation):**

Since the original BNO teleport sprites (`JoinIn`, `JoinOut`, `TeleIn`, `TeleOut`) don't exist in the current atlas, implement a visual equivalent using Phaser tweens. The CONTEXT decision is to use alpha + scale:

```typescript
// TeleportIn: alpha 0→1 + scale 0.5→1 over 500ms (~15 ticks at 30Hz)
// Runs DURING the spawnDelayTicks 30-tick hold
// TeleportOut: alpha 1→0 + scale 1→0.5 over 333ms (~10 ticks) before destroy
```

Duration mapping: BNO's JoinIn sprite plays at `image_speed = 0.4` (advances 0.4 frames/tick). If JoinIn has ~6 frames, full play = 6/0.4 = 15 ticks = 500 ms at 30 Hz. This matches the 30-tick spawnDelay window well (teleport-in finishes before the hold expires).

### SpriteStateMachine extension

`SpriteStateMachine.ts` is a pure function (`deriveFrame`), not a class with states. [VERIFIED: SpriteStateMachine.ts] There is no state machine class to extend — just the `SpriteState` interface and `deriveFrame` function.

**D-55 implementation lives in `PlayerRenderer.ts` and `GameScene.ts`:**

1. Add a `teleportState: 'in' | 'out' | 'none'` field to `PlayerRenderState`
2. In `ensureLocal()`, start the TeleportIn tween immediately after sprite creation
3. In `GameScene.onRemoteRemove()`, start the TeleportOut tween before calling `playerRenderer.removeRemote()`
4. The tween target is the sprite (alpha + scale); nameplate follows after TeleportIn completes

The `SpriteStateMachine.ts` file exports `SpriteState` type — no changes needed there. The CONTEXT refers to `SpriteState.TeleportIn/Out` as conceptual states, but they're implemented in `PlayerRenderer` logic, not in `SpriteStateMachine.deriveFrame`.

---

## D-56 — Chat box readability

### Current ChatHUD styling

`ChatHUD.ts:84-95`: [VERIFIED]
```typescript
'font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace',
'font-size: 16px',
'line-height: 1.5',
'min-width: 320px',
'max-width: 480px',
```
Log container (line 100): `background: rgba(10,14,26,0.45); border-radius: 4px; padding: 8px; gap: 4px;`

**UAT complaint: very small / hard to read.** At 16px with 0.45 opacity background, at Phaser's MAX_ZOOM (typically 2× or 3× on a 1080p screen), the chat box is a fixed DOM element at native DPI. The issue is likely the low background opacity making text hard to read against the dark game world.

**Recommended CSS bumps (single plan, no spike):**
- Increase background opacity: `rgba(10,14,26,0.45)` → `rgba(10,14,26,0.75)`
- Increase `max-width` from 480px to 560px for more line width
- Add `max-height: 200px; overflow-y: auto;` to the log container so long chat doesn't overflow
- Keep `font-size: 16px` — operator's complaint is readability (contrast), not font size

These changes are purely cosmetic; no functional behavior change.

---

## D-59 — Nameplate font race

### Current boot pipeline (no font gate)

`BootScene.ts:34-101` loads atlas + checks session. No `document.fonts.ready` await. [VERIFIED: BootScene.ts]

`index.html:12-14` declares `@font-face` with `font-display: block`. [VERIFIED: index.html]

`Nameplate.ts:57-68`: `Phaser.GameObjects.Text` is constructed in `ensureLocal()` which is called from `onLocalJoin` (GameScene.ts:468). [VERIFIED]

**The font race:** `document.fonts.ready` resolves when all `@font-face` declarations have either loaded or failed. `font-display: block` means the browser holds the render (blocking period up to 3 seconds for WOFF2) before showing fallback. However, Phaser's canvas text rendering snapshots the font at the time `Phaser.GameObjects.Text` is constructed — if the WOFF2 hasn't finished loading, Phaser uses the fallback at construction time and never re-renders when the font loads.

**Observed symptom:** UAT shows `uat_a`'s own nameplate uses Fixedsys on `uat_b`'s client but other 3 nameplates use fallback monospace. This means the font loaded for the first player to join (font was ready by that time) but was NOT yet ready for subsequent players (join order matters). Playwright/staging may have variable WOFF2 load times.

### Recommended fix

**In `BootScene.ts:create()`**, add a font-ready gate:
```typescript
// D-59: gate on font load before transitioning to LoginScene.
// Ensures document.fonts.check('1em Fixedsys Excelsior') returns true
// before any Phaser.GameObjects.Text is created in GameScene.
await document.fonts.load('16px "Fixedsys Excelsior"').catch(() => {
  // Font failed to load — continue with fallback (non-blocking degradation)
  console.warn('BootScene: Fixedsys font failed to load; nameplate will use fallback monospace');
});
```
`document.fonts.load()` returns a Promise that resolves when the font is available (or rejects if it fails). This is the correct API vs `document.fonts.ready` (which waits for ALL fonts, potentially slower). [ASSUMED — MDN document.fonts.load() behavior; standard browser API, high confidence]

**In `index.html`**, add a preload hint:
```html
<link rel="preload" href="/assets/fonts/FixedsysExcelsior.woff2" as="font" type="font/woff2" crossorigin>
```
The `crossorigin` attribute is REQUIRED for WOFF2 preload even for same-origin fonts — without it, the browser fetches the font twice (preload + @font-face). [ASSUMED — WOFF2 preload crossorigin requirement; standard CORS behavior for fonts]

**Late-joiner safety:** Once `document.fonts.load()` resolves in BootScene, subsequent `Nameplate` instances will use Fixedsys (the font is in the font cache). The race is eliminated for all players joining after the local client boots.

**Regression test:** `document.fonts.check('16px "Fixedsys Excelsior"')` returns `true` before any `Nameplate` is constructed. Test should mock/stub the Phaser Text constructor or assert the check passes in a jsdom environment after loading the WOFF2. [ASSUMED — test implementation approach; jsdom may not fully support document.fonts]

---

## GREEN gate uplift — Anti-recurrence

### The false-PASS problem (cycle-3 → cycle-4)

`cli-08-nameplate-offset.e2e.test.ts:64`: [VERIFIED]
```typescript
const expectedY = spriteTop - 16;
// Asserts nameplateY ≈ expectedY ± 1
```
This assertion matched the (overshot) code because the test derived `expectedY` FROM the code's constant (`16`), not from the canonical legacy source. When the constant is wrong, the test is wrong too.

**Canonical ref cite pattern:** Every expected value in a Playwright assertion must cite its source in a comment:
```typescript
// SOURCE: extracted/client-5-8/objects/0000-server/events/Draw.dnd.json
// draw_text(x + sprite_width/2 - string_width/2, y - 16, name)
// y = sprite top-left in GM (origin 0,0), so y-16 = 16 px above sprite top edge.
// Phaser translation: spriteY (feet) - spriteHeight - NAMETAG_OFFSET_Y = same gap.
// NAMETAG_OFFSET_Y corrects for Phaser Text descender padding (~15 px for 16px Fixedsys).
// Effective gap from visible glyph bottom to sprite top ≈ 10 px (operator measurement 2026-05-13).
const expectedY = spriteTop - NAMETAG_OFFSET_Y; // NAMETAG_OFFSET_Y = 1 after D-45 Plan A fix
```

**gsd-code-reviewer gate syntax:**
A Playwright e2e assertion is considered canonical-ref compliant if:
1. The comment above or on the line contains one of: `SOURCE:`, `REF:`, `CLAUDE.md`, `canonical:`, `operator measurement`, `legacy GML`, `extracted/client-5-8/`
2. The expected value is not computed from the same variable being tested (no `actual === actual` tautology)

This is a HARD gate 3 check per CONTEXT. The planner should add this as a per-plan checklist item: "Does each e2e assertion expected value cite its canonical source?"

### Existing Playwright e2e tests — status

| Test file | Current status (cycle-4) | Fix needed |
|-----------|--------------------------|------------|
| `cli-08-nameplate-offset.e2e.test.ts` | PASS (wrong — assertion matched overshot code) | Update expected value to new NAMETAG_OFFSET_Y=1; add canonical ref cite |
| `cli-08-local-anim.e2e.test.ts` | FAIL (spawn delay overlap with 1s sample window) | Fix: wait 1200ms past spawn — already pushed at b34e053; redeploy needed |
| `cli-08-floor-collision.e2e.test.ts` | FAIL (collision extent mismatch) | Fix after D-54 NAVI_MASK correction |
| `camera-follow.e2e.test.ts` | FAIL (camera frozen during spawn delay window) | Fix after D-53 spawnDelayTicks reinit + D-55 teleport |
| `cli-08-camera.e2e.test.ts` | FAIL (same camera cascade) | Fix after D-53/D-55 |
| `cli-08-anim.e2e.test.ts` | FAIL (D-53 spawn delay boundary) | Fix after D-53 |

**New e2e tests needed this phase:**
- D-51 dup-login Playwright test (multi-context)
- D-54 collision extent test (update `cli-08-floor-collision` with correct boundary expectation)
- D-59 font readiness assertion

---

## Runtime State Inventory

> Not a rename/refactor phase. No stored data, live service config, or OS-registered state changes.

**Nothing found in any category** — all changes are code/config edits to the repo. The phase adds telemetry fields to `window.__rebno` (ephemeral, no persistence) and modifies CSS/TypeScript source. No data migration required.

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Smooth position interpolation | Custom linear interpolator | `this.tweens.add({ targets, x, y, duration, ease: 'Linear' })` | Phaser Tween handles RAF, cleanup, kill-on-destroy automatically |
| Font availability check | `setInterval` polling | `document.fonts.load('16px "Fixedsys Excelsior"')` | Native browser Promise API |
| Playwright multi-context dup-login | Manual WS scripting | `browser.newContext()` × 2 | Playwright built-in isolated contexts |
| Depth ordering | Custom z-sort list | `Phaser.GameObjects.Text.setDepth(constant)` | Phaser's depth system handles painter's algorithm |

---

## Common Pitfalls

### Pitfall 1: Tween `killTweensOf` before restart

**What goes wrong:** If a Phaser tween is in progress and a new `this.tweens.add({ targets: sprite, ... })` runs, two tweens fight over the same properties. The sprite oscillates or snaps to incorrect positions.
**How to avoid:** Always call `this.tweens.killTweensOf(local)` before `this.tweens.add(...)` in the `tweenTo` adapter.

### Pitfall 2: Nameplate depth must account for camera scroll

**What goes wrong:** Setting nameplate depth to a fixed constant like `10000` works as long as there are no other objects with depth > player sprites. The tile depth formula gives very negative depths; player sprites get ~400-900. A constant 10000 clears all of these. No camera-scroll issue — Phaser depth is scene-space, not camera-space.
**How to avoid:** Use a fixed constant ≥ 1000 (safe; player max depth ≈ y + 43 ≈ 843 at room bottom).

### Pitfall 3: `document.fonts.load` in jsdom (test environment)

**What goes wrong:** `document.fonts.load()` in jsdom (vitest/happy-dom) may not be implemented or may not resolve. A font-gate in BootScene would cause the boot sequence to hang in unit tests.
**How to avoid:** Wrap in a try/catch with a short timeout. Don't add the font gate to the critical boot path in a way that blocks tests — the gate should be best-effort (log warning on failure, continue boot).

### Pitfall 4: NAVI_MASK constant update requires unit test update

**What goes wrong:** `packages/game-logic/test/` has a unit test that pins `NAVI_MASK` values. If the constants change (from `{left:9,top:40,right:26,bottom:46}` to `{left:-9,top:-8,right:8,bottom:-2}`), the test will fail.
**How to avoid:** Update the test to assert the new values with a comment explaining the coordinate system: "offsets relative to player center-feet, NOT sprite top-left."

### Pitfall 5: Protocol version bump not needed for D-51 fix

**What goes wrong:** If the D-51 fix modifies the `force_reset` event shape or adds new s2c events, `PROTOCOL_VERSION` must be bumped and two hardcoded asserts updated per 06.2-09 burn:
- `packages/protocol/test/state.test.ts:11-12`
- `apps/client/src/__test__/colyseus-client.test.ts:131,141`
**How to avoid:** D-51 fix should not require a schema change. The fix should be in the server eviction sequencing (timing/ordering of state operations), not in the wire protocol.

### Pitfall 6: Worktree merge cwd drift (from memory)

**What goes wrong:** Running `git merge $WT_BRANCH` from a worktree subdirectory causes orphaned commits.
**How to avoid:** Always `cd` to repo root before `git merge`. [CITED: .claude/memory/worktree-merge-cwd-gotcha.md]

### Pitfall 7: Staging deploy build order

**What goes wrong:** `vite build` wipes the `public/` directory, losing the atlas if re-copy isn't done after build.
**How to avoid:** `vite build` FIRST, re-copy atlas AFTER. [CITED: 06.3-CONTEXT.md canonical_refs]

### Pitfall 8: `__rebno` hooks must be unconditional (no env gate)

**What goes wrong:** Gating `window.__rebno` writes on `process.env.NODE_ENV === 'development'` or `import.meta.env.DEV` silences hooks on staging (MODE==='staging' is neither dev nor test).
**How to avoid:** Never gate `window.__rebno` writes on env. The existing code correctly uses unconditional writes. New fields in D-53 and D-57/D-58 spike MUST follow this pattern. [CITED: 06.3-CONTEXT.md code_context]

---

## Code Examples

### D-54: Corrected NAVI_MASK constants

```typescript
// packages/game-logic/src/constants.ts — corrected origin-relative offsets
// SOURCE: extracted/client-5-8/sprites/0034-NaviMask/meta.json
//   { bboxLeft: 9, bboxTop: 40, bboxRight: 26, bboxBottom: 46, width: 36, height: 48,
//     originX: 0, originY: 0 }
// Player sprite origin in Phaser: (0.5, 1) → player.x = center, player.y = feet
// Conversion: offset_relative_to_center_feet = bbox_from_topleft - (width/2, height)
//   left  = 9  - 18 = -9
//   right = 26 - 18 =  8
//   top   = 40 - 48 = -8
//   bottom = 46 - 48 = -2
export const NAVI_MASK = {
  left: -9,   // player.x + left = left edge of feet bbox (sprite-center-x - 9)
  top: -8,    // player.y + top  = top edge of feet bbox (8 px above feet)
  right: 8,   // player.x + right = right edge of feet bbox
  bottom: -2, // player.y + bottom = bottom of feet bbox (2 px above feet)
} as const;
```

### D-57/D-58: Reconciler tweenTo fix

```typescript
// apps/client/src/scenes/GameScene.ts — onLocalJoin adapter
// [impl->REQ-SRV-14] [impl->REQ-CLI-08]
const adapter: ReconcilerSpriteAdapter = {
  setPosition: (x: number, y: number) => local.setPosition(x, y),
  getPosition: () => ({ x: local.x, y: local.y }),
  // D-57 fix: implement actual tween (was setPosition no-op — killed lerp path)
  tweenTo: (x: number, y: number, ms: number) => {
    this.tweens.killTweensOf(local);
    this.tweens.add({
      targets: local,
      x,
      y,
      duration: ms,
      ease: 'Linear',
    });
  },
};
```

### D-45 Plan C: Nameplate depth fix

```typescript
// apps/client/src/render/Nameplate.ts — constructor, after text creation
// [impl->REQ-CLI-04] [impl->REQ-CLI-08] D-45 Plan C
// SOURCE: extracted/client-5-8/objects/0000-server/events/Draw.dnd.json —
//   nameplate draw_text() runs in the player's Draw event (same depth as sprite).
//   In BNO, Draw event output always renders above other objects at the same depth.
//   In Phaser: nameplate text must have higher depth than any sprite.
//   Player sprite max depth = computeDepth(0, 43, room_height_px) = 800 + 43 = 843.
//   Set nameplate depth to 10000 — clears all player sprites and tiles.
const NAMEPLATE_DEPTH = 10000;
this.text.setDepth(NAMEPLATE_DEPTH);
```

### D-59: Font gate in BootScene

```typescript
// apps/client/src/scenes/BootScene.ts — in create(), before session probe
// [impl->REQ-CLI-04] [impl->REQ-CLI-08] D-59 font race fix
// document.fonts.load() returns a Promise that resolves when the font is available.
// Wrapping in try/catch ensures boot continues even if the font fails.
try {
  await document.fonts.load('16px "Fixedsys Excelsior"');
} catch {
  console.warn('BootScene: Fixedsys font failed to load; nameplate will use fallback');
}
```

### D-51: Playwright multi-context fixture

```typescript
// apps/client/test/e2e/cli-08-dup-login.e2e.test.ts
// [int->REQ-SRV-03] [int->REQ-CLI-08] D-51 dup-login gate
test('dup-login: second session evicts first; second reaches GameScene', async ({ browser, accountA, inviteSuffix }) => {
  const ctx1 = await browser.newContext();
  const ctx2 = await browser.newContext();
  const p1 = await ctx1.newPage();
  const p2 = await ctx2.newPage();
  try {
    // First login — reaches GameScene
    await loginAs(p1, accountA, inviteSuffix);
    await waitForGameReady(p1);
    // Second login as same account — triggers eviction
    await loginAs(p2, accountA, inviteSuffix);
    // p2 must reach GameScene
    await waitForGameReady(p2);
    // p1 must be evicted → LoginScene with banner
    await expect(p1.locator('[data-scene="LoginScene"]')).toBeAttached({ timeout: 10_000 });
    // Assert no crash on p1 (LoginScene banner visible, not blank/error)
  } finally {
    await ctx1.close();
    await ctx2.close();
  }
});
```

---

## Validation Architecture

### Test Framework

| Property | Value |
|----------|-------|
| Framework | Vitest 1.x (unit) + Playwright 1.x (e2e) |
| Unit config | `packages/game-logic/vitest.config.ts`, `apps/client/vitest.config.ts` |
| E2e config | `apps/client/playwright.config.ts` |
| Quick unit run | `pnpm --filter @rebno/game-logic test` or `pnpm --filter rebno-client test:unit` |
| Full suite | `pnpm test` (all workspaces) |
| E2e run | `pnpm --filter rebno-client test:e2e` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|--------------|
| REQ-CLI-04 | Nameplate Y offset = 1 px above sprite top (visible glyph) | unit | `pnpm --filter rebno-client test:unit -- nameplate` | ✅ (needs update) |
| REQ-CLI-04 | spawnDelayTicks reinit on reconnect | unit | `pnpm --filter rebno-client test:unit -- playerRenderer` | ✅ (needs new case) |
| REQ-CLI-04 | TeleportIn/Out state transitions | unit | `pnpm --filter rebno-client test:unit -- playerRenderer` | ✅ (needs new cases) |
| REQ-CLI-04 | Font loaded before Nameplate created | unit | `pnpm --filter rebno-client test:unit -- nameplate` | ❌ Wave 0 |
| REQ-CLI-04 | Reconciler lerp uses actual tween (not setPosition) | unit | `pnpm --filter rebno-client test:unit -- reconciler` | ✅ (needs update) |
| REQ-CLI-06 | NAVI_MASK offsets correct (center-feet coordinate system) | unit | `pnpm --filter @rebno/game-logic test -- constants` | ✅ (needs update) |
| REQ-CLI-06 | Floor collision blocks at correct room boundary | e2e | `pnpm --filter rebno-client test:e2e -- cli-08-floor-collision` | ✅ (needs assertion update) |
| REQ-CLI-07 | Chat box readable (visual) | operator UAT | manual only | — |
| REQ-CLI-08 | Nameplate offset gate (D-45 anti-recurrence) | e2e | `pnpm --filter rebno-client test:e2e -- cli-08-nameplate-offset` | ✅ (needs assertion fix) |
| REQ-CLI-08 | Dup-login eviction (D-51) | e2e | `pnpm --filter rebno-client test:e2e -- cli-08-dup-login` | ❌ Wave 0 |
| REQ-CLI-08 | Camera follows local player | e2e | `pnpm --filter rebno-client test:e2e -- camera-follow` | ✅ |
| REQ-SRV-03 | Prior session evicted on new join | e2e | `pnpm --filter rebno-client test:e2e -- cli-08-dup-login` | ❌ Wave 0 |
| REQ-SRV-14 | Idle-state position converges | unit | `pnpm --filter rebno-client test:unit -- reconciler` | ✅ (needs new idle case) |

### Wave 0 Gaps (new test files needed)

- [ ] `apps/client/test/e2e/cli-08-dup-login.e2e.test.ts` — covers REQ-SRV-03/REQ-CLI-08 D-51
- [ ] Font readiness assertion in `apps/client/src/__test__/nameplate.test.ts` — covers REQ-CLI-04 D-59

### Sampling Rate

- **Per task commit:** `pnpm --filter @rebno/game-logic test && pnpm --filter rebno-client test:unit`
- **Per wave merge:** Full `pnpm test` + `pnpm trace:check`
- **Phase gate:** All Playwright e2e GREEN on staging before `/gsd-verify-work 06.3`

---

## Security Domain

> security_enforcement absent from config = enabled.

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes (D-51 eviction) | Better-Auth session token (existing) |
| V3 Session Management | yes (D-51 prior session eviction) | Colyseus 4001 close code + force_reset (existing) |
| V4 Access Control | no | — |
| V5 Input Validation | yes (chat HUD D-56) | `textContent` (never innerHTML) — existing, confirmed in ChatHUD.ts:221 |
| V6 Cryptography | no | — |

### Known Threat Patterns

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Session fixation via dup-login | Spoofing | Server evicts prior session on new join (D-51 fix) |
| XSS via chat message | Tampering | `textContent` assignment (confirmed ChatHUD.ts:221) |
| Client position spoofing | Tampering | Server-authoritative: client sends axis intent only (existing, unchanged) |

---

## Open Questions

1. **D-54: Will NAVI_MASK sign change break the server-side collision fallback path?**
   - What we know: Server uses `resolveCollision + clampToRoom` (no walkable grid), not `isFeetBboxWalkable`. So `NAVI_MASK` is only used client-side.
   - What's unclear: Confirm the server path never calls `isFeetBboxWalkable`.
   - Recommendation: Grep server code for `isFeetBboxWalkable` before changing. [VERIFIED: step.ts:155-159 confirms server-side uses legacy path when `grid === undefined`]

2. **D-51: Is the both-tabs crash reproducible deterministically in Playwright?**
   - What we know: UAT was with separate Chrome profiles; server code appears correct in isolation.
   - What's unclear: Whether the Colyseus state patch ordering triggers the both-tabs symptom.
   - Recommendation: Playwright multi-context fixture in W1 spike will determine.

3. **D-45 Plan B: Does `RemoteExtrapolator.sample()` ever get called?**
   - What we know: `extrapolator` is pushed to in `onRemoteSnapshot()` but the `update()` loop reads positions directly from `room.state.players` (GameScene.ts:799), not from extrapolator.
   - What's unclear: Is the extrapolator dead code for remote positioning, or is it used elsewhere?
   - Recommendation: Grep for `extrapolator.sample` — if never called, the remote flicker may be caused by the discrete server-state updates (no interpolation at all for remotes).

4. **D-55: Does TeleportIn tween overlap correctly with spawnDelayTicks?**
   - What we know: spawnDelayTicks=30 ticks = 1s. TeleportIn proposed duration = 500ms (~15 ticks).
   - What's unclear: Should spawnDelayTicks decrement during TeleportIn (yes, per spec), or should the counter reset after TeleportIn completes?
   - Recommendation: Per CONTEXT §D-55: "TeleportIn runs DURING the spawnDelayTicks hold." The 30-tick counter runs from spawn regardless. TeleportIn is visual only, not blocking.

---

## Environment Availability

> Staging: `rebno-staging.fly.dev`. No new external deps needed for this phase.

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Playwright | D-51 e2e fixture | ✓ | per package.json | — |
| Fly.io staging deploy | HARD gate 1 + UAT | ✓ | existing | — |
| `pnpm trace:check` | Phase gate | ✓ | existing | — |
| WOFF2 font file | D-59 | ✓ (already in /assets/fonts/) | existing | — |

---

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Phaser Text descender padding for 16px Fixedsys is ~15 px, explaining the 15 px overshoot | D-45 Plan A | Low risk — CONTEXT.md operator measurement confirms 15 px overshoot; NAMETAG_OFFSET_Y=1 is the operator-directed fix regardless of the precise cause |
| A2 | Colyseus 0.17 seat-reservation timeout is ~2 seconds (eviction loop must complete within) | D-51 | Low-medium: if timeout is shorter, the Playwright fixture timing may be tight; server logs will surface this |
| A3 | `document.fonts.load()` correctly resolves for same-origin WOFF2 in Chrome | D-59 | Low: standard browser API; widely implemented |
| A4 | WOFF2 preload with `crossorigin` attribute required for same-origin fonts | D-59 | Low: CSS spec requirement; without it font is fetched twice (no functional breakage, just performance) |
| A5 | RemoteExtrapolator.sample() is dead code for remote player positioning | D-45 Plan B | Low: if it's used, the flicker source changes; the spike ring buffer will reveal the actual position source |

---

## Sources

### Primary (HIGH confidence — verified against codebase)

- `apps/server/src/RebnoRoom.ts:214-495` — broadcastRoomLayout, sendRoomLayoutToClient, replace-prior-session eviction
- `packages/protocol/src/intents.ts:115-196` — legacyLayoutSchema, newLayoutSchema (no collision_polys in new shape)
- `apps/client/src/render/RoomCollision.ts` — deriveWalkableGrid implementation
- `apps/client/src/render/Nameplate.ts` — NAMETAG_OFFSET_Y=16, follow() formula, depth not set
- `apps/client/src/prediction/reconciler.ts` — DIVERGENCE_THRESHOLD_PX=22, tweenTo implementation
- `apps/client/src/scenes/GameScene.ts:511` — tweenTo adapter does setPosition (no actual tween)
- `apps/client/src/render/PlayerRenderer.ts` — ensureLocal idempotency, spawnDelayTicks lifecycle
- `packages/game-logic/src/constants.ts:65-70` — NAVI_MASK values
- `packages/game-logic/src/step.ts:162-208` — NAVI_MASK application in collision loop
- `extracted/client-5-8/sprites/0034-NaviMask/meta.json` — bbox{Left,Top,Right,Bottom} values, origin(0,0)
- `extracted/client-5-8/objects/0042-player/events/Draw.dnd.json` — draw_text(x+w/2-sw/2, y-16, name) formula
- `extracted/client-5-8/objects/0000-server/events/Draw.dnd.json` — same formula for local player
- `extracted/client-5-8/objects/0042-player/events/Other-7.gml` — Alarm 0: TeleIn/JoinIn → NaviStandD at image_speed=0.4
- `apps/server/rooms/mvp-room/000.json` — tile layout analysis: 324 tiles, cols 1-18, rows 1-18, 20×20 grid
- `apps/client/src/ui/ChatHUD.ts` — current font-size 16px, background opacity 0.45
- `apps/client/index.html` — @font-face declaration, font-display: block, no preload link
- `apps/client/src/scenes/BootScene.ts` — no document.fonts gate
- `apps/client/src/render/SpriteStateMachine.ts` — pure function, no state machine class (D-55 note)
- `apps/client/test/e2e/cli-08-nameplate-offset.e2e.test.ts` — false-PASS root cause analysis

### Secondary (MEDIUM confidence — standard API docs, training knowledge)

- Phaser 3 Tween API (`tweens.add`, `tweens.killTweensOf`) — standard Phaser 3 documented API [ASSUMED from training]
- `document.fonts.load()` Promise semantics — MDN-documented Font Loading API [ASSUMED]
- Playwright `browser.newContext()` for isolated cookies — documented Playwright API [ASSUMED]

---

## Metadata

**Confidence breakdown:**
- D-40/D-54 root cause (NAVI_MASK offset): HIGH — derived from verified constants + sprite meta + step.ts code
- D-45 Plan C (nameplate depth = 0): HIGH — verified, Nameplate.ts has zero setDepth calls
- D-57 root cause (tweenTo is setPosition): HIGH — verified GameScene.ts:511 exactly
- D-58 (consequence of D-57): HIGH — logical derivation
- D-45 Plan A (NAMETAG_OFFSET_Y=1): MEDIUM — operator-directed; Phaser descender mechanism assumed
- D-51 root cause: LOW (H2b most likely but not proven) — spike needed
- D-52 root cause: LOW — no reproductions; spike only
- D-55 tween duration (500ms): MEDIUM — derived from legacy image_speed=0.4 × 6 frames
- D-59 font gate approach: HIGH — standard browser Font Loading API

**Research date:** 2026-05-13
**Valid until:** 2026-05-27 (2 weeks; fast-moving phase)
