# Phase 06.3: cycle-4 gap-closure — Pattern Map

**Mapped:** 2026-05-13
**Files analyzed:** 17 new/modified files
**Analogs found:** 15 / 17

---

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `apps/client/src/render/Nameplate.ts` (constant + setDepth) | render | request-response | self (edit-in-place) | exact |
| `apps/client/src/render/PlayerRenderer.ts` (spawnDelayTicks reinit + teleport tween + window.__rebno publish) | render | event-driven | self (edit-in-place) | exact |
| `apps/client/src/render/RoomRenderer.ts` (bounds-check + window.__rebno telemetry extension) | render | CRUD | self (edit-in-place) | exact |
| `apps/client/src/render/RoomCollision.ts` (NAVI_MASK fix consumer) | render | transform | self (edit-in-place) | exact |
| `apps/client/src/scenes/GameScene.ts` (tweenTo fix + onRemoteRemove teleport + font gate) | scene | event-driven | self (edit-in-place) | exact |
| `apps/client/src/scenes/BootScene.ts` (document.fonts.load gate) | scene | request-response | `apps/client/src/scenes/BootScene.ts` | exact |
| `packages/game-logic/src/constants.ts` (NAVI_MASK correction) | utility | transform | self (edit-in-place) | exact |
| `packages/protocol/src/intents.ts` (newLayoutSchema extension, if fix path 2) | model | transform | self (edit-in-place) | exact |
| `apps/server/src/RebnoRoom.ts` (eviction block + pino logging) | service | event-driven | self (edit-in-place) | exact |
| `apps/client/src/ui/ChatHUD.ts` (CSS opacity + max-width bump) | ui | request-response | self (edit-in-place) | exact |
| `apps/client/src/prediction/reconciler.ts` (tweenTo + idle-state telemetry) | utility | event-driven | self (edit-in-place) | exact |
| `apps/client/index.html` (preload hint) | config | — | self (edit-in-place) | exact |
| `apps/client/test/e2e/fixtures.ts` (NEW: duplicate-login multi-context fixture) | test | event-driven | `apps/client/test/e2e/cli-08.e2e.test.ts` | role-match |
| `apps/client/test/e2e/cli-08-dup-login.e2e.test.ts` (NEW: D-51 e2e test) | test | event-driven | `apps/client/test/e2e/ws-kill-reconnect.e2e.test.ts` | role-match |
| `apps/client/src/__test__/nameplate.test.ts` (update expected Y + add canonical-ref cite) | test | transform | self (edit-in-place) | exact |
| `apps/client/src/__test__/player-renderer-spawn-delay.test.ts` (add reinit path coverage) | test | event-driven | self (edit-in-place) | exact |
| `apps/client/test/e2e/cli-08-floor-collision.e2e.test.ts` (update boundary expectation after D-54 fix) | test | transform | self (edit-in-place) | exact |

---

## Pattern Assignments

### `apps/client/src/render/Nameplate.ts` — D-45 Plan A + Plan C (edit-in-place)

**Analog:** self — `apps/client/src/render/Nameplate.ts`

**D-45 Plan A — constant change** (line 42):
```typescript
// BEFORE:
const NAMETAG_OFFSET_Y = 16;
// AFTER:
// SOURCE: extracted/client-5-8/objects/0000-server/events/Draw.dnd.json
// draw_text(..., y - 16, ...) where y = sprite TOP-LEFT in GM (origin 0,0).
// Phaser Text bbox includes ~15 px descender padding below visible glyph.
// Operator measurement 2026-05-13: visible gap was 25 px; legacy BNO = 10 px → 15 px overshoot.
// NAMETAG_OFFSET_Y corrects: 16 (formula gap) - 15 (descender padding) = 1.
const NAMETAG_OFFSET_Y = 1;
```

**D-45 Plan C — setDepth in constructor** (after `scene.add.text(...)` block, lines 57-68):
```typescript
// After .setOrigin(0.5, 1):
// SOURCE: PlayerRenderer.ts:58-61 computeDepth — player sprite depth ≈ y + 43 at spawn (≈443).
// Nameplate must draw ON TOP of all player sprites.
// Max player depth ≈ room_height + 43 ≈ 800 + 43 = 843 at room bottom.
// Fixed constant 10000 clears all sprites and tiles (tile depth < 0).
.setDepth(10000)
```

