# Phase 06.1: gap-closure D-39..D-46 (UAT 2026-05-11) - Pattern Map

**Mapped:** 2026-05-11
**Files analyzed:** 2 NEW + 9 MODIFIED + 4 NEW tests
**Analogs found:** 15 / 15 (every NEW file has a strong in-repo analog from Phase 6)

---

## Workspace + Boundary Rules

Phase 06.1 is **edits-only inside the existing tree** — no new workspace members, no new tools, no new directories above `src/`. See `06-PATTERNS.md` §"Workspace + Boundary Rules" for the locked `pnpm-workspace.yaml` boundary; Phase 06.1 inherits it unchanged.

| Touch surface | Workspace member? | Phase 06.1 action |
|---|---|---|
| `apps/client/src/render/*` | YES (via `apps/*`) | MODIFY 4, NEW 2 |
| `apps/client/src/scenes/GameScene.ts` | YES | MODIFY |
| `apps/client/src/prediction/input-dispatcher.ts` | YES | MODIFY |
| `apps/client/public/assets/fonts/FixedsysExcelsior.woff2` | YES (static) | NEW (vendored binary) |
| `apps/client/index.html` | YES | MODIFY (@font-face block) |
| `packages/game-logic/src/{constants,step,types}.ts` | YES | MODIFY |
| `tools/asset-pipeline/src/bootstrap.ts` | NO (standalone tool) | MODIFY |
| `apps/server/public/{atlas-mvp.{png,json},pipeline-manifest.json}` | YES (static) | REGEN |
| `apps/client/test/e2e/*.e2e.test.ts` | YES | NEW 4 |
| `tools/asset-pipeline/test/alpha-key.test.ts` | NO | NEW |
| `packages/game-logic/tests/*.test.ts` | YES | NEW 4 |

**Critical purity boundary:** `packages/game-logic/src/**` is policed by `tools/scripts/lint-game-logic-purity.mjs` (regex denylist for `Date.*`, `Math.random`, `fs.*`, `process.*`, `fetch(`, `node:fs`, `node:os`). The NaviMask bbox + walkable-region edge-block port must add no forbidden APIs. `step()` receives the walkable grid as data via `state.room_layout` — no Phaser, no DOM imports.

---

## File Classification

### Modified files (apps/client/src/render/)

| File | Role | Data Flow | Closest Analog | Match Quality |
|------|------|-----------|----------------|---------------|
| `PlayerRenderer.ts` | renderer | sim-tick callsite | self @ Phase 6 (lines 128-187) | exact (in-place edit) |
| `SpriteStateMachine.ts` | pure-sim helper (render) | sim-tick transform | self @ Phase 6 (lines 158-199) | exact (in-place edit) |
| `RoomRenderer.ts` | renderer + asset-load | room-load transform | self @ Phase 6 (lines 103-167) | exact (in-place edit) |
| `Nameplate.ts` | renderer (chrome) | follow-callback | self @ Phase 6 (lines 42-48) | exact (in-place edit) |

### NEW files (apps/client/src/render/)

| File | Role | Data Flow | Closest Analog | Match Quality |
|------|------|-----------|----------------|---------------|
| `BackgroundRenderer.ts` | renderer | sim-tick + render-frame | `RoomRenderer.ts` (sprite group + atlas-keyed frames + dispose) | exact-role |
| `RoomCollision.ts` | pure helper (browser-side) | room-load transform | `RoomRenderer.renderNew` (lines 103-125, tiles[] scan) + `packages/game-logic/src/collision.ts` (AABB-only helpers) | role-match |

### Modified files (packages/game-logic/)

| File | Role | Data Flow | Closest Analog | Match Quality |
|------|------|-----------|----------------|---------------|
| `constants.ts` | pure data | n/a | self @ Phase 4 (lines 22-33: WALK_SPEED with full GML citation comment) | exact (in-place edit, citation pattern) |
| `step.ts` | pure-sim | server-shared (Node + browser) | self @ Phase 4 (lines 70-160 movement loop) | exact (in-place edit) |
| `types.ts` | pure data | server-shared | self @ Phase 4 (lines 30-33: `RoomLayout`) | exact (in-place edit, add walkable-grid field) |

### Modified files (apps/client/src/scenes/, prediction/)

| File | Role | Data Flow | Closest Analog | Match Quality |
|------|------|-----------|----------------|---------------|
| `scenes/GameScene.ts` | scene-wiring | render-frame + sim-tick | self @ Phase 6 (lines 555-581 applyCameraFollow; 634-685 update loop) | exact (in-place edit) |
| `prediction/input-dispatcher.ts` | net/input | event-driven keydown/up | self @ Phase 6 (lines 90-152) | exact (in-place edit) |
| `index.html` | static-shell | n/a | self (Vite scaffold from Phase 6) | exact (add `<style>@font-face`) |

### Modified files (tools/asset-pipeline/)

| File | Role | Data Flow | Closest Analog | Match Quality |
|------|------|-----------|----------------|---------------|
| `src/bootstrap.ts` | asset-tool (build-time) | file-I/O transform (BMP→PNG) | self @ Phase 6 (lines 99-122 decode→sharp→buffer) | exact (in-place edit, insert alpha-key step) |

### NEW test files

| File | Role | Data Flow | Closest Analog | Match Quality |
|------|------|-----------|----------------|---------------|
| `tools/asset-pipeline/test/alpha-key.test.ts` | unit-test | n/a | `tools/asset-pipeline/test/bootstrap.test.ts` (existing) | exact |
| `packages/game-logic/tests/run-speed.test.ts` | unit-test | n/a | `packages/game-logic/tests/step.test.ts` (existing) | exact |
| `packages/game-logic/tests/navi-mask-bbox.test.ts` | unit-test | n/a | same | exact |
| `packages/game-logic/tests/wall-slide.test.ts` | unit-test (pure-sim) | n/a | same | exact |
| `packages/game-logic/tests/walkable-edge.test.ts` | unit-test (pure-sim) | n/a | same | exact |
| `apps/client/test/e2e/cli-08-camera.e2e.test.ts` | e2e | n/a | `apps/client/test/e2e/camera-follow.e2e.test.ts` (lines 16-89) | exact (rebuild of same shape post-Phase 6.1) |
| `apps/client/test/e2e/cli-08-tiles.e2e.test.ts` | e2e | n/a | `apps/client/test/e2e/camera-follow.e2e.test.ts` (fixtures + `__rebno` hook) | role-match |
| `apps/client/test/e2e/cli-08-nameplate.e2e.test.ts` | e2e | n/a | `apps/client/test/e2e/sprite-state.e2e.test.ts` (lines 48-59 DOM-mirror assertion) | exact |
| `apps/client/test/e2e/cli-08-anim.e2e.test.ts` | e2e | n/a | `apps/client/test/e2e/sprite-state.e2e.test.ts` (lines 16-46 frame-string assertion) | exact |

