# Phase 06.1: gap-closure D-39..D-46 (UAT 2026-05-11) - Research

**Researched:** 2026-05-11
**Domain:** Phaser 3 client rendering correctness + GameMaker 5.3a → modern port fidelity (animation/collision/depth/camera/background/asset alpha)
**Confidence:** HIGH

## Summary

Phase 06.1 is **not a new-stack research phase** — it is a **fidelity-and-correctness gap-closure** against an already-locked stack (Phaser 3.90.0 + Vite 8 + TypeScript 5.6.3 + Colyseus 0.17 + msgpackr + Better-Auth + sharp 0.34.5). The user's CONTEXT.md `<decisions>` block locks 30 implementation specifics (D6.1-01..D6.1-30) — research scope is therefore narrow: verify each locked decision against extracted GML ground-truth, surface Phaser API specifics that affect implementation (camera deadzone semantics, depth sign, setRoundPixels, setFrame guard), and document the only non-trivial unknown (D-40 root cause has three candidate causes and the planner must pick a verification order).

**Every load-bearing constant in CONTEXT.md is confirmed against extracted GML in this document** (RUN=5 in KeyPress-82.gml:5, image_speed = curspeed/10 in Other-7.gml:4, NaviMask bbox 9/40/26/46 in sprites/0034-NaviMask/meta.json, depth_set formula in scripts/0354-depth_set.gml, BNCentral views[0] hBorder=304/vBorder=224 → deadzone 32×32, bkdraw image_speed=0.25 in 0051-bkdraw/Create.gml:2, bkdraw scroll math in Step.gml + dxspeed=0.25/dyspeed=-1 in BNCentral instances.json:3, BKA1 = 32×32, 55-frame, transparent:false, TSide1 = 44×4, transparent:false, tileborder() body in scripts/0085-tileborder.gml, local nametag c_aqua + fixedsys in Draw.gml:2-3, remote nametag c_white + fixedsys in 0042-player/Draw.gml:2-3, depth_set(0,43) for player in Step.gml:5, depth_set(2,0) for tile in 0020-borderedtile/Create.gml:2, depth_set(3,0) for tside1 in 0021-tside1/Create.gml:2). The locked decisions are not assumptions — they are extracted facts.

**Primary recommendation:** Execute the locked plan verbatim in 4 waves (asset-pipeline + game-logic in parallel → renderers in parallel → GameScene wire-up → e2e + UAT re-run). The only research-flagged item is D-40 root-cause investigation — start with the cheapest verification (atlas frame-key lookup), escalate to schema dual-union, then Ed25519 verify. Do NOT introduce new libraries; every fix is in existing files.

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|--------------|----------------|-----------|
| BMP→PNG alpha keying (D-39) | Build/Tooling (asset-pipeline) | — | Source assets transform offline; output is a deterministic atlas the client loads as bytes |
| Atlas + manifest serving (D-40 candidate) | Static (apps/server/public) | — | Already-locked: atlas PNG + JSON sidecars served as static files; client fetches blob via manifest |
| Atlas frame-key resolution (D-40 candidate) | Browser/Client (RoomRenderer) | — | Client looks up `${spriteId}_${NNN}` via Phaser's texture cache |
| Room-layout broadcast + signed manifest (D-40 candidate) | API/Backend (RoomRegistry) → Client | — | Server signs Ed25519, client verifies in `roomLayoutVerify.ts` |
| Movement intent → axes | Browser/Client (InputDispatcher) | — | Pure DOM keydown/keyup → `c2s.input` event; Shift modifier handled client-side only |
| Pure `step()` simulation (collision/run-speed) | Shared package (`@rebno/game-logic`) | runs in API/Backend + Browser/Client | Same code on both tiers — must change identically; NaviMask bbox + per-axis sub-pixel loop is per-entity data, not hardcoded |
| Sprite-state machine (animation frame per sim-tick) | Browser/Client (SpriteStateMachine + PlayerRenderer) | — | Per-tick frame index advance from sim-tick callback; not server-broadcast |
| Background scroll + animation (bkdraw) | Browser/Client (BackgroundRenderer, new) | — | Pure local visual effect; no server state |
| Depth sort (depth_set port) | Browser/Client (each renderer file inline) | — | Per-sprite z-order; no shared registry until Phase 7 |
| Nameplate text + shadow | Browser/Client (Nameplate via Phaser Text) | — | Local text rendering; reads PlayerState.name from Colyseus |
| Camera deadzone + follow + unbounded | Browser/Client (GameScene.applyCameraFollow) | — | Phaser `cameras.main` API; no server involvement |
| Walkable-region mask derivation | Browser/Client (RoomCollision, new) | — | Computed at room-load from tiles[]; mirrored into game-logic via `room_layout.collision_polys` for step() |

## Standard Stack

### Already Locked — NO Changes

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| phaser | 3.90.0 | Game engine, scenes, sprites, camera, text | ADR 0001 / Plan 02-06 locked [VERIFIED: apps/client/package.json] |
| @colyseus/sdk | 0.17.42 | Multiplayer client | Phase 4 locked [VERIFIED: apps/client/package.json] |
| @colyseus/schema | 4.0.23 | Wire schema | Phase 4 [VERIFIED: apps/client/package.json] |
| msgpackr | 1.11.10 | Room-layout decode | Phase 6 [VERIFIED: apps/client/package.json] |
| vite | 8.0.11 | Bundler | Plan 06-01 [VERIFIED: apps/client/package.json] |
| typescript | 5.6.3 | Strict-mode TS | Plan 01-01 [VERIFIED: apps/client/package.json] |
| @playwright/test | 1.59.1 | e2e | Plan 06-08 [VERIFIED: apps/client/package.json] |
| sharp | 0.34.5 | BMP/PNG/atlas pipeline | Plan 01-05 [VERIFIED: tools/asset-pipeline] |

### New for Phase 06.1

None. No new runtime dependencies are required. The fixedsys font is loaded as a static `.woff2` asset via CSS `@font-face` — no library.

### Font Asset