**follow() formula stays unchanged** (lines 98-104) — only the constant changes.

---

### `apps/client/src/scenes/GameScene.ts` — D-57/D-58 tweenTo fix (edit-in-place)

**Analog:** `apps/client/src/prediction/reconciler.ts` (defines the `ReconcilerSpriteAdapter` interface specifying what `tweenTo` must do)

**Current broken adapter** (GameScene.ts line 511):
```typescript
tweenTo: (x: number, y: number, _ms: number) => local.setPosition(x, y),
```

**Fixed adapter pattern** — copy from reconciler.ts JSDoc contract (`tweenTo(x, y, durationMs)`):
```typescript
tweenTo: (x: number, y: number, ms: number) => {
  // D-57/D-58: kill any in-progress tween before starting a new one.
  // Pitfall: two tweens on the same target fight → oscillation.
  this.tweens.killTweensOf(local);
  this.tweens.add({ targets: local, x, y, duration: ms, ease: 'Linear' });
},
```

`this.tweens` is a Phaser.Scene property available in any scene method. No import needed.

---

### `apps/client/src/scenes/GameScene.ts` — D-55 TeleportOut hook in `onRemoteRemove` (edit-in-place)

**Analog:** `apps/client/src/render/PlayerRenderer.ts:348-354` (`removeRemote` — current teardown pattern)

**Current `onRemoteRemove`** (GameScene.ts lines 564-567):
```typescript
private onRemoteRemove(sid: string): void {
  this.playerRenderer?.removeRemote(sid);
  this.publishRemotePlayers();
}
```

**Tween-before-destroy pattern** — add alpha/scale tween, then destroy in `onComplete`:
```typescript
private onRemoteRemove(sid: string): void {
  const sprite = this.playerRenderer?.getRemoteSprite(sid);
  if (sprite) {
    this.tweens.killTweensOf(sprite);
    this.tweens.add({
      targets: sprite,
      alpha: 0,
      scaleX: 0.5,
      scaleY: 0.5,
      duration: 333, // ~10 ticks at 30 Hz — TeleportOut
      ease: 'Linear',
      onComplete: () => {
        this.playerRenderer?.removeRemote(sid);
        this.publishRemotePlayers();
      },
    });
  } else {
    this.playerRenderer?.removeRemote(sid);
    this.publishRemotePlayers();
  }
}
```

Note: `PlayerRenderer.getRemoteSprite(sid)` does not yet exist — the planner must add it (returns `this.remotes.get(sid)?.sprite`).

---

### `apps/client/src/scenes/BootScene.ts` — D-59 font-ready gate (edit-in-place)

**Analog:** `apps/client/src/scenes/BootScene.ts` async create() pattern (lines 45-101)

**Existing async boot gate shape** (lines 57-75):
```typescript
await new Promise<void>((resolve) => {
  this.load.once('complete', () => resolve());
  queueAtlasLoads(this, manifest);
  this.load.start();
  if (this.load.totalToLoad === 0) {
    this.load.off('complete');
    resolve();
  }
});
```

**New font-ready gate** — insert after step 3 (setNearestFilterAll), before step 4 (getSession), following the same best-effort / catch + continue pattern:
```typescript
// D-59: gate on Fixedsys font load before LoginScene so all subsequent
// Phaser.GameObjects.Text construction uses the correct font.
// document.fonts.load() resolves when the font is available; failure is
// non-blocking (fallback monospace degrades gracefully).
// Pitfall: jsdom may not implement document.fonts.load — catch + warn.
try {
  await document.fonts.load('16px "Fixedsys Excelsior"');
} catch (err) {
  console.warn('BootScene: Fixedsys font load failed; nameplate will use fallback', err);
}
```

---

### `apps/client/src/render/PlayerRenderer.ts` — D-53 window.__rebno publish + reinit fix (edit-in-place)

**Analog:** `apps/client/src/prediction/reconciler.ts:68-98` — unconditional `window.__rebno` spread-and-merge pattern (same shape used consistently throughout codebase)

**Unconditional __rebno publish pattern** (from reconciler.ts lines 68-78):
```typescript
if (typeof globalThis !== 'undefined') {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const g = globalThis as any;
  g.__rebno = {
    ...(g.__rebno ?? {}),
    lastReconcileX: predicted.x,
    // ... fields
  };
}
```