---

## Pattern Assignments

### `apps/client/src/render/BackgroundRenderer.ts` (NEW — renderer, sim-tick + render-frame)

**Analog:** `apps/client/src/render/RoomRenderer.ts` (sprite group + atlas-keyed frames + dispose lifecycle) + `apps/client/src/render/PlayerRenderer.ts` (sim-tick callsite contract).

**Imports + scene constructor pattern** (copy from `RoomRenderer.ts:25-86`):

```ts
import Phaser from 'phaser';

export class BackgroundRenderer {
  private group: Phaser.GameObjects.Group;
  // bkdraw state (mirrors 0051-bkdraw object's instance vars):
  private dxoff = 0;          // scrolled px on x — wraps on |dxoff| >= sprite_w*dscale
  private dyoff = 0;          // scrolled px on y
  private bgframe = 0;        // fractional frame index, wraps at bgframes (=55)
  private readonly dxspeed = 0.25;   // BNCentral instance creationCode (instances.json:3)
  private readonly dyspeed = -1;
  private readonly bgspeed = 0.25;   // = image_speed per 0051-bkdraw/Create.gml:2
  private readonly spriteW = 32;     // BKA1 meta.json
  private readonly spriteH = 32;
  private readonly bgframes = 55;    // BKA1 frame count

  constructor(
    private scene: Phaser.Scene,
    private atlasKey: string = 'atlas-mvp',
  ) {
    this.group = scene.add.group();
  }
  // ... build(), onSimulationTick(), dispose()
}
```

**Sprite-group factory pattern (mirror `RoomRenderer.renderNew` lines 113-125):**

```ts
// build(): on first call, create a (viewW/32 + 2) × (viewH/32 + 2) grid of sprites.
// Each tile gets scrollFactor=0 so positioning is relative to the camera viewport,
// not world-absolute. Position is set every render frame in update().
if (this.scene.textures.exists(this.atlasKey)) {
  for (let row = 0; row < tilesY; row++) {
    for (let col = 0; col < tilesX; col++) {
      const s = this.scene.add
        .sprite(0, 0, this.atlasKey, '0064-BKA1_000')
        .setOrigin(0, 0)
        .setScrollFactor(0)         // viewport-anchored (RESEARCH Q4)
        .setDepth(-10000);          // far behind all world content
      this.group.add(s);
    }
  }
}
```

**Sim-tick callsite contract (copy from `PlayerRenderer.ts:128-158`):**

```ts
/**
 * D6.1-18/19: per-sim-tick advance.
 * MUST be called from GameScene update() sim-tick block (NOT per render frame).
 * Citation: 0051-bkdraw/events/Step.gml — dxoff/dyoff/bgframe advance once per step.
 */
onSimulationTick(): void {
  this.dxoff += this.dxspeed;
  if (Math.abs(this.dxoff) >= this.spriteW) this.dxoff -= Math.sign(this.dxoff) * this.spriteW;
  this.dyoff += this.dyspeed;
  if (Math.abs(this.dyoff) >= this.spriteH) this.dyoff -= Math.sign(this.dyoff) * this.spriteH;
  this.bgframe += this.bgspeed;
  if (this.bgframe >= this.bgframes) this.bgframe -= this.bgframes;
}
```

**Per-render-frame position update** (called from `GameScene.update()` **every** frame, not gated by sim-tick — keeps wrap-around visually smooth even between 30 Hz ticks):

```ts
// Iterate this.group.getChildren() ; for each sprite at grid (col, row):
const frameIdx = Math.floor(this.bgframe);
const frameKey = `0064-BKA1_${String(frameIdx).padStart(3, '0')}`;
for (const child of this.group.getChildren()) {
  const s = child as Phaser.GameObjects.Sprite;
  // position = (col*32 + dxoff - 32, row*32 + dyoff - 32) within viewport
  // (-32 = one tile of bleed each side)
  s.setPosition(/* computed col*spriteW + dxoff - spriteW */, /* row*spriteH + dyoff - spriteH */);
  s.setFrame?.(frameKey);   // guard for Rectangle fallback (Pitfall 6)
}
```

**Dispose pattern (copy verbatim from `RoomRenderer.ts:264-274`):**

```ts
dispose(): void {
  this.group.clear(true, true);
  // any other Phaser objects: destroy them and null the field
}
```

**Frame-key naming convention pinned:** `${spriteId}_${padStart(3,'0')}` — atlas-mvp.json key format. Same convention used by `RoomRenderer.renderNew` line 117 and `SpriteStateMachine.padPhase`. Do not invent a new format.

**Fallback safety:** `setFrame?.(...)` optional chaining matches `PlayerRenderer.ts:138, 181` — Phaser Rectangle (atlas-missing fallback) has no `setFrame`. Keep the guard.

---

### `apps/client/src/render/RoomCollision.ts` (NEW — pure helper, room-load transform)

**Analog:** `RoomRenderer.renderNew` lines 103-125 (tile grid iteration) + `packages/game-logic/src/collision.ts` lines 32-34 (AABB-only point-in-rect helpers).

**Hybrid responsibility:**
- **Browser side** (`apps/client/src/render/RoomCollision.ts`): derive the walkable-region boolean grid at room-load by scanning `layout.tiles[]`. Emit `WalkableGrid = { width: number; height: number; tile_w: number; tile_h: number; cells: Uint8Array }`.
- **Pure-sim side** (`packages/game-logic/src/step.ts`): consume the same grid via `state.room_layout.walkable_grid`. The grid is per-entity layout data — passed in, never imported across the workspace-purity boundary.

**Pattern (tile-grid iteration — copy shape from `RoomRenderer.renderNew:113-125`):**