| Asset | Source | License | Path |
|-------|--------|---------|------|
| Fixedsys Excelsior 3.01 | Public-domain recreation by Darien Valentine | CC0 / public domain [CITED: https://github.com/kika/fixedsys; cufonfonts.com] | `apps/client/public/assets/fonts/FixedsysExcelsior.woff2` |

**Versioning note:** The font is intended to be rendered at 16 px / 12 pt @ 96 dpi with antialiasing off, per the font designer's documentation. Phase 6.1 renders it through Phaser's `Phaser.GameObjects.Text` — confirm pixel crispness at the target render scale during e2e. Fallback `monospace` if WOFF2 fails to load.

### Alternatives NOT chosen

| Instead of | Could use | Why locked stack wins |
|------------|-----------|----------------------|
| Phaser Text for nameplate | DOM overlay div | Mixed coord systems — Nameplate already uses Phaser canvas text + hidden DOM mirror (existing). Locked. |
| Custom WebGL shader for alpha-key | sharp pixel-replace in bootstrap | BMPs are 8-bit paletted — exact-RGB replace is correct and deterministic; CONTEXT D6.1-02 locks this. |
| TilemapLayer for floor tiles | Per-tile sprites | Atlas frame is at known x,y in tiles[] — direct sprite is simpler than tilemap; existing RoomRenderer already does this. |
| Phaser Animation API for bkdraw | Manual frame index per tick | bkdraw scrolls AND animates; manual sim-tick advance keeps it consistent with the rest of the codebase (D-31 lock). |

## Architecture Patterns

### System Architecture Diagram

```
                       ┌────────────────────────────┐
                       │  extracted/client-5-8/     │
                       │  (BMP frames + meta.json)  │
                       └────────────┬───────────────┘
                                    │ bootstrap (sharp + bmp-decoder)
                                    │ alpha-key path D6.1-01
                                    ▼
                       ┌────────────────────────────┐
                       │  assets/source/sprites/    │
                       │  <id>.{png,json}           │
                       └────────────┬───────────────┘
                                    │ build (maxrects-packer)
                                    ▼
                       ┌────────────────────────────┐
                       │  apps/server/public/       │
                       │  atlas-mvp.{png,json}      │
                       │  pipeline-manifest.json    │
                       └────────────┬───────────────┘
                                    │ static fetch via Vite manifest
                                    ▼
   ╔════════════════════════════════════════════════════════════════════════╗
   ║                          Browser (Phaser 3.90)                         ║
   ║                                                                        ║
   ║   ┌──────────────┐    ┌──────────────────┐    ┌────────────────────┐   ║
   ║   │ atlas-loader │───▶│ scene.textures   │───▶│ RoomRenderer       │   ║
   ║   └──────────────┘    └──────────────────┘    │ (D-40 fix)         │   ║
   ║                                                │ - floor tiles      │   ║
   ║                                                │ - TSide1 sides     │   ║
   ║                                                │ - walkable-region  │   ║
   ║                                                │   mask derivation  │   ║
   ║                                                └────────┬───────────┘   ║
   ║                                                         │               ║
   ║   ┌─────────────────────────┐   ┌───────────────────┐   │               ║
   ║   │ Colyseus state (server) │──▶│ GameScene         │◀──┘               ║
   ║   │ s2c.room_layout         │   │ - applyCameraFollow                   ║
   ║   │ PlayerState.{x,y,axes}  │   │   (D-46 deferred)                     ║
   ║   └─────────────────────────┘   │ - sim-tick accumulator                ║
   ║                                 │ - currentLayoutForSim()               ║
   ║                                 └──────┬────────────────────────────┐   ║
   ║                                        │                            │   ║
   ║   ┌──────────────────┐    ┌────────────▼──────────┐    ┌────────────▼───────┐
   ║   │ InputDispatcher  │    │ PredictionEngine      │    │ PlayerRenderer    │
   ║   │ - WASD + Shift   │───▶│ - calls step()        │───▶│ - onSimTickLocal  │
   ║   │   stand semantics│    │ - reconciles snapshot │    │ - onSimTickRemote │
   ║   │ - room.send      │    │                       │    │ - setDepth        │
   ║   └──────────────────┘    └───────┬───────────────┘    │ - setFrame        │
   ║                                   │                    └──────┬────────────┘
   ║                                   ▼                           │
   ║                          ┌──────────────────┐                 │
   ║                          │ @rebno/game-logic│                 ▼
   ║                          │ step()           │       ┌──────────────────────┐
   ║                          │ - RUN_SPEED=5    │       │ SpriteStateMachine   │
   ║                          │ - per-axis loop  │       │ - deriveFrame        │
   ║                          │ - NaviMask bbox  │       │   (curspeed/10 rate) │
   ║                          │ - walkable-mask  │       └──────────────────────┘
   ║                          └──────────────────┘
   ║
   ║   ┌────────────────────────┐    ┌────────────────────┐    ┌──────────────┐
   ║   │ BackgroundRenderer NEW │    │ Nameplate          │    │ ChatHUD      │
   ║   │ - tiles BKA1 viewport  │    │ - Fixedsys WOFF2   │    │ (unchanged)  │
   ║   │ - dxspeed/dyspeed wrap │    │ - cyan local       │    └──────────────┘
   ║   │ - bgframe += 0.25/tick │    │ - white remote     │
   ║   │ - 55-frame BKA1        │    │ - 1px black shadow │
   ║   └────────────────────────┘    └────────────────────┘
   ╚════════════════════════════════════════════════════════════════════════╝
```

The Colyseus server is unchanged for Phase 06.1 — every fix is browser-side OR pipeline-side. The `step()` function is shared and must change identically on server and client (NaviMask bbox + walkable-region must compile and run in both Node and the browser bundle).

### Recommended Touch Set (no structural changes)

```
apps/client/
├── src/
│   ├── render/
│   │   ├── PlayerRenderer.ts          [MODIFY: depth-set per sim-tick, curspeed/10 anim]
│   │   ├── SpriteStateMachine.ts      [MODIFY: TICKS_PER_FRAME_ADVANCE → curspeed/10]
│   │   ├── RoomRenderer.ts            [MODIFY: D-40 fix, per-tile depth, TSide1 placement, walkable-region derivation]
│   │   ├── Nameplate.ts               [MODIFY: Fixedsys + cyan/white + 1px shadow + y-16 anchor]
│   │   ├── BackgroundRenderer.ts      [NEW]
│   │   └── RoomCollision.ts           [NEW — walkable-region mask helper]
│   ├── scenes/
│   │   └── GameScene.ts               [MODIFY: deferred startFollow, setRoundPixels(true), drop setBounds, sim-tick accumulator routes remote anim]
│   └── prediction/
│       └── input-dispatcher.ts        [MODIFY: Shift-stand semantics replacing legacy Alt-held]
├── public/
│   ├── assets/
│   │   ├── atlas-mvp.{png,json}       [REGEN — D-39 alpha keying]
│   │   └── fonts/
│   │       └── FixedsysExcelsior.woff2 [NEW]
│   └── pipeline-manifest.json         [REGEN]
└── index.html                          [MODIFY: @font-face declaration]

apps/server/public/                    [REGEN: atlas + manifest mirror — same bytes]

packages/game-logic/
├── src/
│   ├── constants.ts                   [MODIFY: WALK_SPEED → RUN_SPEED=5, add NAVI_MASK bbox]
│   └── step.ts                        [MODIFY: per-axis sub-pixel loop, NaviMask bbox, walkable-region check]

tools/asset-pipeline/
├── src/
│   ├── bootstrap.ts                   [MODIFY: alpha-key per meta.transparent; bg ingestion path D6.1-20]
│   └── build.ts                       [MODIFY: Aseprite-path trusts embedded alpha]
└── test/
    └── alpha-key.test.ts              [NEW — D6.1-03 regression guard]

extracted/client-5-8/sprites/0064-BKA1/  → ingested by bootstrap (55 frames added to atlas)
extracted/client-5-8/sprites/0024-TSide1/ → ingested by bootstrap
extracted/client-5-8/sprites/0034-NaviMask/ → metadata only (bbox values copied into game-logic constants)
```

### Pattern 1: Alpha-Key in `bootstrap.ts` (D-39)

**What:** Read `meta.json.transparent`; if `true`, sample top-left pixel of frame 0 as key color; replace exact-RGB matches with `alpha=0`. If `false`, emit fully opaque.

**When:** Every BMP→PNG decode in `bootstrap.ts`. Build path (`build.ts`) trusts Aseprite-embedded alpha; do NOT key in `build.ts`.

**Reference pattern (existing decodeBmp produces RGBA raw; the alpha-key step inserts before the sharp pipeline):**

```typescript
// tools/asset-pipeline/src/bootstrap.ts (modified)
// Source: CONTEXT D6.1-01..04
const { width, height, rgba } = decodeBmp(buf);
if (meta.transparent) {
  // Sample top-left of frame 0 as sentinel — GameMaker rule
  const r0 = rgba[0], g0 = rgba[1], b0 = rgba[2];
  for (let i = 0; i < rgba.length; i += 4) {
    if (rgba[i] === r0 && rgba[i+1] === g0 && rgba[i+2] === b0) {
      rgba[i+3] = 0; // alpha = 0; D6.1-02 exact match, no tolerance band
    }
  }
}
// then existing sharp pipeline — png(PNG_OPTS)
```

**Regression guard test (D6.1-03):** for each sprite with `meta.transparent === true`, decode the output PNG and assert (a) presence of alpha channel, (b) zero pixels with sentinel RGB at alpha=255.

### Pattern 2: Sim-Tick-Driven Animation (D-41/D-42)

**What:** `image_speed = curspeed/10` per tick → fractional frame advance.

**Citation:** `extracted/client-5-8/objects/0000-server/events/Other-7.gml:4`:

```gml
image_speed = global.curspeed/10;
```

**When:** Both local and remote sprites advance at the same rate, driven by the simulation tick (30 Hz), NOT by Phaser's render frame (60 Hz). D-31 was correctly enforced for local sprites by the existing accumulator in `GameScene.update()` (lines 634-665); the bug fixed in 06.1 is that **remote sprite advance must use the same sim-tick callsite contract** — verify the `onSimulationTickRemote` call site is gated by the same accumulator (it is, line 651-664) but `axis_x_held * 3` is the wrong magnitude — should be `* RUN_SPEED_PX_PER_TICK` (i.e. `* 5`) post-rename.

**Frame math:**
- BKA1 sprite (background): `image_speed = 0.25` → advance 0.25 frame index per sim-tick → 7.5 fps at 30 Hz over 55 frames.
- Navi sprites (player): `image_speed = global.curspeed/10` → at curspeed=5, advance = 0.5 per tick → 15 fps at 30 Hz over 6 frames = full cycle every 12 ticks (~400ms). At curspeed=0 (stand), advance = 0 → frame held.

**Implementation contract for `SpriteStateMachine.deriveFrame`:** Replace the boolean `TICKS_PER_FRAME_ADVANCE = 1` constant with a fractional `framesPerTick = curspeed / 10` parameter. Accumulator stores fractional phase as `cyclePhase + framesPerTick`; when it crosses an integer boundary, the frame index advances by `floor(cyclePhase)` modulo `RUN_FRAME_COUNT[facing]`. Stand reset to 0 unchanged.

### Pattern 3: Single Run Speed + Shift-Stand Input (D-44)

**What:** Rename `WALK_SPEED_PX_PER_TICK=3` → `RUN_SPEED_PX_PER_TICK=5`. Drop the Ctrl+R toggle entirely. Replace legacy `vk_alt` stand-in-place modifier with Shift held.

**Citation for RUN=5:** `extracted/client-5-8/objects/0000-server/events/KeyPress-82.gml:5-8`:

```gml
if(global.curspeed == 3) {
  global.curspeed = 5;       // ← RUN speed
  image_speed = 0.5;          // = 5/10
}
```

**Citation for Alt-stand → Shift-stand mapping (operator preference):**
- Legacy: `extracted/client-5-8/objects/0000-server/events/Keyboard-37.gml:15`: `if(!keyboard_check(vk_alt)) { fspeed = global.curspeed; sprite_index = NaviRunUL; } else { fspeed = 0; sprite_index = NaviStandUL; }`
- rebno: replace `vk_alt` semantics with `Shift` held — when Shift is down and a direction key is also held, dispatch `axes={x,y}` BUT also signal "stand pose" so the renderer picks STAND_FRAME (not RUN_SPRITE_ID). Two options for the wire shape: (a) extend `cInputSchema` with a `stand: boolean` flag (protocol change — server must echo for remote players); (b) keep the wire schema unchanged and zero the axes on Shift+direction client-side — server treats this as "no movement," remote players see them stand. Option (b) is simpler and avoids a protocol bump but means **the local player sprite cannot face-without-moving differently from "fully idle"** — they look identical. CONTEXT does not pre-decide between (a) and (b); the planner picks. **Recommendation: option (b)** — Shift+direction sends `axes={0,0}` but InputDispatcher updates a `facingHint` that local rendering uses to set the stand-frame direction. Wire shape unchanged.

**Existing InputDispatcher reference points:**
- `apps/client/src/prediction/input-dispatcher.ts:90` — `attachTo(targetEl)` adds keydown/keyup listeners. Add a Shift-key tracker (Set `shiftDown`).
- `currentAxes()` line 139: if `shiftDown` AND any direction key is held, return `{x:0, y:0}` but expose `facingHint` separately to the renderer.
- Remove any Ctrl+R toggle code paths if they exist (search the file — none observed in current file; this is anti-port enforcement).

### Pattern 4: Per-Axis Sub-Pixel Collision (D6.1-28)

**Citation:** `extracted/client-5-8/objects/0000-server/events/Step.gml:222-243` — verbatim:

```gml
//fSpeed movement
//x+9,y+39,x+26,y+47 (navi collision bounds w/1 pixel coushin)
move = round(lengthdir_x(fspeed,direction));
i = 0;
for(i = 0; i < abs(move); i += 1)
{
if(sign(move) == -1 && ((tbordered && (!abscheckheight2(tile1,mplatparent,x+9-1,y+39,8) || sprite_index == Hexport)) || !tbordered || jshell))
  x -= 1;
else if(sign(move) == 1 && ((tbordered && (!abscheckheight2(tile1,mplatparent,x+26+1,y+39,8) || sprite_index == Hexport)) || !tbordered || jshell))
  x += 1;
else break;
}
move = round(lengthdir_y(fspeed,direction));
// ... same shape for y-axis
```

**Modern port:** in `packages/game-logic/src/step.ts`, replace the current single-shot `resolveCollision(desired)` call with two pixel-loops:

```typescript
// Pseudocode — translate to actual step.ts shape
const moveX = Math.round(rawDx);
for (let i = 0; i < Math.abs(moveX); i += 1) {
  const probeX = player.x + Math.sign(moveX);
  if (isWalkable(probeX + NAVI_MASK.left, player.y + NAVI_MASK.top, probeX + NAVI_MASK.right, player.y + NAVI_MASK.bottom)) {
    player.x = probeX;
  } else break;
}
// then y-axis
```

`isWalkable` accepts the four feet-bbox corners and queries the walkable-region mask (D6.1-29). The mask is the union of floor-tile rectangles. **Wall-slide along diagonals at corners falls out naturally** because each axis is checked independently — the player can move in y while blocked in x.

### Pattern 5: Walkable-Region Mask Derivation (D6.1-29)

**At room-load**, scan `layout.tiles[]` (where each tile has `x`, `y`, and `tile_w=44, tile_h=40`). Compute one of:

| Representation | Storage | Lookup cost | Choice |
|----------------|---------|-------------|--------|
| Per-cell boolean grid `width_tiles × height_tiles` | small | O(1) | RECOMMENDED for MVP — easy to test |
| Array of AABB rectangles (one per tile or merged) | small | O(n) per probe | Use only if merged into a few rectangles |
| Phaser physics body | large | engine-handled | NOT recommended — couples to Phaser, breaks game-logic purity |

**Recommendation:** Use the boolean grid. The probe is `mask[floor(x / tile_w)][floor(y / tile_h)]`. For a 4-corner bbox probe, query 4 cells (or 1-2 when bbox spans grid lines). This stays in pure `@rebno/game-logic` — pass the grid via `room_layout.walkable_grid` (extend the type).

**Legacy `tileborder()` replaced:**
- Citation: `extracted/client-5-8/scripts/0085-tileborder.gml:1-7`:
  ```gml
  if((!tbottomcheck(tile1) || tbottomcheck(hiddentile) || tbottomcheck(magictile)) && argument0 != -1)
  {
  retval = instance_create(x,y+40,argument0);  // spawn TSide1 below
  }
  // top/left/right border spawn is commented out in 5-8 (lines 9-25)
  ```
- rebno replacement: at room-load, **for each floor tile** whose bottom-neighbor cell is empty, place a `0024-TSide1` sprite at `(tile.x, tile.y + 40)`. This is a derived render-step in `RoomRenderer.render(layout)`, not an entity-spawn.

### Pattern 6: Depth Sort Port (`depth_set`)

**Citation:** `extracted/client-5-8/scripts/0354-depth_set.gml`:

```gml
argument2 *= -1;
depth = round(((10000*argument2)+(1000*argument0)) - (y+argument1));
```

For single-floor MVP (drop floor index, treat as 0):
```typescript
const gmDepth = Math.round(1000 * layer - (y + yOffset));
// GameMaker: LOWER depth value = drawn ON TOP
// Phaser:    HIGHER depth value = drawn ON TOP
// Sign-flip:
sprite.setDepth(-gmDepth);
```

**Citations for layer/yOffset values:**
- Player: `0000-server/events/Step.gml:5` → `depth_set(0, 43)` → `layer=0, yOffset=43`
- BorderedTile: `0020-borderedtile/events/Create.gml:2` → `depth_set(2, 0)` → `layer=2, yOffset=0`
- TSide1: `0021-tside1/events/Create.gml:2` → `depth_set(3, 0)` → `layer=3, yOffset=0`

**Player depth refreshed per sim-tick** (because y changes). Tile and TSide1 depth set at create — they don't move. CONTEXT D6.1-24: values inline in each renderer for MVP; consolidate to `depth-registry.ts` in Phase 7.

**Phaser depth math sign — discretion item (CONTEXT line 109):** confirm the sign at implementation. The above is the standard interpretation; sanity-check by placing player at `y=200` and a tile at `y=300` — player should draw above the further-down tile. If they don't, flip the sign in the formula.

### Pattern 7: Camera Deadzone + Unbounded + RoundPixels (D-46)

**Citation:** `extracted/client-5-8/rooms/0058-BNCentral/meta.json` views[0]:

```json
{ "viewW": 640, "viewH": 480, "hBorder": 304, "vBorder": 224, "hSpeed": -1, "vSpeed": -1 }
```

Deadzone = `viewW - 2 * hBorder = 640 - 608 = 32`; `viewH - 2 * vBorder = 480 - 448 = 32`. Instant follow (`hSpeed = -1`).

**Implementation:**

```typescript
// apps/client/src/scenes/GameScene.ts — applyCameraFollow (modified)
private applyCameraFollow(): void {
  if (this.cameraFollowApplied) return;
  const localSprite = this.playerRenderer?.getLocalSprite();
  if (!localSprite) return;
  // D6.1-16: DROP setBounds — camera unbounded
  // this.cameras.main.setBounds(...);  ← REMOVE
  this.cameras.main.startFollow(localSprite, true);   // roundPixels=true
  this.cameras.main.setDeadzone(32, 32);              // D6.1-14
  this.cameras.main.setRoundPixels(true);             // D6.1-16 HiDPI
  this.cameraFollowApplied = true;
}
```

**Root-cause fix (D6.1-15):** the current code (GameScene.ts:564-581) calls `applyCameraFollow` from `onLocalJoin` AND from `onRoomLayout` — BUT the local sprite isn't created until `PlayerRenderer.ensureLocal(...)` runs inside `onLocalJoin`. The fix is to defer until **after** `ensureLocal()` returns and the sprite exists. Already structured correctly in the existing code path; the bug is that the cookie-resume self-heal (from 06-15) tears down and recreates the sprite without re-running `applyCameraFollow`. **Fix:** make `applyCameraFollow` idempotent on `localSprite` reference change — reset `cameraFollowApplied = false` when `ensureLocal` creates a new sprite.

### Pattern 8: Background Renderer (port `0051-bkdraw`)

**Citation:** `extracted/client-5-8/objects/0051-bkdraw/events/`:

- `Create.gml`: `image_speed = 0.25; dscale = 1;`
- `Step.gml`: `dxoff += dxspeed` (wrap on overflow), same for dyoff. `bgframe += bgspeed` (`bgspeed = image_speed = 0.25`); wrap when `bgframe > bgframes`.
- `Draw.gml`: tile-across-viewport pattern, double loop covering `view_left - sprite_w` to `view_left + 640 + sprite_w`, by `sprite_height`.
- BNCentral instance creation code: `dyspeed = -1; dxspeed = 0.25;` (citation: `extracted/client-5-8/rooms/0058-BNCentral/instances.json:3`)

**Modern Phaser implementation strategy — discretion choice (CONTEXT line 111-112):**

| Approach | Pros | Cons | Recommendation |
|----------|------|------|----------------|
| **(a) Per-tile Phaser sprites in a Group, depth = -1000** | Simple; each tile is a real sprite | Many sprite objects | RECOMMENDED for MVP |
| (b) Phaser `TileSprite` with `setTilePosition` | One object, builtin tiling | TileSprite uses single frame — animation requires manual frame swap, scroll math is the same | viable, slightly more elegant |
| (c) Custom WebGL via Pipeline | Best perf | over-engineered for 1 background | reject |

**Pick (a)** — match the GML draw loop literally. Each render frame:
1. `bgframe += 0.25` (sim-tick driven, not render-frame).
2. `dxoff += 0.25`; wrap when `|dxoff| >= sprite_w * dscale` (32 px).
3. `dyoff += -1`; wrap when `|dyoff| >= sprite_h * dscale` (32 px).
4. For each tile position covering the viewport plus one tile of bleed each side:
   ```
   sprite.setPosition(view_left - sprite_w + dx + dxoff, view_top - sprite_h + dy + dyoff);
   sprite.setFrame(`0064-BKA1_${pad3(floor(bgframe))}`);
   ```

**Number of tiles:** `(viewW / 32 + 2) × (viewH / 32 + 2)` = `22 × 17 = 374 sprites`. This is acceptable for Phaser at 60fps. If perf surfaces a problem, switch to `(b)`.

**`scrollFactor` consideration:** Background must NOT scroll with the camera at world rate — it scrolls at the bkdraw rate. Set each background tile's `scrollFactor` to 0 and position relative to viewport (using `cameras.main.scrollX/scrollY`); OR keep `scrollFactor=1` and position absolute in world space. The legacy draw-event uses `view_left` (camera scroll), so the modern equivalent is `scrollFactor(0)` + manual position via `cameras.main.scrollX + offset`.

### Pattern 9: Nametag (D-45)

**Citations:**
- Local: `extracted/client-5-8/objects/0000-server/events/Draw.gml:2-3`: `font_color = c_aqua; font_name = "fixedsys";`
- Local position: line 12: `draw_text(x+(sprite_width/2)-(string_width(global.playername)/2), y-16, global.playername);`
- Remote: `extracted/client-5-8/objects/0042-player/events/Draw.gml:2-3`: `font_color = c_white; font_name = "fixedsys";`
- Remote position: line 17: same `x+(sprite_width/2)-(string_width(name)/2), y-16`.

**Implementation:**

```typescript
// apps/client/src/render/Nameplate.ts — modified per D6.1-10..13
this.text = scene.add.text(x, y, username, {
  fontFamily: '"Fixedsys Excelsior", monospace',  // D6.1-10
  fontSize: '16px',                                // D6.1-12 — Fixedsys renders at 16px
  color: isLocal ? '#00FFFF' : '#FFFFFF',          // D6.1-11 — c_aqua / c_white (0x00FFFF / 0xFFFFFF)
  // D6.1-12 — 1px solid-black drop shadow at (1, 1)
  shadow: { offsetX: 1, offsetY: 1, color: '#000000', blur: 0, fill: true },
}).setOrigin(0.5, 1);   // anchor bottom-center per D6.1-13

// Position math (D6.1-13): text_x = sprite.x + sprite_w/2 - string_w/2 = sprite_origin + 0; text_y = sprite.y - 16
// Sprite has origin (0.5, 1), so sprite.x IS center-x; nameplate text origin (0.5, 1) anchors text bottom-center;
// place at (sprite.x, sprite.y - sprite.height - 16).
```

The Nameplate component already has a hidden DOM mirror (`data-nameplate`) for Playwright; keep it. **Add an `isLocal` flag** to the constructor — currently the color is fixed at `#22D3EE`; need conditional cyan vs white.

`index.html` must add:
```html
<style>
@font-face {
  font-family: 'Fixedsys Excelsior';
  src: url('/assets/fonts/FixedsysExcelsior.woff2') format('woff2');
  font-display: block;
}
</style>
```

(Phaser canvas-text consults the document's loaded fonts.)

### Anti-Patterns to Avoid

- **Render-frame-driven animation.** D-31 explicitly says sim-tick. Re-confirmed.
- **`setBounds()` on camera.** CONTEXT D6.1-16 explicitly drops it. World should feel infinite.
- **Reactivating top/left/right TSide borders.** They are commented out in 5-8 GML (`scripts/0085-tileborder.gml` lines 9-25 — fully commented `/* ... */`). Do not port.
- **Tolerance-band alpha keying.** BMPs are 8-bit paletted; tolerance breaks anti-alias-free sprites. CONTEXT D6.1-02 locks exact RGB match.
- **Hand-rolling per-tile object spawning for borders.** Walkable-region mask supersedes it (D6.1-29).
- **Color-coupling the renderer to `#22D3EE` for nameplates.** That's the UI accent token; nameplate is c_aqua (`#00FFFF`) per GML.
- **Floor index in depth formula.** Single-floor MVP — `floor = 0` always; drop it.
- **Spawning a TSide1 sprite per Phaser object for every tile in the room.** The drawn TSide1 doesn't need to be a separate Group entry — could be a single batched draw. For MVP, plain sprites are fine; consider a TileSprite or batched if perf surfaces.

## Don't Hand-Roll

| Problem | Don't build | Use instead | Why |
|---------|-------------|-------------|-----|
| Camera follow with deadzone | Manual scroll arithmetic | `cameras.main.startFollow + setDeadzone` | Phaser 3.90 native, handles HiDPI roundPixels correctly. |
| Per-axis pixel-stepping in Phaser physics | Custom Arcade body | `step()` pure function in `@rebno/game-logic` | Already pure; lint-enforced no-I/O; runs identically on server. |
| Font loading | Manual canvas drawing of characters | CSS `@font-face` + Phaser Text | Phaser canvas-text picks up document-loaded fonts. |
| BMP decode | Custom decoder | existing `bmp-decoder.ts` | Plan 06-14 deliverable; sharp's prebuilt libvips lacks BMP support so the project ships its own. |
| PNG alpha output | Manual chunk writer | `sharp@0.34.5` with PNG_OPTS | Already deterministic in `bootstrap.ts`. |
| Atlas pack | Hand-built | maxrects-packer (already in `build.ts`) | Plan 06-02 already running. |
| Ed25519 verify | Manual curve math | `@noble/ed25519` (already in `roomLayoutVerify.ts`) | Pinned in plan 04-12. |
| Walkable-region mask | Geometric union of polygons | Boolean tile grid | Tiles are on a regular 44×40 grid — a 2D boolean grid is O(1) probe and trivially correct. |
| Background scrolling | TileSprite or custom shader | Per-tile Phaser sprite group with manual position math | Matches the GML draw event exactly; small enough to be a regular sprite group. |

**Key insight:** Phase 06.1 is almost entirely a port of well-trodden GameMaker 5.3a idioms (depth_set, image_speed, lengthdir_x+round+pixel-loop, draw_text, instance_create-for-tile-side). Each idiom has a direct Phaser/TS equivalent. No new abstractions are needed; the existing renderer files just need to be made faithful to the GML.

## Runtime State Inventory

Phase 06.1 is largely a code-edit phase, but several artifacts need explicit regen / commit:

| Category | Items Found | Action Required |
|----------|-------------|-----------------|
| Stored data | None — no schema changes, no DB migrations, no Colyseus state shape change. Phase 06.1 wire-format is unchanged. | None — verified by reading CONTEXT (no protocol bump mentioned) and confirming no `cInputSchema`/`PlayerState` changes are listed. |
| Live service config | Staging Fly.io service: no env-var changes. `VITE_ROOM_SIGNING_PUBKEY` stays at the existing staging value (`VBSr0aj+rbf2vVkXax6vCvWACSv+dmsKdy0g0DsSfQM=` per STATE.md line 38). | None. |
| OS-registered state | None — no scheduled tasks, no service registrations. | None. |
| Secrets/env vars | Better-Auth session secret unchanged. `VITE_*` env keys unchanged. | None. |
| Build artifacts | (a) `apps/server/public/atlas-mvp.png` — must regen with alpha; (b) `apps/server/public/atlas-mvp.json` — must regen to include 0024-TSide1 + 0064-BKA1 (55 frames) + NaviMask metadata; (c) `apps/server/public/pipeline-manifest.json` — same; (d) any cached client bundle in `apps/server/public/assets/` from prior Vite build — regenerate via `pnpm --filter @rebno/client build:staging`; (e) any committed `assets/source/sprites/*.{png,json}` for the newly-ingested sprites. | All committed by the fixing plan(s) per CONTEXT D6.1-04. CI workflow `deploy-staging.yml` rebuilds client bundle automatically; manual deploy ritual documented in 06-HUMAN-UAT re-run 2026-05-11 must NOT be repeated (CI handles it). |

**Canonical question:** *After every file in the repo is updated, what runtime systems still have the old string cached, stored, or registered?* — **Answer:** the deployed staging Fly machine has the OLD atlas/manifest in its image. The CI pipeline rebuilds the Docker image on every push to `main`; once Phase 06.1 plans merge, the auto-deploy republishes the new atlas. **No manual atlas copy required** (UAT 2026-05-11 ops note about manual copy was a one-time `fly deploy` skip; merge to `main` via PR is the correct path).

## Common Pitfalls

### Pitfall 1: Atlas frame-key lookup miss (D-40 candidate cause)
**What goes wrong:** Tiles in `layout.tiles[]` reference `tileset_sprite_id = "0023-Tile1"`. RoomRenderer builds frame name `0023-Tile1_000`. If the atlas was regenerated without that key, OR if the frame_count for `0023-Tile1` in `pipeline-manifest.json` doesn't match what was ingested, the `this.scene.textures.get(this.atlasKey).has(frameName)` check returns false and the sprite is skipped silently.
**Why it happens:** The bootstrap step uses lex-sorted dirs + frames; if a sprite was added to the hard-coded `spriteIds` list in bootstrap call site but not actually present in `extracted/`, or vice versa, the manifest and atlas drift.
**How to avoid:** D-40 verification step 1 — log every `has(frameName) === false` skip during RoomRenderer.renderNew. Cross-reference with `pipeline-manifest.json` to find the offending sprite_id.
**Warning signs:** Player sprite renders (NaviStandD_000 lookup succeeds) but tiles don't (different lookup, same code path).

### Pitfall 2: Ed25519 verify failure due to msgpack re-stringification (D-40 candidate cause)
**What goes wrong:** Server signs `room_id || rev || sha256(json)` over the canonical JSON it wrote to disk. Client receives `layout_bytes` (msgpack), decodes to JS object, re-stringifies via `JSON.stringify(layout)` and verifies — but JS `JSON.stringify` key order is insertion order, which may not match disk order. Signature verify returns false; `onRoomLayout` logs warn and returns (line 530-538 in GameScene.ts), leaving the room unrendered.
**Why it happens:** Existing 06-07-SUMMARY.md acknowledges this is an "approximate verify."
**How to avoid:** D-40 verification step 2 — check whether `verifyRoomLayout` logs a verify-failure warning for `mvp-room`. If yes, that's the cause. Two fixes: (a) protocol change to ship raw JSON (defers Phase 7); (b) make the client side bail-open (already does — but maybe the bail-open path is wrong). Actually re-reading GameScene.ts:529-537 — bail returns early WITHOUT rendering. **That IS the bug.** Either change to "log and render anyway" OR fix the canonical stringification.
**Warning signs:** Console warn `roomLayoutVerify error` or `room_layout signature did not verify`.

### Pitfall 3: Schema dual-union mismatch (D-40 candidate cause)
**What goes wrong:** `RoomRenderer.render(layout)` discriminates legacy vs new shape via `'room_id' in layout`. If the server sends `mvp-room` in new shape but `unpack()` produces an object missing `room_id` (msgpackr decoded-as-array, or schema-mismatched packed form), it routes to `renderLegacy` which expects `tile_grid` and finds nothing.
**Why it happens:** Plan 04-12b shipped the room signer but the wire encoding may have drifted.
**How to avoid:** D-40 verification step 3 — log `Object.keys(layout)` immediately after `unpack(evt.layout_bytes)` in onRoomLayout. If `room_id` is missing or nested, that's the cause.
**Warning signs:** RoomRenderer.layout property is set but no tile sprites visible AND no `has(frameName) === false` skips logged.

**Verification order (cheapest first, per CONTEXT line 106):**
1. Frame-key lookup miss (add console log to renderNew). 5 minutes.
2. Schema discriminant check (log unpacked keys in onRoomLayout). 5 minutes.
3. Ed25519 verify failure (check warn log in roomLayoutVerify). 5 minutes — if (1) and (2) don't surface the issue, this is the cause.

### Pitfall 4: Sim-tick accumulator reset on lag spike
**What goes wrong:** Current `GameScene.update()` caps `simTickAccumulator` to fire AT MOST one sim tick per render frame. On a long lag spike (>1 second tab-blur), the accumulator could legitimately need to fire multiple ticks to catch up; capping to 1 means animation falls behind reality.
**Why it happens:** Existing code line 638-641 sets accumulator to 0 after firing instead of subtracting SIM_TICK_MS.
**How to avoid:** Change `this.simTickAccumulator = 0` to `this.simTickAccumulator -= SIM_TICK_MS`; cap MAX accumulated ticks at e.g. 5 to prevent spiral-of-death. **This is a latent bug exposed by D-43 (choppy movement) — fix while in the area.**
**Warning signs:** Movement feels "smooth then jumpy" after returning from a backgrounded tab.

### Pitfall 5: Cookie-resume self-heal recreates sprite but not camera-follow target
**What goes wrong:** When GameScene re-enters via cookie resume, `recoverSessionTokenAndConnect` runs; `ensureLocal` may create a new sprite; but `cameraFollowApplied` stays `true` so `applyCameraFollow` no-ops; the camera follows the destroyed old sprite reference.
**Why it happens:** Existing flag is set once; sprite recreation doesn't reset it.
**How to avoid:** In `PlayerRenderer.ensureLocal`, if `this.local` already exists with a DIFFERENT sprite reference, OR when a new sprite is created, signal up to GameScene to reset `cameraFollowApplied = false`. Alternative: have applyCameraFollow check `cameras.main._follow !== localSprite` and re-apply.
**Warning signs:** D-46 reproduces after a cookie auto-login but not after a fresh login.

### Pitfall 6: `setFrame` called on Phaser Rectangle fallback
**What goes wrong:** When the atlas hasn't loaded, `PlayerRenderer.makeSprite` returns a Rectangle cast as Sprite. Rectangle has no `setFrame`. Existing code guards with `?.()` (line 138, 181). **Keep the guard** — do not regress.
**How to avoid:** Verify the optional chaining stays in place after refactoring SpriteStateMachine.

### Pitfall 7: Phaser depth math sign confusion
**What goes wrong:** GameMaker: lower depth = drawn on top. Phaser: higher depth = drawn on top. If the sign is wrong, the player renders BEHIND floor tiles instead of above.
**How to avoid:** Implementation-time sanity check: place player at y=200, floor tile at y=300. Player should appear above. If not, flip the sign.
**Warning signs:** Player sprite occluded by tiles that are "below" it (lower y).

### Pitfall 8: BKA1 sprite emits 1 frame instead of 55 in bootstrap
**What goes wrong:** Default bootstrap path scans for `^img_\d+\.bmp$` and sorts lex. Lex-sort puts `img_10.bmp` before `img_2.bmp` if the indices are not zero-padded. **Check actual filenames** before relying on lex-sort.
**Verification (already done in this research):** `ls extracted/client-5-8/sprites/0064-BKA1/frames/` returns `img_000.bmp` ... `img_054.bmp` (3-digit padded — confirmed). Lex sort is correct. [VERIFIED: filesystem listing]

### Pitfall 9: Walkable-region mask doesn't account for tile origin
**What goes wrong:** Tile sprite origin in atlas is `(0,0)` per `pipeline-manifest.json`. Tile position is `(t.x, t.y)` placed via `.setOrigin(0, 0)`. So the floor occupies `[t.x, t.x + 44] × [t.y, t.y + 40]`. If the mask is built using `t.x / 44, t.y / 40` (grid cell), and player position is "feet at y" (sprite origin 0.5/1), then the NaviMask bbox check at `y + NAVI_MASK.bottom = y + 46` correctly probes the feet zone — but only if positions are world-coords, not local-coords. Verify both renderer + game-logic use the same coord system.
**How to avoid:** Unit test pinning a sample probe: tile at (44, 40), player feet at (50, 80) → probe (50+9, 80+40) = (59, 120) → cell (1, 3); player feet at (50, 50) → probe → cell (1, 2). Lock these values.

### Pitfall 10: Run animation frame index advances at 60 Hz when remote
**What goes wrong:** D-42 root cause. Existing `GameScene.update()` lines 651-664 calls `onSimulationTickRemote` INSIDE the sim-tick accumulator block (good — gated to 30 Hz) BUT inside that block, it iterates ALL remote players and calls `onSimulationTickRemote` for each — that's correct. So the bug is NOT that remote is on 60Hz; it must be elsewhere. Reading more carefully: line 653: `const vx = (p.axis_x_held ?? 0) * 3;` — the `* 3` is WALK_SPEED. Post-rename to RUN=5, this should be `* 5`. **More importantly:** if remote sprites are stuck at the SAME velocity-derived facing every tick (because `axis_x_held` is the held-axis flag NOT changing per tick), the `deriveFrame` will increment the cycle correctly. The reported D-42 "too fast" symptom — is the player perhaps also calling `onSimulationTickRemote` from a different code path? Search for it. (Only one call site in GameScene.ts — gated. Suspect: the existing `onRemoteSnapshot` handler on line 465-478 may ALSO be advancing animation via `updateRemote` which calls `setPosition` but NOT setFrame — that's fine.) **Recommend:** add a frame-rate counter to `onSimulationTickRemote` during the D-42 fix to verify it fires exactly 30/sec under steady state.

## Code Examples

Verified patterns from extracted source:

### `image_speed = curspeed/10` (animation rate)
```gml
// Source: extracted/client-5-8/objects/0000-server/events/Other-7.gml:4
image_speed = global.curspeed/10;
```

### Per-axis sub-pixel collision loop
```gml
// Source: extracted/client-5-8/objects/0000-server/events/Step.gml:222-243
move = round(lengthdir_x(fspeed,direction));
for(i = 0; i < abs(move); i += 1)
{
  if(sign(move) == -1 && walkable_at(x-1+9, y+39)) x -= 1;
  else if(sign(move) == 1 && walkable_at(x+1+26, y+39)) x += 1;
  else break;
}
// same for y-axis with bbox top/bottom
```

### Depth formula
```gml
// Source: extracted/client-5-8/scripts/0354-depth_set.gml
depth = round(((10000 * -floor) + (1000 * layer)) - (y + yOffset));
```

### BNCentral camera deadzone derivation
```json
// Source: extracted/client-5-8/rooms/0058-BNCentral/meta.json views[0]
{ "viewW": 640, "viewH": 480, "hBorder": 304, "vBorder": 224, "hSpeed": -1, "vSpeed": -1 }
// → deadzone = (640 - 2*304, 480 - 2*224) = (32, 32)
```

### bkdraw create + step + instance creation code
```gml
// Source: extracted/client-5-8/objects/0051-bkdraw/events/Create.gml
if(image_speed == 1) image_speed = 0.25;
if(dscale == 0) dscale = 1;

// Source: extracted/client-5-8/objects/0051-bkdraw/events/Step.gml
dxoff += dxspeed;    // wrap when > sprite_width*dscale
dyoff += dyspeed;
bgframe += bgspeed;  // wrap when > bgframes

// Source: extracted/client-5-8/rooms/0058-BNCentral/instances.json:3 — first instance creationCode
dyspeed = -1;
dxspeed = 0.25;
```

### tileborder body (bottom-only side, top/left/right commented out)
```gml
// Source: extracted/client-5-8/scripts/0085-tileborder.gml:1-25
if((!tbottomcheck(tile1) || tbottomcheck(hiddentile) || tbottomcheck(magictile)) && argument0 != -1)
{
  retval = instance_create(x, y+40, argument0);  // TSide1 below the tile
}
/* top/left/right border spawn — fully commented out in 5-8 */
```

### Local vs remote nametag color rule
```gml
// Source: extracted/client-5-8/objects/0000-server/events/Draw.gml:2-3 — local
font_color = c_aqua;
font_name = "fixedsys";

// Source: extracted/client-5-8/objects/0042-player/events/Draw.gml:2-3 — remote
font_color = c_white;
font_name = "fixedsys";
```

### Run = 5, walk dropped per Ctrl+R toggle (rebno drops the toggle)
```gml
// Source: extracted/client-5-8/objects/0000-server/events/KeyPress-82.gml:4-14
if(global.curspeed == 3) {
  global.curspeed = 5;          // RUN speed
  image_speed = 0.5;            // = 5/10
}
else {
  global.curspeed = 3;          // WALK speed (rebno drops)
  image_speed = 0.3;            // = 3/10
}
```

### NaviMask bbox (player feet collision)
```json
// Source: extracted/client-5-8/sprites/0034-NaviMask/meta.json
{ "bboxLeft": 9, "bboxTop": 40, "bboxRight": 26, "bboxBottom": 46, "width": 36, "height": 48 }
// → 18 px wide × 7 px tall feet rect at sprite bottom-center
```

## State of the Art

| Old Approach (in current code) | Current Approach (per CONTEXT) | Why Changed | Impact |
|--------------------------------|---------------------------------|-------------|--------|
| `WALK_SPEED_PX_PER_TICK = 3` | `RUN_SPEED_PX_PER_TICK = 5` | rebno is single-speed; CONTEXT D6.1-05; legacy Ctrl+R toggle dropped | All call sites in step.ts + remote-vx computation in GameScene must rename |
| `TICKS_PER_FRAME_ADVANCE = 1` (boolean per-tick) | `framesPerTick = curspeed / 10` (fractional) | image_speed in GML is fractional; D-41/D-42 root cause | SpriteStateMachine.deriveFrame signature change |
| `setBounds(0, 0, roomW, roomH)` on camera | NO setBounds (camera unbounded) | World should feel infinite; CONTEXT D6.1-16 | Remove the call; background renders past room edge masks the void |
| Full-sprite or rectangle collision bbox in step() | NaviMask 18×7 feet bbox | BNO canonical; CONTEXT D6.1-27 | step() takes bbox as per-entity data |
| Single-shot `resolveCollision(desired)` | Per-axis pixel-by-pixel loop with bbox check each step | BNO canonical; CONTEXT D6.1-28; enables wall-slide | step() inner loop rewrite |
| Per-tile-object border spawns (legacy) | Walkable-region mask derived at room load | Modern equivalent; CONTEXT D6.1-29; PAR-03 will reuse this path | New `RoomCollision.ts` helper |
| RoomRenderer wall_border as Rectangle primitives | Floor tiles + TSide1 bottom-edge sides (no wall rectangles) | BNO has no "wall" — just absence of floor; CONTEXT D6.1-25 | Drop wall_border code path or leave dormant |
| Nameplate fixed color `#22D3EE` | Cyan local / white remote + Fixedsys + 1px shadow | BNO canonical with operator-requested shadow; CONTEXT D6.1-10..13 | Nameplate constructor takes `isLocal` flag |
| Animation advance per render-frame (old D-31 regression) | Animation per sim-tick (already correct for local, verify for remote) | D-31 lock; D-41/D-42 are regression reopen | Confirm sim-tick accumulator gates both call sites |
| No background | bkdraw port — animated tiled scrolling background | Operator-required visual identity; CONTEXT D6.1-17..20 | New BackgroundRenderer + atlas-ingest BKA1 + scroll math |

**Deprecated/outdated:**
- `WALK_SPEED_PX_PER_TICK` constant — renamed.
- `TICKS_PER_FRAME_ADVANCE` boolean — replaced by fractional rate.
- `wall_border` rendering branch in RoomRenderer — keep dormant or remove.
- Legacy `setBounds` on camera — remove.
- Use of `* 3` magic numbers in GameScene.update for remote vx/vy proxy (line 653-654) — replace with RUN_SPEED_PX_PER_TICK.

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | Fixedsys Excelsior 3.01 CC0 / public-domain license is acceptable for this project | Standard Stack — Font Asset | Low — operator can swap fonts; alternate is any monospace pixel font with permissive license |
| A2 | Phaser 3.90 `setRoundPixels(true)` + `startFollow(target, true)` combine correctly for HiDPI nearest-neighbor at integer-scale zoom | Pattern 7 | Medium — if not, e2e test would catch in HUMAN-UAT Test 1; can fall back to manual setScroll math |
| A3 | The shadow rendered via Phaser Text `shadow: {offsetX, offsetY, blur:0, fill:true}` produces a 1-px solid offset (not a soft glow) | Pattern 9 | Low — visible regression detectable in Playwright screenshot |
| A4 | Walkable-region boolean grid is performant enough for 30 Hz simulation × 8-keys-pressed × 2-axis × max-5-px-loop = ~240 probes/sec; trivial | Pattern 5 | Low — even a naive grid is microseconds per probe |
| A5 | 06-15's cookie-resume self-heal IS what teardown-and-recreates the local sprite (D-46 root cause hypothesis) | Pitfall 5 | Medium — if root cause is different (e.g., a Phaser internal `_follow` not being updated), the fix shape changes; verify by reading 06-15-SUMMARY.md during plan generation |
| A6 | Atlas frame-key lookup is the cheapest of the three D-40 candidate causes to verify | Pitfall 1 / common pitfalls | Low — even if not the cause, the 5-minute log addition is cheap |
| A7 | The Shift-stand semantics can be implemented client-side with zero protocol changes by sending `axes={0,0}` and updating a local `facingHint` | Pattern 3 | Medium — if operator wants Shift+direction to look IDENTICAL on remote clients (other players see this player face DR but stand still), then we DO need a protocol change to broadcast the facingHint. CONTEXT is silent on remote-observability of Shift-stand. Plan should ask. |

**If this table is empty:** Not empty — 7 assumptions. The planner and discuss-phase should confirm A1, A5, A7 before locking the plan.

## Open Questions (RESOLVED)

All open questions have been resolved at plan-time (2026-05-11) per checker B1.

1. **D-40 root cause: which of the three candidates?** (RESOLVED via in-execution spike — see Plan 06.1-03 Task 1)
   - What we know: tiles loop in RoomRenderer.renderNew exists and iterates layout.tiles[]; atlas key `0023-Tile1_000` is in pipeline-manifest.json; player sprite renders, so atlas loads.
   - What's unclear: which short-circuits the rendering — manifest miss, schema discriminant miss, or signature verify miss.
   - Resolution: deferred to in-execution spike Task 1 of Plan 06.1-03; spike output (`06.1-D40-SPIKE.md`) names the root cause before Wave 2 starts. ~15-minute three-step verification (atlas key → schema discriminant → Ed25519 verify) in cheapest-first order.

2. **Shift-stand: client-only or protocol-level?** (RESOLVED via CONTEXT D6.1-09 — client-only, no protocol change)
   - What we know: Operator requested Shift replaces Alt-stand-in-place; CONTEXT D6.1-09 specifies client-side input binding.
   - What's unclear (was): whether remote players see the Shift-standing player as "facing right while standing" (requires protocol change) or as "standing facing last-moved direction" (no protocol change).
   - Resolution: CONTEXT D6.1-09 locks client-only. Remote players see standing-facing-last-moved-direction; no wire-format change. Phase 7 may revisit if visual fidelity demands.

3. **Camera deadzone vs. instant snap on cookie-resume.** (RESOLVED 2026-05-11: no pre-snap on cookie-resume; Phaser deadzone catches up within ≤1 frame)
   - What we know: Camera reset to deadzone (32, 32) on every applyCameraFollow call.
   - What's unclear (was): should the cookie-resume path re-center the camera on the player AND reapply deadzone? Or just snap?
   - Resolution: **adopt "no pre-snap"** — let Phaser's deadzone catch up over a few frames. The deadzone is only 32×32, so visible lag is ≤1 frame at run speed (5 px/tick × 30 Hz = 150 px/s; 32 px deadzone closes in <0.25 s, and typically within 1 frame because cookie-resume happens while the player is at rest). Plan 06.1-06 Task 2 step 1's `wasRecreated` branch wires `startFollow + setDeadzone + setRoundPixels` ONLY — no `setScroll` pre-snap.

4. **Background scrollFactor — option (a) vs (b) for scrolling math.** (RESOLVED — scrollFactor=0 + per-frame position offset)
   - What we know: bkdraw uses `view_left, view_top` (camera scroll) as the anchor.
   - What's unclear (was): in Phaser, do we set `scrollFactor=0` on each background tile and position relative to viewport, OR `scrollFactor=1` and position world-absolute relative to camera scrollX/Y? Both work, different rendering perf.
   - Resolution: scrollFactor=0; position each tile at `cameras.main.scrollX + offset`. Phaser optimizes scrollFactor=0 sprites. Locked in Plan 06.1-05 BackgroundRenderer.

5. **Walkable-grid: per-cell boolean OR rectangle-union?** (RESOLVED — per-cell boolean Uint8Array)
   - What we know: 20×20 MVP room → 400 cells × 1 byte = 400 bytes. Trivial.
   - What's unclear (was): edge cases for tiles whose `(t.x, t.y)` doesn't align to grid (shouldn't happen if tile_w=44, tile_h=40 and tiles are placed on the grid, but verify).
   - Resolution: per-cell boolean `Uint8Array`. Unit test the alignment by reading the actual `mvp-room` layout and asserting `t.x % 44 === 0 && t.y % 40 === 0` for every tile. Locked in Plans 06.1-02 (types) and 06.1-05 (RoomCollision derivation).

## Environment Availability

Phase 06.1 is code+config only. No new external tools required. Confirming critical existing dependencies:

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node | All builds | ✓ | 25.8.2 (host) — runtime target 22 | — |
| pnpm | Workspace ops | ✓ | (per project lockfile) | — |
| sharp | bootstrap.ts | ✓ | 0.34.5 [VERIFIED: tools/asset-pipeline/package.json] | — |
| @noble/ed25519 | roomLayoutVerify | ✓ | (Plan 04-12 pinned) | — |
| Aseprite | D-39 Test 3 (manual) | unknown | — | manual ergonomic check; not a CI gate. Skip if unavailable. |
| Fixedsys WOFF2 | Nameplate font | ✗ (must be added) | — | Vendored from public-domain GitHub kika/fixedsys release; commit under `apps/client/public/assets/fonts/` |

**Missing dependencies with no fallback:** none.

**Missing dependencies with fallback:** Aseprite — operator-only ergonomic test. Fixedsys WOFF2 — vendored, no install dependency.

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | vitest (unit) + @playwright/test (e2e) — same as Phase 6 |
| Config file | `apps/client/vitest.config.ts`, `apps/client/playwright.config.ts`, `tools/asset-pipeline/vitest.config.ts`, `packages/game-logic/vitest.config.ts` (existing) |
| Quick run command | `pnpm --filter @rebno/client test:unit` |
| Full suite command | `pnpm verify:phase-6` (existing composite gate — extend to cover 06.1 e2e) |

### Phase Requirements → Test Map

Phase 06.1 has no new REQ-IDs; it closes existing CLI-08 + AST-01 (and indirectly CLI-04, CLI-06, CLI-07) which were already failed at UAT.

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| REQ-AST-01 | bootstrap alpha-key produces alpha-channel PNG with zero sentinel pixels | unit | `pnpm --filter @rebno/asset-pipeline test test/alpha-key.test.ts` | ❌ Wave 0 |
| REQ-CLI-04 | RUN_SPEED_PX_PER_TICK = 5; pin constant | unit | `pnpm --filter @rebno/game-logic test test/run-speed.test.ts` | ❌ Wave 0 |
| REQ-CLI-04 | Shift+direction yields axes (0,0) + facingHint set | unit | `pnpm --filter @rebno/client test src/__test__/input-dispatcher-shift.test.ts` | ❌ Wave 0 |
| REQ-CLI-04 | SpriteStateMachine advances `curspeed/10` per tick (=0.5 at curspeed=5) | unit | `pnpm --filter @rebno/client test src/__test__/sprite-state-rate.test.ts` | ❌ Wave 0 |
| REQ-CLI-04 | depth_set port: layer=0,yOffset=43,y=200 → Phaser depth (sign-flipped formula) | unit | `pnpm --filter @rebno/client test src/__test__/depth-set.test.ts` | ❌ Wave 0 |
| REQ-CLI-04 | NaviMask bbox pinned to {9, 40, 26, 46} | unit | `pnpm --filter @rebno/game-logic test test/navi-mask-bbox.test.ts` | ❌ Wave 0 |
| REQ-CLI-04 | Per-axis sub-pixel collision wall-slide at corner | unit | `pnpm --filter @rebno/game-logic test test/wall-slide.test.ts` | ❌ Wave 0 |
| REQ-CLI-04 | Walkable-region edge-block: player at room edge cannot step past | unit | `pnpm --filter @rebno/game-logic test test/walkable-edge.test.ts` | ❌ Wave 0 |
| REQ-CLI-07 | BackgroundRenderer: dxoff wraps at sprite_w * dscale; bgframe wraps at 55 | unit | `pnpm --filter @rebno/client test src/__test__/background-renderer.test.ts` | ❌ Wave 0 |
| REQ-CLI-08 | Playwright: floor tiles rendered with TSide1 on outer-cell bottoms | e2e | `pnpm --filter @rebno/client test:e2e test/e2e/cli-08-tiles.e2e.test.ts` | ❌ Wave 0 |
| REQ-CLI-08 | Playwright: camera.scrollX/Y changes after 200ms of held movement input | e2e | `pnpm --filter @rebno/client test:e2e test/e2e/cli-08-camera.e2e.test.ts` | ❌ Wave 0 |
| REQ-CLI-08 | Playwright: nameplate visible above sprite with correct color rule (cyan local / white remote) | e2e | `pnpm --filter @rebno/client test:e2e test/e2e/cli-08-nameplate.e2e.test.ts` | ❌ Wave 0 |
| REQ-CLI-08 | Playwright: local + remote animation rate visually consistent (smoke) | e2e | `pnpm --filter @rebno/client test:e2e test/e2e/cli-08-anim.e2e.test.ts` | ❌ Wave 0 |
| REQ-CLI-08 | UAT re-run Test 2 (manual): two-player video captured | manual | `06.1-HUMAN-UAT.md` Test 2 | ❌ Wave 0 |

### Sampling Rate
- **Per task commit:** `pnpm --filter @rebno/client test:unit` (or scoped filter)
- **Per wave merge:** `pnpm verify:phase-6` (composite — extend to include alpha-key + background-renderer + walkable-edge unit tests)
- **Phase gate:** Full suite green + Playwright two-player smoke green + operator-captured `06.1-CLI-08-milestone.mp4` before `/gsd-verify-work`

### Wave 0 Gaps
- [ ] `tools/asset-pipeline/test/alpha-key.test.ts` — covers D-39 alpha keying regression guard
- [ ] `packages/game-logic/tests/run-speed.test.ts` — pins RUN_SPEED_PX_PER_TICK = 5
- [ ] `packages/game-logic/tests/navi-mask-bbox.test.ts` — pins {left:9,top:40,right:26,bottom:46}
- [ ] `packages/game-logic/tests/wall-slide.test.ts` — corner wall-slide assertion
- [ ] `packages/game-logic/tests/walkable-edge.test.ts` — edge-of-floor cannot step past
- [ ] `apps/client/src/__test__/sprite-state-rate.test.ts` — fractional frame advance test
- [ ] `apps/client/src/__test__/depth-set.test.ts` — depth formula pin
- [ ] `apps/client/src/__test__/background-renderer.test.ts` — wrap math
- [ ] `apps/client/src/__test__/input-dispatcher-shift.test.ts` — Shift→axes(0,0)+facingHint
- [ ] `apps/client/src/__test__/nameplate-color.test.ts` — cyan local / white remote
- [ ] `apps/client/test/e2e/cli-08-tiles.e2e.test.ts` — Playwright tile-rendering assertion
- [ ] `apps/client/test/e2e/cli-08-camera.e2e.test.ts` — Playwright camera-follow assertion
- [ ] `apps/client/test/e2e/cli-08-nameplate.e2e.test.ts` — Playwright color rule
- [ ] `apps/client/test/e2e/cli-08-anim.e2e.test.ts` — animation-rate smoke
- [ ] `.planning/phases/06.1-gap-closure-d-39-d-46-uat-2026-05-11/06.1-HUMAN-UAT.md` — UAT re-run instructions

## Security Domain

Phase 06.1 has **no security-surface changes**:
- No new auth flows; Better-Auth + argon2id unchanged.
- No new wire shapes; PROTOCOL_VERSION unchanged.
- No new RCE-relevant intent types.
- No new user input pathways beyond keyboard.
- Atlas alpha-key is offline build-time; deterministic output.
- Walkable-region mask is server-derivable in `@rebno/game-logic` step(); server authority preserved.

**ASVS categories applicable to this phase:** none directly. CLAUDE.md hard rules #1 (server-authoritative — preserved), #2 (no plaintext passwords — n/a), #3 (no RCE admin — n/a), #5 (wire protocol from extracted GML — n/a, no protocol change), #6 (extract→document→rewrite — followed: every constant cited to extracted file:line) are all respected by the locked CONTEXT decisions.

**Known threat patterns for this stack — already mitigated:**

| Pattern | STRIDE | Mitigation |
|---------|--------|------------|
| Client-trusted positions | Tampering | Server-authoritative step() (existing — preserved) |
| Atlas tampering in transit | Tampering | TLS-gated; pipeline-manifest.json sha256 cross-check (existing) |
| Room-layout tampering | Tampering | Ed25519 signature verify (existing; D-40 candidate cause may need a softening of the verify to log-and-render — be careful not to fully disable the check; this is defense-in-depth even though server-side RoomRegistry is the primary trust path) |

## Sources

### Primary (HIGH confidence)
- Extracted GML and meta.json (this repo, `extracted/client-5-8/`) — every cited file:line verified by Read tool in this research session.
- `apps/client/package.json`, `tools/asset-pipeline/src/bootstrap.ts`, `apps/client/src/render/*.ts`, `apps/client/src/scenes/GameScene.ts`, `apps/client/src/prediction/input-dispatcher.ts`, `packages/game-logic/src/{constants,step}.ts` — all read verbatim.
- CONTEXT.md `<decisions>` block — 30 locked decisions D6.1-01..D6.1-30.

### Secondary (MEDIUM confidence)
- Fixedsys Excelsior 3.01 license + WOFF2 availability (web search).

### Tertiary (LOW confidence)
- Phaser 3.90 `setRoundPixels` exact HiDPI semantics — not re-verified in this session against Phaser docs; consult phaser docs at implementation time if pixel-art appears blurry.

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — already locked from Phase 6, unchanged.
- Architecture patterns: HIGH — every pattern cited to extracted file:line in this session.
- Pitfalls: HIGH — derived from reading the actual existing code in this session.
- D-40 root cause: MEDIUM — three candidates documented; planner must run the cheapest verification first.
- Shift-stand wire shape: MEDIUM — A7 assumption; ask in plan-phase.

**Research date:** 2026-05-11
**Valid until:** Phase 06.1 plan-phase completion. Extracted-GML citations are durable (Phase 1 closed; bytes won't change). Phaser 3.90 API references valid for the engine version pinned.

## Project Constraints (from CLAUDE.md)

Phase 06.1 must respect the following project-wide hard rules (CLAUDE.md):

1. **Server-authoritative.** Clients send intent. Server emits state. The locked CONTEXT decisions preserve this: NaviMask + walkable-region must be in `@rebno/game-logic` (shared, runs on server). ✓
2. **No faithful port of plaintext passwords.** N/A (no auth changes).
3. **No faithful port of "run clipboard as superuser" admin.** N/A (no admin paths).
4. **`.bno`/`.bnb`/`.bnu` parsing requires extracted GML first.** N/A (no save-format work).
5. **39dll wire protocol = call order.** N/A (no protocol changes; Shift-stand intentionally client-only per recommendation A7).
6. **Extract → document → rewrite, in that order.** ✓ Every constant in this research is cited to extracted GML; every fix is a rewrite based on extracted facts.
7. **Modern decompilers cannot read GM 5.3a.** N/A (extraction already done in Phase 1).
8. **Repo stays private through Phase 7.** N/A (no public-facing changes; staging-invite gate still in place).

**Additional hard constraints from CLAUDE.md:**
- **44×40 tile pitch is load-bearing.** ✓ CONTEXT confirms; BKA1 is 32×32 (background tile, not floor tile); 0023-Tile1 is 44×40 (verified meta.json). Background tiles are NOT floor tiles.
- **Sim-tick lock (D-31).** ✓ D-41/D-42 fix re-enforces this.
- **Single canonical mover object is `0000-server`, not `0042-player` or navi* scripts.** ✓ All GML citations in this research are from `0000-server` (local player) or `0042-player` (remote player draw).