**D-53 (a) — add spawnDelayTicks publication** in `onSimulationTickLocal()` after the `spawnDelayTicks -= 1` decrement (lines 196-197):
```typescript
// D-53: publish spawn delay counter to window.__rebno for operator devtools read.
// Unconditional — no DEV gate (RC1 burned us: staging silences gated hooks).
if (typeof globalThis !== 'undefined') {
  const g = globalThis as any;
  g.__rebno = {
    ...(g.__rebno ?? {}),
    spawnDelayTicks: this.local.spawnDelayTicks,
    spawnDelayTicksTotal: 30,
  };
}
```

**D-53 (b) — ensureLocal reinit fix** (PlayerRenderer.ts line 127):
```typescript
// BEFORE — idempotent guard, no reinit on reconnect:
if (this.local) return { sprite: this.local.sprite, wasRecreated: false };

// AFTER — add resetSpawnDelay() method called from GameScene.onLocalJoin:
resetSpawnDelay(): void {
  if (this.local) {
    this.local.spawnDelayTicks = 30;
  }
}
// GameScene.onLocalJoin() calls this.playerRenderer.resetSpawnDelay() on every join,
// including reconnect path.
```

---

### `apps/client/src/render/PlayerRenderer.ts` — D-55 TeleportIn tween in `ensureLocal` (edit-in-place)

**Analog:** `apps/client/src/scenes/GameScene.ts:508-512` — tween adapter in the same codebase confirms `this.tweens.add` is the Phaser scene API. Tween targets a sprite.

TeleportIn tween fires immediately after `makeSprite` in `ensureLocal` (PlayerRenderer.ts line 128). Since `PlayerRenderer` holds `this.opts.scene`, it can call `this.opts.scene.tweens`:

```typescript
// D-55 TeleportIn: alpha 0→1 + scale 0.5→1 over 500ms (~15 ticks at 30 Hz).
// Runs during the spawnDelayTicks 30-tick hold — player isn't input-responsive
// until BOTH animation completes AND spawnDelayTicks reaches 0.
// SOURCE: Other-7.gml JoinIn sprite plays at image_speed=0.4 over ~6 frames = 15 ticks = 500 ms.
this.opts.scene.tweens.killTweensOf(sprite);
sprite.setAlpha(0);
sprite.setScale(0.5);
this.opts.scene.tweens.add({
  targets: sprite,
  alpha: 1,
  scaleX: 1,
  scaleY: 1,
  duration: 500,
  ease: 'Linear',
});
```

---

### `apps/client/src/render/RoomRenderer.ts` — D-40/D-54 telemetry extension (edit-in-place)

**Analog:** self — `apps/client/src/render/RoomRenderer.ts:220-239` (existing unconditional `window.__rebno` block)

**Existing telemetry block** (lines 220-239):
```typescript
if (typeof window !== 'undefined') {
  (window as unknown as Record<string, unknown>).__rebno = {
    ...((window as unknown as Record<string, Record<string, unknown>>).__rebno ?? {}),
    tilesIn,
    tilesOut,
    atlasHasTile1,
    atlasHasTside1,
    roomLayoutTilesetIds,
  };
}
```

**D-40 extension** — spread additional fields into the same block. Add after computing the grid:
```typescript
// D-40 spike: extend existing __rebno block with walkable grid diagnostics.
const walkableCellsTrue = derivedGrid ? derivedGrid.flat().filter(v => v).length : -1;
const walkableCellsTotal = derivedGrid ? derivedGrid.flat().length : -1;
const tilesOOB = (layout.tiles ?? []).filter(t =>
  t.x < 0 || t.x >= layout.width_tiles * layout.tile_w ||
  t.y < 0 || t.y >= layout.height_tiles * layout.tile_h
).length;
const payloadHasCollisionPolys = 'collision_polys' in layout;
const payloadWallBorder = layout.wall_border ?? null;
// Add these to the existing spread in the __rebno block.
```

---

### NEW: `apps/client/test/e2e/cli-08-dup-login.e2e.test.ts` — D-51 multi-context fixture

**Analog:** `apps/client/test/e2e/cli-08.e2e.test.ts` — already uses `browser.newContext()` × 2 pattern (lines 28-31)