```ts
// apps/client/src/render/RoomCollision.ts
// [impl->REQ-CLI-04]
// Derive a per-cell boolean walkable grid from layout.tiles[]. Each tile is
// 44×40 (extracted constant — CLAUDE.md), so cell coords are floor(x/44),
// floor(y/40). The grid is the UNION of all floor-tile rectangles.

export interface WalkableGrid {
  width_tiles: number;
  height_tiles: number;
  tile_w: 44;
  tile_h: 40;
  /** Row-major flat array: cells[row * width_tiles + col] = 1 if walkable */
  cells: Uint8Array;
}

export function deriveWalkableGrid(layout: {
  width_tiles: number;
  height_tiles: number;
  tile_w: number;
  tile_h: number;
  tiles: ReadonlyArray<{ tileset_sprite_id: string; x: number; y: number }>;
}): WalkableGrid {
  const cells = new Uint8Array(layout.width_tiles * layout.height_tiles);
  for (const t of layout.tiles) {
    // Per CLAUDE.md: tile pitch is 44×40, NEVER 32×32.
    const col = Math.floor(t.x / layout.tile_w);
    const row = Math.floor(t.y / layout.tile_h);
    if (col >= 0 && col < layout.width_tiles && row >= 0 && row < layout.height_tiles) {
      cells[row * layout.width_tiles + col] = 1;
    }
  }
  return {
    width_tiles: layout.width_tiles,
    height_tiles: layout.height_tiles,
    tile_w: 44, tile_h: 40,
    cells,
  };
}

/**
 * Probe a NaviMask feet-bbox rectangle against the walkable grid. Returns true
 * only if ALL 4 corners fall inside walkable cells. Used by the per-axis sub-
 * pixel collision loop in packages/game-logic/src/step.ts.
 */
export function isFeetBboxWalkable(
  grid: WalkableGrid,
  feetLeft: number, feetTop: number, feetRight: number, feetBottom: number,
): boolean {
  // Sample 4 corners; for a 18×7 bbox at most 2 cells span each axis
  for (const px of [feetLeft, feetRight]) {
    for (const py of [feetTop, feetBottom]) {
      const col = Math.floor(px / grid.tile_w);
      const row = Math.floor(py / grid.tile_h);
      if (col < 0 || col >= grid.width_tiles || row < 0 || row >= grid.height_tiles) return false;
      if (grid.cells[row * grid.width_tiles + col] !== 1) return false;
    }
  }
  return true;
}
```

**Bottom-edge TSide1 derivation (used by `RoomRenderer.renderNew` after this helper builds the grid):**

```ts
// Citation: extracted/client-5-8/scripts/0085-tileborder.gml:1-7
// For each floor tile whose bottom-neighbor cell is NOT walkable, place a
// 0024-TSide1 at (tile.x, tile.y + 40). Top/left/right are commented-out
// in the legacy GML and MUST NOT be reactivated (CONTEXT D6.1-25 anti-port).
export function bottomEdgeTiles(grid: WalkableGrid): Array<{ x: number; y: number }> {
  const out: Array<{ x: number; y: number }> = [];
  for (let row = 0; row < grid.height_tiles; row++) {
    for (let col = 0; col < grid.width_tiles; col++) {
      if (grid.cells[row * grid.width_tiles + col] !== 1) continue;
      // Bottom-neighbor: out-of-bounds counts as "not floor" → emit side
      const isBottomFloor =
        row + 1 < grid.height_tiles &&
        grid.cells[(row + 1) * grid.width_tiles + col] === 1;
      if (!isBottomFloor) {
        out.push({ x: col * grid.tile_w, y: row * grid.tile_h + grid.tile_h });
      }
    }
  }
  return out;
}
```

**Purity self-check:** RoomCollision.ts lives under `apps/client/src/render/` so it is NOT scanned by `lint-game-logic-purity.mjs` (which only walks `packages/game-logic/src`). However, the helper functions are pure-data-in / pure-data-out and CAN be moved into `packages/game-logic` later if the planner wants to reuse them server-side. Keeping them in the client renderer for MVP avoids a wire-format change for the grid.

---

### `packages/game-logic/src/step.ts` (MODIFY — pure-sim, NaviMask bbox + per-axis sub-pixel + walkable-region)

**Analog:** Self (current `step()` at lines 27-172). Modify in place; preserve the function signature `step(state, inputs, dt_ms) → state`.