**Existing multi-context structure** (cli-08.e2e.test.ts lines 22-31):
```typescript
test('...', async ({ browser, accountA, accountB, inviteSuffix }) => {
  const ctxA = await browser.newContext();
  const ctxB = await browser.newContext();
  const a = await ctxA.newPage();
  const b = await ctxB.newPage();
  try {
    await loginAs(a, accountA, inviteSuffix);
    await loginAs(b, accountB, inviteSuffix);
    await waitForGameReady(a);
    await waitForGameReady(b);
    // ... assertions
  } finally {
    await ctxA.close();
    await ctxB.close();
  }
});
```

**D-51 dup-login variant** — same shape, SAME ACCOUNT on both contexts:
```typescript
// [int->REQ-SRV-03] [int->REQ-CLI-08]
import { test, expect, loginAs, waitForGameReady } from './fixtures.js';

test('D-51 dup-login: second session evicts first; only second tab reaches GameScene', async ({
  browser,
  accountA,
  inviteSuffix,
}) => {
  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();

  try {
    // First session: login and reach GameScene.
    await loginAs(pageA1, accountA, inviteSuffix);
    await waitForGameReady(pageA1);

    // Second session: login with SAME credentials — server evicts A1.
    await loginAs(pageA2, accountA, inviteSuffix);
    await waitForGameReady(pageA2);
    // SOURCE: operator decision 2026-05-13 — second tab must reach GameScene.

    // Assert A1 was evicted to LoginScene (force_reset banner).
    // SOURCE: GameScene.ts:333-345 onForceReset transitions to LoginScene.
    await expect(pageA1.locator('#username')).toBeVisible({ timeout: 10_000 });

    // Assert A2 is still in GameScene (not crashed).
    await expect(
      pageA2.locator('canvas[data-game-ready="true"]'),
    ).toBeVisible({ timeout: 5_000 });
  } finally {
    await ctxA1.close();
    await ctxA2.close();
  }
});
```

**fixtures.ts change:** The existing `fixtures.ts` already exports `loginAs` and `waitForGameReady` — no new fixture function needed. The `accountA` fixture (lines 36-41) provides the same-account scenario; D-51 test uses `accountA` twice (same creds, two contexts).

---

### `apps/client/src/ui/ChatHUD.ts` — D-56 CSS bump (edit-in-place)

**Analog:** self — `apps/client/src/ui/ChatHUD.ts:99-100` (current log container style)

**Current** (line 100):
```typescript
'display: flex; flex-direction: column; gap: 4px; padding: 8px; background: rgba(10,14,26,0.45); border-radius: 4px;'
```

**D-56 bumps** (three changes, same string):
```typescript
// background: 0.45 → 0.75 opacity for readability
// max-height + overflow-y: auto to cap log height
'display: flex; flex-direction: column; gap: 4px; padding: 8px; background: rgba(10,14,26,0.75); border-radius: 4px; max-height: 200px; overflow-y: auto;'
```

Also in `mount()` line 89, the `max-width` property:
```typescript
// BEFORE:
'max-width: 480px',
// AFTER:
'max-width: 560px',
```

---

### `apps/client/index.html` — D-59 preload hint (edit-in-place)

**Analog:** `apps/client/index.html` existing `@font-face` declaration at lines 12-14.

**Add before the `<style>` block:**
```html
<!-- D-59: preload Fixedsys WOFF2 at HTML parse time to minimize BootScene font-gate wait.
     crossorigin is REQUIRED for WOFF2 preload even same-origin — without it the browser
     fetches twice (preload + @font-face).
     SOURCE: MDN <link rel="preload" as="font"> -->
<link rel="preload" href="/assets/fonts/FixedsysExcelsior.woff2" as="font" type="font/woff2" crossorigin>
```

---

### D-40/D-54 spike — server pino logging (edit-in-place: `apps/server/src/RebnoRoom.ts`)

**Analog:** existing pino logger pattern in `apps/server/src/RebnoRoom.ts` — wherever `this.logger.info(...)` or `pino` calls already exist.

Server-side spike logging in `sendRoomLayoutToClient()` (near line 214-254):
```typescript
// D-40 spike: log layout payload fields before encoding to catch server-side bugs.
// Both window.__rebno (client) AND pino (server) per CONTEXT decision.
this.logger.info({
  event: 'd40_layout_broadcast',
  room_id: layout.room_id,
  tilesLength: layout.tiles?.length ?? 0,
  widthTiles: layout.width_tiles,
  heightTiles: layout.height_tiles,
  hasCollisionPolys: 'collision_polys' in layout,
  wallBorder: layout.wall_border ?? null,
}, 'broadcastRoomLayout payload trace');
```

---

### D-52/D-57/D-58 spike — reconciler telemetry extension (edit-in-place: `apps/client/src/prediction/reconciler.ts`)

**Analog:** `apps/client/src/prediction/reconciler.ts:68-98` — existing `window.__rebno` spread-and-merge blocks (lines 68-78 for divergence-threshold, 86-97 for lerp-correction)

**Extended fields for D-57/D-58 idle-state diagnostics** — add to BOTH existing `__rebno` blocks:
```typescript
g.__rebno = {
  ...(g.__rebno ?? {}),
  lastReconcileX: predicted.x,
  lastReconcileY: predicted.y,
  lastReconcileReason: 'divergence-threshold', // or 'lerp-correction'
  lastReconcileSeq: snap.last_input_seq ?? predicted.last_input_seq ?? 0,
  lastReconcileAt: Date.now(),
  // NEW D-57/D-58 fields:
  lastReconcileVx: snap.vx,
  lastReconcileVy: snap.vy,
  lastReconcileLocalX: cur.x,
  lastReconcileLocalY: cur.y,
  lastReconcileDist: dist,
  lastReconcilePredictedX: predicted.x,
  lastReconcilePredictedY: predicted.y,
};
```

---

### D-45 Plan B — flicker spike ring buffer (edit-in-place: `apps/client/src/render/Nameplate.ts` or `PlayerRenderer.ts`)

**Analog:** `apps/client/src/prediction/reconciler.ts:68-78` — unconditional globalThis spread-and-merge

**Ring buffer publish pattern** (add in `PlayerRenderer.onSimulationTickRemote` or `Nameplate.follow`, scoped to FIRST remote only):
```typescript
// D-45 Plan B spike: ring buffer for first remote nameplate history.
// Cap at 60 entries; unconditional (no DEV gate).
if (typeof globalThis !== 'undefined' && isFirstRemote) {
  const g = globalThis as any;
  const hist = g.__rebno?.firstRemoteNameplateHistory ?? [];
  hist.push({
    nameplate_y: textY,
    sprite_y: spriteY,
    sprite_height: spriteHeight,
    sprite_displayHeight: /* pass from caller */ spriteHeight,
    sprite_scaleY: 1, // pass actual if available
    sprite_originY: 1,
    ts: Date.now(),
  });
  if (hist.length > 60) hist.shift();
  g.__rebno = { ...(g.__rebno ?? {}), firstRemoteNameplateHistory: hist };
}
```

---

## Shared Patterns

### window.__rebno unconditional publish
**Source:** `apps/client/src/prediction/reconciler.ts` lines 68-98 AND `apps/client/src/render/RoomRenderer.ts` lines 230-239
**Apply to:** All D-40, D-45 Plan B, D-52/D-57/D-58, D-53 telemetry publishes
```typescript
// Pattern A — globalThis (works in workers + browser):
if (typeof globalThis !== 'undefined') {
  const g = globalThis as any;
  g.__rebno = { ...(g.__rebno ?? {}), fieldName: value };
}
// Pattern B — window (browser-only, used in RoomRenderer):
if (typeof window !== 'undefined') {
  (window as unknown as Record<string, unknown>).__rebno = {
    ...((window as unknown as Record<string, Record<string, unknown>>).__rebno ?? {}),
    fieldName: value,
  };
}
// RULE: NO if (import.meta.env.DEV) gate — RC1 commit cd47745 proved
// staging silences gated hooks. All diagnostic hooks unconditional.
```

### Phaser async boot gate
**Source:** `apps/client/src/scenes/BootScene.ts` lines 45-75 (`async create()` with try/catch + continue)
**Apply to:** D-59 font gate, any future boot-time async await
```typescript
async create(): Promise<void> {
  try {
    await someAsyncOperation();
  } catch (err) {
    console.warn('BootScene: operation failed, continuing', err);
    // degrade gracefully — never throw from create()
  }
  // continue boot...
}
```