**Imports + purity contract (already in place at lines 11-20 — extend, don't replace):**

```ts
// Add to imports:
import { NAVI_MASK } from './constants.js';
// REMOVE the `resolveCollision` import; replace with per-axis bbox probes against
// state.room_layout.walkable_grid (new field — extend RoomLayout type).
```

**Per-axis sub-pixel collision loop (replace lines 127-146 — `desired`/`resolveCollision` block — with):**

Citation: `extracted/client-5-8/objects/0000-server/events/Step.gml:222-243` (already cited in RESEARCH §Pattern 4 lines 252-286).

```ts
// Per-axis sub-pixel collision loop — BNO canonical.
// Citation: extracted/client-5-8/objects/0000-server/events/Step.gml:222-243
// "x+9,y+39,x+26,y+47 (navi collision bounds w/1 pixel coushin)"
let px = player.x;
let py = player.y;

// X-axis: advance 1 px at a time, check feet bbox against walkable grid
const moveX = Math.round(dx);
const signX = Math.sign(moveX);
for (let i = 0; i < Math.abs(moveX); i += 1) {
  const probeX = px + signX;
  if (
    isFeetBboxWalkable(
      state.room_layout.walkable_grid,
      probeX + NAVI_MASK.left,
      py + NAVI_MASK.top,
      probeX + NAVI_MASK.right,
      py + NAVI_MASK.bottom,
    )
  ) {
    px = probeX;
  } else {
    break;  // hit wall — stay at last clear pixel
  }
}

// Y-axis: same pattern (yields wall-slide on diagonals naturally)
const moveY = Math.round(dy);
const signY = Math.sign(moveY);
for (let i = 0; i < Math.abs(moveY); i += 1) {
  const probeY = py + signY;
  if (
    isFeetBboxWalkable(
      state.room_layout.walkable_grid,
      px + NAVI_MASK.left,
      probeY + NAVI_MASK.top,
      px + NAVI_MASK.right,
      probeY + NAVI_MASK.bottom,
    )
  ) {
    py = probeY;
  } else {
    break;
  }
}

// Final position — no clampToRoom (camera is unbounded; walkable-grid IS the bound)
newPlayers.set(account_id, {
  x: px, y: py,
  vx: 0, vy: 0,
  last_input_seq,
  sprite_id: player.sprite_id,
  room_id: player.room_id,
});
```

**Inline-import `isFeetBboxWalkable`:** to keep `step.ts` pure (no client renderer dep), copy the helper into `packages/game-logic/src/collision.ts` (which is also lint-purity-policed). Replace `collision.ts` body — the existing `resolveCollision` becomes dead code on the new path; keep it exported for legacy `step()` tests, or delete it if no callers remain.

**`WalkableGrid` type lives in `packages/game-logic/src/types.ts`** (extend `RoomLayout`):

```ts
export interface RoomLayout {
  collision_polys: ReadonlyArray<ReadonlyArray<{ x: number; y: number }>>;  // legacy — keep for Phase 4 server fallback
  room_size: { w: number; h: number };
  /** D6.1-29: per-cell walkable boolean grid (1 byte per cell). */
  walkable_grid: {
    width_tiles: number;
    height_tiles: number;
    tile_w: number;
    tile_h: number;
    cells: Uint8Array;
  };
}
```

**Server-side sync (out-of-scope-but-load-bearing):** `apps/server/src/RebnoRoom.ts` constructs the initial `WorldState.room_layout` somewhere; the planner must hand it a `walkable_grid` derived from the same room layout at load time. The pure-sim `step()` will then run identically on server + client. If the server-side derivation is too invasive for Phase 06.1, the simpler path is: planner adds a defaulted grid (all-walkable) as fallback and relies on **client-side prediction** for the wall-slide visual (server still reconciles via prior `collision_polys` until Phase 7 protocol bump).

---

### `packages/game-logic/src/constants.ts` (MODIFY — rename WALK→RUN, add NAVI_MASK)

**Analog:** Self (current `WALK_SPEED_PX_PER_TICK` at lines 22-33). The existing comment block is the canonical "extract → cite → name" pattern; mirror it byte-for-byte for the new constants.

**Rename + add (concrete edits):**

```ts
/**
 * Run speed in pixels per tick at the BNO tick rate (30 Hz).
 *
 * SOURCE: extracted/client-5-8/objects/0000-server/events/KeyPress-82.gml:5-8
 *   if(global.curspeed == 3) {
 *     global.curspeed = 5;       // ← RUN speed (rebno single-speed)
 *     image_speed = 0.5;          // = 5/10
 *   }
 *
 * rebno is single-speed (no Ctrl+R walk/run toggle). The legacy curspeed=3
 * walk path is dropped per CONTEXT D6.1-05.
 */
export const RUN_SPEED_PX_PER_TICK: number = 5;

/**
 * Animation speed coefficient (frames advanced per simulation tick).
 *
 * SOURCE: extracted/client-5-8/objects/0000-server/events/Other-7.gml:4
 *   image_speed = global.curspeed/10;
 *
 * At curspeed=5 (RUN), frame advances 0.5 per tick → 15 fps at 30 Hz over
 * 6-frame run sprites → full cycle every 12 ticks (~400 ms).
 */
export const FRAMES_PER_TICK_AT_RUN: number = RUN_SPEED_PX_PER_TICK / 10;

/**
 * Player collision bbox — NaviMask feet rectangle at sprite bottom-center.
 *
 * SOURCE: extracted/client-5-8/sprites/0034-NaviMask/meta.json
 *   { "bboxLeft": 9, "bboxTop": 40, "bboxRight": 26, "bboxBottom": 46 }
 *
 * 18 px wide × 7 px tall feet rect. Used by per-axis sub-pixel collision
 * loop in step.ts. Pinned by a unit test against the source meta.json so a
 * regen of the sprite asset is detected immediately.
 */
export const NAVI_MASK = {
  left: 9, top: 40, right: 26, bottom: 46,
} as const;
```

**Delete `WALK_SPEED_PX_PER_TICK`** — call sites are:
- `packages/game-logic/src/step.ts:14, 82, 83, 99` — rename to `RUN_SPEED_PX_PER_TICK`.
- `apps/client/src/scenes/GameScene.ts:653, 654` — replace `* 3` magic with `* RUN_SPEED_PX_PER_TICK` (import from `@rebno/game-logic`).

**Citation comment pattern** (mandatory per CLAUDE.md hard rule #6): every new constant carries `SOURCE: <extracted-path>:<line>` in its JSDoc. The existing file has 6 perfect examples (lines 10-128) — match the prose style.

---

### `packages/game-logic/src/types.ts` (MODIFY — add walkable_grid to RoomLayout)

**Analog:** Self (current `RoomLayout` interface at lines 30-33).

**Add field** (additive, non-breaking — server can fill with all-1s grid until ported):

```ts
export interface RoomLayout {
  collision_polys: ReadonlyArray<ReadonlyArray<{ x: number; y: number }>>;
  room_size: { w: number; h: number };
  /** D6.1-29: per-cell walkable boolean mask (44×40 cells per CLAUDE.md). */
  walkable_grid: {
    width_tiles: number;
    height_tiles: number;
    tile_w: number;
    tile_h: number;
    cells: Uint8Array;
  };
}
```

**`Uint8Array` is browser-safe and Node-safe** — no `node:fs` import needed. Passes `lint-game-logic-purity.mjs` (no forbidden APIs).

---

### `apps/client/src/render/PlayerRenderer.ts` (MODIFY — depth-set per sim-tick)

**Analog:** Self (current `onSimulationTickLocal` at lines 128-158 and `onSimulationTickRemote` at lines 165-187).

**Insert `setDepth` call after `setFrame`** (D6.1-22):

Citation: `extracted/client-5-8/objects/0000-server/events/Step.gml:5` — `depth_set(0, 43)` → `layer=0, yOffset=43`.

```ts
// Within onSimulationTickLocal AND onSimulationTickRemote, after setFrame/setPosition:
// D6.1-21/22: per-sim-tick depth refresh (player y changes → depth changes).
// Citation: 0354-depth_set.gml:  depth = round((1000*layer) - (y + yOffset))
// GameMaker: lower depth = on top; Phaser: higher depth = on top → sign-flip.
const PLAYER_LAYER = 0;
const PLAYER_Y_OFFSET = 43;
const gmDepth = Math.round(1000 * PLAYER_LAYER - (y + PLAYER_Y_OFFSET));
this.local.sprite.setDepth?.(-gmDepth);   // optional-chain for Rectangle fallback (Pitfall 6)
```

**Inline constants pattern (CONTEXT D6.1-24):** layer/yOffset values live inline in each renderer file. Do NOT introduce a `depth-registry.ts` for MVP — that consolidation is deferred to Phase 7.

**Sprite-recreation signal for D-46 self-heal (insert in `ensureLocal` lines 76-97):** if `this.local` already exists OR was just torn down, callers need to know the sprite reference changed. Add a return-value distinction or a callback option so `GameScene.applyCameraFollow` can reset `cameraFollowApplied = false`:

```ts
// Option A: return both sprite + "wasRecreated" flag
ensureLocal(...): { sprite: Phaser.GameObjects.Sprite; wasRecreated: boolean } {
  if (this.local) return { sprite: this.local.sprite, wasRecreated: false };
  // ... create new sprite path ...
  return { sprite, wasRecreated: true };
}
// Option B (less invasive): add a public getter
isLocalSpriteFresh(): boolean { /* tracks last-create timestamp or boolean flag */ }
```

Planner picks; option A is cleaner.

---

### `apps/client/src/render/SpriteStateMachine.ts` (MODIFY — fractional framesPerTick)

**Analog:** Self (current `deriveFrame` at lines 158-199). The `TICKS_PER_FRAME_ADVANCE = 1` boolean constant at line 28 is replaced.

**Signature change (D6.1-06):**

```ts
// REMOVE:
//   export const TICKS_PER_FRAME_ADVANCE = 1 as const;

// REPLACE deriveFrame fractional-phase logic — copy the existing function
// shape but replace integer accumulator with fractional accumulator.
//
// Citation: extracted/client-5-8/objects/0000-server/events/Other-7.gml:4
//   image_speed = global.curspeed / 10;   (= 0.5 at curspeed=5)

export function deriveFrame(
  vx: number,
  vy: number,
  lastFacing: Direction,
  cyclePhase: number,
  tickAccumulator: number,
  framesPerTick: number,   // NEW param — caller passes FRAMES_PER_TICK_AT_RUN (=0.5)
): SpriteState {
  // ... isRunning/deriveFacing unchanged ...

  // D6.1-06 fractional advance:
  const advanced = tickAccumulator + framesPerTick;
  const wholeFrames = Math.floor(advanced);
  const newAccumulator = advanced - wholeFrames;
  const newPhase = (cyclePhase + wholeFrames) % RUN_FRAME_COUNT[facing];

  return { facing, cyclePhase: newPhase, tickAccumulator: newAccumulator,
           isRunning: true, frameKey: `${RUN_SPRITE_ID[facing]}_${padPhase(newPhase)}` };
}
```

**Callsite updates** (`PlayerRenderer.onSimulationTickLocal/Remote`): pass `FRAMES_PER_TICK_AT_RUN` from `@rebno/game-logic` constants as the 6th argument.

---

### `apps/client/src/render/RoomRenderer.ts` (MODIFY — D-40 fix + tile depth + TSide1)

**Analog:** Self (`renderNew` at lines 103-167; tile loop at 113-125 is the patch site).

**D-40 root-cause investigation (insert log; remove once root-causaed per RESEARCH Pitfall 1-3):**

```ts
// Inside renderNew tile loop (line 114):
for (const t of layout.tiles) {
  const frameName = `${t.tileset_sprite_id}_${String(t.frame ?? 0).padStart(3, '0')}`;
  const has = this.scene.textures.get(this.atlasKey).has(frameName);
  if (!has) {
    // eslint-disable-next-line no-console
    console.warn('RoomRenderer: missing atlas frame', frameName);   // D-40 verify step 1
    continue;
  }
  // ... existing sprite creation
}
```

**Tile + TSide1 depth-set + bottom-edge placement (insert after tile sprite created):**

Citations: `0020-borderedtile/events/Create.gml:2` → `depth_set(2, 0)`; `0021-tside1/events/Create.gml:2` → `depth_set(3, 0)`.

```ts
const TILE_LAYER = 2, TILE_Y_OFFSET = 0;
const TSIDE_LAYER = 3, TSIDE_Y_OFFSET = 0;

const tileSprite = this.scene.add
  .sprite(t.x, t.y, this.atlasKey, frameName)
  .setOrigin(0, 0);
// D6.1-23: per-tile depth (sign-flip — Phaser higher = on top)
tileSprite.setDepth(-(1000 * TILE_LAYER - (t.y + TILE_Y_OFFSET)));
this.group.add(tileSprite);

// D6.1-25: bottom-edge TSide1 placement via walkable-grid neighbor check
// Build the grid first, then iterate bottomEdgeTiles().
```

**Walkable-grid derivation (insert at top of `renderNew` before tile loop, expose to GameScene via `this.layout`):**

```ts
import { deriveWalkableGrid, bottomEdgeTiles } from './RoomCollision.js';

// After `this.layout = layout;` in render():
const grid = deriveWalkableGrid(layout);
// Stash on `this` so GameScene can hand it to step() via room_layout.walkable_grid
this.walkableGrid = grid;

// In renderNew, after tile loop, render TSide1 sides:
const TSIDE_SPRITE = '0024-TSide1';
for (const side of bottomEdgeTiles(grid)) {
  const frameName = `${TSIDE_SPRITE}_000`;
  if (!this.scene.textures.get(this.atlasKey).has(frameName)) continue;
  const s = this.scene.add
    .sprite(side.x, side.y, this.atlasKey, frameName)
    .setOrigin(0, 0);
  s.setDepth(-(1000 * TSIDE_LAYER - (side.y + TSIDE_Y_OFFSET)));
  this.group.add(s);
}
```

**Public accessor for GameScene to bridge into `step()` state:**

```ts
public walkableGrid: WalkableGrid | undefined;
// Mirror existing `worldSize()` / `viewportParams()` accessor pattern at lines 247-262
```

**Drop wall_border code path** (lines 127-166): mark as dormant (keep for `mvp-lobby` legacy support, but new BNO-canonical rooms do not emit it). No deletion — leave the code intact, just unused.

---

### `apps/client/src/render/Nameplate.ts` (MODIFY — Fixedsys + cyan/white + shadow + position math)

**Analog:** Self (current text-creation at lines 42-49).

**Concrete edits (D6.1-10..13):**

```ts
// Constructor signature — add isLocal parameter:
constructor(
  scene: Phaser.Scene,
  public readonly username: string,
  x: number, y: number,
  domOverlay?: HTMLElement,
  sessionId?: string,
  isLocal: boolean = false,   // NEW — D6.1-11 color rule
) {
  this.text = scene.add
    .text(x, y, username, {
      // D6.1-10: Fixedsys Excelsior WOFF2 with monospace fallback
      fontFamily: '"Fixedsys Excelsior", monospace',
      fontSize: '16px',
      // D6.1-11: c_aqua local / c_white remote (citation: Draw.gml:2-3)
      color: isLocal ? '#00FFFF' : '#FFFFFF',
      // D6.1-12: 1 px solid black drop shadow at (1,1) — operator-requested
      shadow: { offsetX: 1, offsetY: 1, color: '#000000', blur: 0, fill: true },
    })
    .setOrigin(0.5, 1);
  // ... DOM mirror unchanged
}
```

**Position math (D6.1-13):** Current `follow()` at lines 72-77 already does `spriteY - spriteHeight/2 - NAMETAG_OFFSET_Y`. BNO canonical is `y - 16` (with sprite origin 0.5/1 — feet anchor — so equivalent to current math when NAMETAG_OFFSET_Y is calibrated). Verify by test; no code change unless visual regression.

**Callsite updates** (`PlayerRenderer.ts`): both `ensureLocal` (line 79-86) and `addRemote` (line 199-206) call `new Nameplate(...)` — pass `isLocal: true` for local, `false` for remote.

**Index.html @font-face block (D6.1-10):**

```html
<!-- apps/client/index.html — add to <head> -->
<style>
@font-face {
  font-family: 'Fixedsys Excelsior';
  src: url('/assets/fonts/FixedsysExcelsior.woff2') format('woff2');
  font-display: block;   /* wait for font before rendering — avoids fallback flicker */
}
</style>
```

---

### `apps/client/src/scenes/GameScene.ts` (MODIFY — D-46 deferred startFollow + idempotent applyCameraFollow + drop setBounds + RUN_SPEED + BackgroundRenderer wire-up)

**Analog:** Self (current `applyCameraFollow` at lines 555-581; update loop at lines 634-685; layout-handler at lines 500-553).

**Concrete edits:**

**1. Drop `setBounds`, add `setRoundPixels`, lock deadzone to 32×32 (D6.1-14/16):** Replace lines 564-581:

```ts
private applyCameraFollow(): void {
  const localSprite = this.playerRenderer?.getLocalSprite();
  if (!localSprite) return;
  // D6.1-15 idempotent: if camera already follows THIS sprite, no-op.
  // If follow target was destroyed (cookie-resume self-heal), re-bind.
  // Phaser private: cameras.main._follow holds the current target.
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const currentTarget = (this.cameras.main as any)._follow;
  if (this.cameraFollowApplied && currentTarget === localSprite) return;

  // D6.1-16: camera UNBOUNDED — DO NOT call setBounds().
  // World feels infinite; bkdraw background masks the void past room edge.
  this.cameras.main.startFollow(localSprite, true);   // roundPixels=true
  this.cameras.main.setDeadzone(32, 32);              // D6.1-14 — BNCentral hBorder/vBorder
  this.cameras.main.setRoundPixels(true);             // D6.1-16 — HiDPI nearest-neighbor
  this.cameraFollowApplied = true;
}
```

**2. Defer startFollow until sprite exists (D6.1-15):** the existing call at line 552 already runs after `this.roomRenderer?.render(layout)`. The bug is that local sprite isn't created until `onLocalSnapshot` populates it; add a second invocation point in the onLocalSnapshot handler (find it in GameScene around line 380-460 — planner will identify) so `applyCameraFollow()` re-runs when the local sprite first exists. Existing `cameraFollowApplied` flag prevents double-apply unless target changed.

**3. Reset `cameraFollowApplied` when PlayerRenderer recreates the sprite (Pitfall 5):** if option A from PlayerRenderer (returning `wasRecreated`), set `cameraFollowApplied = false` whenever `wasRecreated === true`.

**4. Update loop — replace `* 3` magic with `RUN_SPEED_PX_PER_TICK` (lines 653-654):**

```ts
import { RUN_SPEED_PX_PER_TICK, FRAMES_PER_TICK_AT_RUN } from '@rebno/game-logic';
// ...
const vx = (p.axis_x_held ?? 0) * RUN_SPEED_PX_PER_TICK;
const vy = (p.axis_y_held ?? 0) * RUN_SPEED_PX_PER_TICK;
this.playerRenderer.onSimulationTickRemote(sessionId, vx, vy, p.x ?? 0, p.y ?? 0);
```

**5. Background wire-up (D6.1-17):**

```ts
import { BackgroundRenderer } from '../render/BackgroundRenderer.js';

// In create() after roomRenderer init:
this.backgroundRenderer = new BackgroundRenderer(this);
this.backgroundRenderer.build();

// In update() sim-tick block (currently lines 639-664):
if (this.simTickAccumulator >= SIM_TICK_MS) {
  // ... existing local + remote sim-tick calls ...
  this.backgroundRenderer?.onSimulationTick();   // bkdraw advance
}
// At end of update() (every render frame, NOT gated by sim-tick):
this.backgroundRenderer?.tickRender(this.cameras.main);   // position sprites
```

**6. Sim-tick accumulator subtraction (Pitfall 4):** Replace line 640 `this.simTickAccumulator = 0` with `this.simTickAccumulator -= SIM_TICK_MS` and add a cap to prevent spiral-of-death:

```ts
while (this.simTickAccumulator >= SIM_TICK_MS) {
  this.simTickAccumulator -= SIM_TICK_MS;
  // (cap accumulator at 5*SIM_TICK_MS before this block to prevent spiral)
  // ... existing sim-tick body ...
}
```

**7. D-40 bail-open or fix (Pitfall 2):** the `if (!verified) return;` at line 529-537 short-circuits rendering when Ed25519 verify fails. Either change to "log and render anyway" (single-line edit) OR fix the canonical stringification. Planner picks; verification log via `console.warn` already in place — promote bail-open if Pitfall 2 is the D-40 root cause.

---

### `apps/client/src/prediction/input-dispatcher.ts` (MODIFY — Shift-stand)

**Analog:** Self (current key-tracker at lines 30-32, `currentAxes` at lines 139-145, `onKey` at lines 122-137).

**Concrete edits (D6.1-09):**

**1. Add Shift to tracked keys + facing-hint state:**

```ts
const SHIFT_KEYS = new Set(['SHIFT']);
const MOVE_KEYS = new Set([...X_KEYS, ...Y_KEYS, ...SHIFT_KEYS]);

// Add private field
private shiftDown = false;
private facingHint: { x: -1 | 0 | 1; y: -1 | 0 | 1 } = { x: 0, y: 0 };
```

**2. Handle Shift in `onKey` (after line 124's `_frozen` check):**

```ts
if (SHIFT_KEYS.has(key)) {
  this.shiftDown = kind === 'down';
  // Re-dispatch so server learns about the axes flip
  this.dispatch();
  return;
}
```

**3. Modify `currentAxes` (lines 139-145):**

```ts
private currentAxes(): { x: Axis; y: Axis } {
  const heldX: Axis = this.xOrder[0] === 'D' ? 1 : this.xOrder[0] === 'A' ? -1 : 0;
  const heldY: Axis = this.yOrder[0] === 'S' ? 1 : this.yOrder[0] === 'W' ? -1 : 0;
  // D6.1-09: Shift held + direction → axes (0,0), preserve facingHint locally.
  // Server treats this as "no movement"; remote players see this player stand.
  // Local renderer reads facingHint to pick STAND_FRAME[facing] direction.
  if (this.shiftDown && (heldX !== 0 || heldY !== 0)) {
    this.facingHint = { x: heldX, y: heldY };
    return { x: 0, y: 0 };
  }
  if (heldX !== 0 || heldY !== 0) this.facingHint = { x: heldX, y: heldY };
  return { x: heldX, y: heldY };
}

/** Public getter for PlayerRenderer to bias stand-frame direction. */
getFacingHint(): { x: -1 | 0 | 1; y: -1 | 0 | 1 } {
  return this.facingHint;
}
```

**4. Drop legacy Ctrl+R toggle code paths (D6.1-05):** grep `Ctrl` / `KeyR` / `vk_alt` in this file — none observed currently; assertion test that none exist (anti-port enforcement).

**Anti-pattern enforcement:** Do NOT add a wire-schema field for Shift-stand. CONTEXT D6.1-09 keeps the protocol unchanged. The `axes={0,0}` payload is server-indistinguishable from "fully idle" — that's intentional.

---

### `tools/asset-pipeline/src/bootstrap.ts` (MODIFY — alpha-key + background ingestion)

**Analog:** Self (current decode→sharp pipeline at lines 99-122).

**Concrete edits (D6.1-01..04, D6.1-20):**

**1. Insert alpha-key step between `decodeBmp` (line 111) and `sharp(rgba, ...)` (line 117):**

```ts
const { width, height, rgba } = decodeBmp(buf);
if (width !== meta.width || height !== meta.height) {
  throw new Error(/* existing */);
}

// D6.1-01/02: alpha-key transparent sprites — exact-RGB match against
// top-left pixel of frame 0. GameMaker canonical rule (BMPs are 8-bit
// paletted, no anti-alias fringe — exact match is correct).
// `meta.transparent` is the per-sprite flag from extracted meta.json.
if (meta.transparent) {
  const sentinelR = rgba[0]!;
  const sentinelG = rgba[1]!;
  const sentinelB = rgba[2]!;
  for (let i = 0; i < rgba.length; i += 4) {
    if (rgba[i] === sentinelR && rgba[i + 1] === sentinelG && rgba[i + 2] === sentinelB) {
      rgba[i + 3] = 0;   // alpha = 0
    }
  }
}

const png = await sharp(rgba, { raw: { width, height, channels: 4 } })
  .png(PNG_OPTS).toBuffer();
```

**2. Extend `ExtractedSpriteMeta` interface (lines 32-39) to declare `transparent`:**

```ts
interface ExtractedSpriteMeta {
  width: number;
  height: number;
  originX: number;
  originY: number;
  name: string;
  id: number;
  transparent?: boolean;   // D6.1-01 — gates alpha-key path
}
```

**3. Background ingestion (D6.1-20):** the bootstrap iterates sprite directories generically by `^\d{4}-` regex (line 41). To include `0064-BKA1` and `0024-TSide1`, the call site that passes `spriteIds` (Plan 06-14 hard-coded list) must add those two IDs. Find the call in `tools/asset-pipeline/cli.ts` or wherever the Plan 06-14 list lives. Both are `transparent: false` per their meta.json — the alpha-key step naturally skips them.

**4. Path separation (D6.1-01 two-path pipeline):** `bootstrap.ts` is the BMP-input path → ALPHA-KEY APPLIES. `build.ts` (Aseprite-PNG path) → trust embedded alpha, NO key step. Verify by reading `build.ts` and confirming it does not call any pixel-loop alpha logic.

**Regression test (D6.1-03):** `tools/asset-pipeline/test/alpha-key.test.ts` — for each transparent sprite, assert (a) output PNG has alpha channel (`info.channels === 4`), (b) zero pixels at the sentinel RGB with `alpha === 255`. Use `sharp(out).raw().toBuffer({ resolveWithObject: true })` to introspect.

---

### Test patterns (NEW files)

**E2E test analog (`apps/client/test/e2e/camera-follow.e2e.test.ts` lines 16-89):** the existing test is the exact template — login fixture, `waitForGameReady`, `__rebno` deterministic hook for state assertion (NOT canvas pixel sampling), keyboard.down/up + waitForTimeout, before/after delta assertion. Mirror this shape for:

- `cli-08-camera.e2e.test.ts` — verbatim copy with `RUN_SPEED_PX_PER_TICK` magnitude in the delta threshold (8 px → 20 px since speed jumped 3→5).
- `cli-08-tiles.e2e.test.ts` — assert `window.__rebno.loadedTextures` contains `'atlas-mvp'` AND probe the canvas for non-background-color pixels at known tile positions (or, cleaner: add a `__rebno.tileCount` field in `RoomRenderer.renderNew` and assert > 0).
- `cli-08-nameplate.e2e.test.ts` — adapt `sprite-state.e2e.test.ts` lines 48-59 (the `[data-nameplate]` DOM-mirror count + computed-style color assertion via `evaluate(el => getComputedStyle(el).color)`).
- `cli-08-anim.e2e.test.ts` — adapt `sprite-state.e2e.test.ts` lines 16-46 (sample `__rebno.localFrame` over 1 s, count distinct frame keys, assert ≥ 3 unique frames at RUN cadence).

**Unit test analog (`packages/game-logic/tests/step.test.ts` — existing):** the test directory is `tests/` (NOT `__test__/`), uses vitest defaults. New tests:

- `run-speed.test.ts` — `expect(RUN_SPEED_PX_PER_TICK).toBe(5)`.
- `navi-mask-bbox.test.ts` — `expect(NAVI_MASK).toEqual({left:9, top:40, right:26, bottom:46})`.
- `wall-slide.test.ts` — set up `WalkableGrid` with a corner; input diagonal axes; assert player slides along free axis when blocked.
- `walkable-edge.test.ts` — set up `WalkableGrid` 3×3 walkable region; place player at edge tile; assert step() does not allow movement past the boundary.

**Unit test analog (`tools/asset-pipeline/test/bootstrap.test.ts` — existing):** new test `test/alpha-key.test.ts` mirrors its setup pattern (tempdir + fixture bmp + run bootstrap + read output PNG + assert).

---

## Shared Patterns (cross-cutting)

### S-01: Tag every artifact with traceable-reqs

**Source:** `CLAUDE.md` §"Tagging contract"; seen on every Phase-6 file (e.g. `PlayerRenderer.ts:2`, `step.ts:1`, `bootstrap.ts:2`).

**Apply to:** Every new/modified TS file in Phase 06.1. Phase 06.1 closes existing REQ-IDs (no new IDs) — use these tags:

```ts
// [impl->REQ-AST-01]  → tools/asset-pipeline/src/bootstrap.ts edits, alpha-key test
// [impl->REQ-CLI-04]  → game-logic constants, step, sprite-state, input-dispatcher, depth-set
// [impl->REQ-CLI-05]  → Nameplate (CLI-05 = chat & in-world UI per ROADMAP)
// [impl->REQ-CLI-06]  → RoomRenderer D-40 fix (CLI-06 = room rendering)
// [impl->REQ-CLI-07]  → BackgroundRenderer, RoomCollision, GameScene camera (CLI-07 = asset & atlas integration)
// [impl->REQ-CLI-08]  → e2e tests (CLI-08 = hard milestone)
// [int->REQ-CLI-08]   → on all 4 new e2e test files
// [unit->REQ-CLI-04]  → run-speed, navi-mask-bbox, wall-slide, walkable-edge tests
// [unit->REQ-AST-01]  → alpha-key.test.ts
```

### S-02: Extract → cite → name (every constant carries a GML SOURCE comment)

**Source:** `packages/game-logic/src/constants.ts` lines 22-128 — every export has `SOURCE: extracted/client-5-8/...:N` in its JSDoc. CLAUDE.md hard rule #6.

**Apply to:** New constants `RUN_SPEED_PX_PER_TICK`, `FRAMES_PER_TICK_AT_RUN`, `NAVI_MASK`, and any inline `PLAYER_LAYER`/`TILE_LAYER`/`TSIDE_LAYER` magic in renderers. Without the SOURCE comment the value is unverifiable on review — block at PR.

### S-03: Sim-tick callsite contract (D-31 lock — re-enforce)

**Source:** `apps/client/src/render/PlayerRenderer.ts` lines 18-22 (file header) + GameScene.ts lines 634-665 (sim-tick accumulator block).

**Apply to:** EVERY animation/depth/render-position update added in Phase 06.1.

- `BackgroundRenderer.onSimulationTick()` fires from the sim-tick block, NOT the render-frame block.
- `setDepth` for player fires from `onSimulationTickLocal/Remote`, not from a separate render loop.
- `BackgroundRenderer.tickRender(camera)` for per-tile position update is allowed at render-frame rate (60 Hz) — it's pure positional, not state-advancing. The visual scroll is governed by `dxoff/dyoff` which advance ONLY in sim-tick.

### S-04: Lint-purity-respecting pure module (packages/game-logic)

**Source:** `tools/scripts/lint-game-logic-purity.mjs` regex denylist (lines 14-25). Forbidden: `Date.*`, `new Date(`, `Math.random`, `process.*`, `fs.*`, `fetch(`, `import 'http'`, `import 'node:fs'`, `import 'node:os'`.

**Apply to:** New code in `packages/game-logic/src/constants.ts`, `step.ts`, `types.ts`, `collision.ts`. Specifically: `Uint8Array` is allowed (web-standard); `Math.floor/round/sign/abs/hypot/SQRT2` are allowed; `Math.random` is FORBIDDEN. Run `pnpm trace:check` AND `node tools/scripts/lint-game-logic-purity.mjs` before commit.

### S-05: Frame-key naming `${spriteId}_${padStart(3,'0')}`

**Source:** `apps/client/src/render/RoomRenderer.ts:117`, `apps/client/src/render/SpriteStateMachine.ts:197`, `apps/client/src/render/PlayerRenderer.ts:271`.

**Apply to:** `BackgroundRenderer` (`0064-BKA1_${pad3(floor(bgframe))}`), TSide1 placement (`0024-TSide1_000`). One convention, no variants.

### S-06: Optional-chained Phaser API for Rectangle fallback (Pitfall 6)

**Source:** `apps/client/src/render/PlayerRenderer.ts:138, 181`.

**Apply to:** Every `setFrame` / `setDepth` / `setTexture` call added in Phase 06.1. The atlas-missing fallback returns a Phaser Rectangle (jsdom unit tests; pre-atlas-load on real client) which lacks these methods. Use `sprite.setDepth?.(...)`, `sprite.setFrame?.(...)`.

### S-07: Hot-swap dispose-before-build (RoomRenderer pattern)

**Source:** `apps/client/src/render/RoomRenderer.ts:88-97` (`render(layout)` calls `this.dispose()` first) + `:264-274` (dispose body).

**Apply to:** `BackgroundRenderer` — provide `dispose()` and call from `GameScene.dispose()` (lines 687-720 — existing teardown). Tests that recreate the scene depend on this.

### S-08: `__rebno` deterministic test hook (D-35)

**Source:** `apps/client/src/scenes/GameScene.ts:670-684` + `apps/client/src/render/PlayerRenderer.ts:146-157`.

**Apply to:** Any Phase 06.1 e2e assertion that needs to read in-Phaser state. Add fields to `window.__rebno` from the relevant renderer:

```ts
// In BackgroundRenderer (gate to DEV / test mode like PlayerRenderer):
if (import.meta.env.DEV || import.meta.env.MODE === 'test') {
  (globalThis as any).__rebno = {
    ...((globalThis as any).__rebno ?? {}),
    bgframe: this.bgframe,
    bgDxoff: this.dxoff,
  };
}
// In RoomRenderer.renderNew after tile loop:
//   (globalThis as any).__rebno.tileCount = this.group.getLength();
```

Production bundle strips these via Vite tree-shaking on `import.meta.env.DEV` constant-folding.

### S-09: Playwright fixtures + `loginAs` + `waitForGameReady`

**Source:** `apps/client/test/e2e/fixtures.ts` (exports `loginAs`, `waitForGameReady`, `accountA`, `accountB`, `inviteSuffix`).

**Apply to:** All 4 new e2e files. NEVER reimplement login. Pattern:

```ts
import { test, expect, loginAs, waitForGameReady } from './fixtures.js';

test('Phase 06.1 ...', async ({ page, accountA, inviteSuffix }) => {
  await loginAs(page, accountA, inviteSuffix);
  await waitForGameReady(page);
  // ... assertions using window.__rebno ...
});
```

### S-10: Atomic regen of atlas + manifest in one plan (D6.1-04)

**Source:** `06-PATTERNS.md` §"Tool ↔ workspace bridge" + STATE.md UAT 2026-05-11 manual-deploy note.

**Apply to:** Plan that touches `bootstrap.ts` MUST also commit (in the same plan):

- `assets/source/sprites/0064-BKA1.{png,json}` (NEW)
- `assets/source/sprites/0024-TSide1.{png,json}` (NEW)
- `assets/source/sprites/<all transparent sprites that change with alpha-key>.png` (REGEN)
- `apps/server/public/atlas-mvp.{png,json}` (REGEN)
- `apps/server/public/pipeline-manifest.json` (REGEN)

Re-run `pnpm asset-pipeline bootstrap` + `pnpm asset-pipeline build` from the plan body. CI auto-rebuilds the client bundle on merge.

---

## No Analog Found

None. Every Phase 06.1 file has a strong in-repo analog from Phase 6 (where the renderer/scene/asset-pipeline scaffolding was built). The closest-to-novel item is `BackgroundRenderer.ts` but the sprite-group + atlas-key + sim-tick-advance pattern is fully covered by `RoomRenderer` + `PlayerRenderer` analogs.

The font asset `FixedsysExcelsior.woff2` is a vendored binary (CC0 public domain) — no code analog needed; the `index.html` `<style>@font-face` block is a standard CSS pattern.

---

## Metadata

- **Analog search scope:** `apps/client/src/render/`, `apps/client/src/scenes/`, `apps/client/src/prediction/`, `apps/client/test/e2e/`, `packages/game-logic/src/`, `packages/game-logic/tests/`, `tools/asset-pipeline/src/`, `tools/asset-pipeline/test/`, `tools/scripts/`
- **Files inspected (full or targeted Read):** 17 source + 3 test + 1 lint + 3 planning docs
- **Pattern extraction date:** 2026-05-11
- **Pre-existing patterns reused verbatim:** sim-tick callsite (D-31), `__rebno` test hook (D-35), Playwright fixtures, optional-chained Phaser API, hot-swap dispose-then-build, frame-key naming, SOURCE-comment-per-constant, traceable-reqs tags.
- **Patterns newly introduced in Phase 06.1:** depth_set sign-flip formula (inline in each renderer per D6.1-24), per-axis sub-pixel collision loop in `step()`, walkable-grid Uint8Array carried in `WorldState.room_layout`, two-path pipeline (bootstrap = alpha-key; build = trust-alpha), `isLocal` flag on Nameplate constructor, Shift-stand axes-zeroing with local `facingHint`.

---

## PATTERN MAPPING COMPLETE