### Playwright multi-context two-tab test
**Source:** `apps/client/test/e2e/cli-08.e2e.test.ts` lines 22-31 + `ws-kill-reconnect.e2e.test.ts` lines 16-70
**Apply to:** D-51 dup-login test
```typescript
// Two contexts = two independent cookie jars = two Chrome profiles
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
try {
  // ... test body
} finally {
  await ctxA.close();
  await ctxB.close();
}
// fixtures.loginAs + waitForGameReady are the standard helpers (fixtures.ts)
```

### Phaser tween add + kill
**Source:** `apps/client/src/prediction/reconciler.ts` lines 27-28 (JSDoc specifying `this.tweens.add(...)`)
**Apply to:** D-55 TeleportIn/Out, D-57 tweenTo fix
```typescript
// Always kill before add to prevent tween conflict.
this.tweens.killTweensOf(target);
this.tweens.add({
  targets: target,
  x, y,           // or alpha, scaleX, scaleY
  duration: ms,
  ease: 'Linear',
  onComplete: () => { /* optional */ },
});
```

### Playwright e2e canonical-ref cite (HARD gate 3)
**Source:** `apps/client/test/e2e/cli-08-nameplate-offset.e2e.test.ts` (negative pattern — the test that failed gate)
**Apply to:** All new e2e assertions in 06.3
```typescript
// Every expected value MUST cite its source. One of:
// SOURCE: extracted/client-5-8/objects/.../Draw.dnd.json
// SOURCE: CLAUDE.md extracted constants table
// SOURCE: operator measurement 2026-05-13
// SOURCE: packages/game-logic/src/constants.ts:65-70
const expectedY = spriteTop - NAMETAG_OFFSET_Y; // NAMETAG_OFFSET_Y=1: see above SOURCE
```

---

## No Analog Found

| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `apps/client/src/__test__/nameplate.test.ts` D-59 assertion | test | transform | `document.fonts.check()` in jsdom is not exercised anywhere in the test suite; planner must design the mock approach |

---

## Metadata

**Analog search scope:** `apps/client/src/`, `apps/client/test/e2e/`, `apps/server/src/`, `packages/game-logic/src/`, `packages/protocol/src/`
**Files read for pattern extraction:** 15
**Pattern extraction date:** 2026-05-13

### Key observations for planner

1. **No new `SpriteStateMachine.ts` changes needed.** RESEARCH.md confirms `SpriteStateMachine` is a pure function (`deriveFrame`), not a state machine class. TeleportIn/Out live in `PlayerRenderer.ts` as tween logic gated on `spawnDelayTicks`, not as new `SpriteState` enum values.

2. **D-57 is a one-line fix in `GameScene.ts:511`.** The `tweenTo` adapter stub (`local.setPosition`) must become a real `this.tweens.add(...)` call. This is the root cause of both D-57 (every-few-seconds snap) and most of D-58 (idle desync after un-reconciled drift).

3. **D-54 fix target is `packages/game-logic/src/constants.ts:65-70`**, not `RoomCollision.ts`. The NAVI_MASK values must change from sprite-local coordinates to center-x/feet-y relative offsets. The walkable grid derivation in `RoomCollision.ts` is correct; only the probe coordinate system is wrong.

4. **D-51 Playwright fixture** uses the already-existing `browser.newContext()` pattern from `cli-08.e2e.test.ts`. The only new construct is using `accountA` twice (same credentials) instead of `accountA` + `accountB`.

5. **Depth constants module** (mentioned in prompt as possible new file) is NOT needed. RESEARCH.md confirms a fixed `const NAMEPLATE_DEPTH = 10000` in `Nameplate.ts` is sufficient. No separate depth-registry module is warranted at this phase (depth-registry consolidation is explicitly deferred to Phase 7 per CONTEXT.md).

6. **Protocol version bump checklist** (from CONTEXT.md canonical refs): if D-51 fix or D-40/D-54 fix bumps `PROTOCOL_VERSION`, planner MUST update `packages/protocol/test/state.test.ts:11-12` AND `apps/client/src/__test__/colyseus-client.test.ts:131,141` — both burned the team in cycle-3.
