# Phase 6: Client Rebuild — MVP Gate (CLI-08 HARD MILESTONE) — Research

**Researched:** 2026-05-09
**Domain:** Vite + Phaser 3.90 client; Colyseus 0.17 client SDK; client-side prediction + server reconciliation; Better-Auth client; sharp+atlas pipeline; same-Fly-app static serve; Playwright two-client merge-gate.
**Confidence:** HIGH on stack pins (`npm view` 2026-05-09); HIGH on Colyseus / Phaser / Vite / Better-Auth API shapes (Context7); HIGH on prior-phase decisions (CONTEXT.md / ADRs verbatim); MEDIUM on prediction-error histogram numbers (no Phase 5 soak telemetry to draw from yet — planner picks final numbers).

## Project Constraints (from CLAUDE.md)

These are project-wide directives extracted from `./CLAUDE.md`. The planner MUST verify every plan against this list. Violations are blocking.

| # | Directive | Phase 6 Application |
|---|-----------|---------------------|
| 1 | Server-authoritative — clients send intent, server emits state | Prediction is local-only; never overwrite server state from client. Reconciliation snaps/lerps TO server position, never the reverse. |
| 2 | No faithful port of plaintext passwords | Login flow is Better-Auth + argon2id only. Client never inspects/transmits cleartext beyond the form POST. |
| 3 | No "run clipboard as superuser" admin model | Phase 6 client has ZERO admin surface; admin UI is Phase 7 PAR-07 (separate web app). |
| 6 | Extract → document → rewrite, in that order | `tools/asset-pipeline` consumes Phase 1 extracted BMPs. Bootstrap runs once per BMP set; build re-runs from source PNGs going forward. |
| 7 | Modern decompilers cannot read GM 5.3a | N/A in Phase 6 — extraction already done. |
| 8 | Repo stays private through Phase 7 | Staging access gated by Fly IP allowlist + STAGING_INVITE_TOKEN (Phase 5 D-04). Client must NOT log secrets, must NOT bake STAGING_INVITE_TOKEN into prod bundle. |
| TS-everywhere | TypeScript strict mode; shared types via `packages/protocol` | `apps/client` uses `tsconfig.json extends ../../tsconfig.base.json` with `"strict": true`. |
| ADR-on-first-lock | Decisions that lock future work get an ADR | D-07 (home-portal spawn semantics), D-08 (`input_axes` in PlayerState), D-09 (event-driven `c2s.input`) lock Phase 7 wire-shape behavior — Phase 6 plan SHOULD produce **ADR 0007 — Phase 6 protocol amendments + prediction contract** to document the locked-in contract for Phase 7 PAR-04. *(Planner discretion — call it 0007 or amend ADR 0004 inline; pick one.)* |
| traceable-reqs tagging | Every artifact gets a `[<stage>->REQ-ID]` comment | Phase 6 reqs: REQ-CLI-01..09 + REQ-AST-01. `apps/client/src/**/*.ts` carries `// [<impl>->REQ-CLI-XX]`; `tools/asset-pipeline/**/*.ts` carries `// [<impl>->REQ-AST-01]`; tests carry `// [<unit>->...]` or `// [int->REQ-CLI-08]`. |

## User Constraints (from CONTEXT.md)

### Locked Decisions (do not propose alternatives)

**Login + session UX (CLI-02, CLI-03)**
- **D-01** Hybrid Phaser scene + DOM-overlay form via `Phaser.GameObjects.DOMElement`. Browser password-manager + autofill + a11y all work. Errors render inline.
- **D-02** Boot calls `GET /api/auth/me`. Valid session → ~500 ms spinner with "Reconnecting as `<username>`…" → transition to `GameScene`. Esc cancels spinner. Frictionless steer (Phase 4 D-07) preserved.
- **D-03** `s2c.force_password_change` after `onJoin` opens an in-room password-change overlay. Movement frozen; **no disconnect-reconnect cycle**.

**Chat HUD (CLI-05)**
- **D-04** Minecraft-pattern non-modal DOM overlay over Phaser canvas. Default = bottom-left fading messages. **Enter or T** opens chat input + freezes movement keys (input field is focus-target). **Enter** sends + closes; **Esc** cancels + closes. Visible chat-mode indicator.
- **D-05** Always-on remote-player nameplates. Form-factor (Phaser canvas text vs DOM overlay) is planner's call.

**Reconnect UX (CLI-09)**
- **D-06** Top-banner "Reconnecting…" with auto-retry every 1 s. Local prediction continues; **world-mutating actions blocked** (none in MVP scope; affordance baked in for Phase 7). Within Phase 4's 10 s grace → `allowReconnection`. Past 10 s → silent re-auth from cookie. Final failure → "Disconnected — click to retry."
- **D-07** Home-portal spawn on new session, NOT last position. **Refines Phase 4 D-14.** Server-side change required: `onJoin` for a new Better-Auth session emits home-portal position. Position persists in `characters.x/y/room_id` only for grace-window reconnect.

**Movement prediction + reconciliation (CLI-04, CLI-06)**
- **D-08** **Refines Phase 4 D-01.** `PlayerState` schema gains `input_axes` field. Server tick-loop populates from per-account stored input vector. Bumps `PROTOCOL_VERSION` (current `1` → planner picks `2`). Coordinated server-before-client deploy ritual (Phase 4 D-03).
- **D-09** **Refines Phase 4 D-02.** `c2s.input` is event-driven — sent on **state change** (keydown/keyup transitions), not per server tick. Payload: `{ seq, axes: {x, y}, buttons_down, buttons_up, monotonic_at_ms }`. Server stores latest axes per account. 15 s heartbeat-coupled "still-pressed" reaffirmation. Planner may relax Phase 4 D-22's `input` budget (currently 25/s burst 35).
- **D-10** Threshold-gated reconciliation. Tile = 44 × 40 px. `divergence < ~22 px` → smooth lerp ~100 ms while replaying unacked inputs. `divergence ≥ threshold` → hard snap. Planner locks the concrete number.
- **D-11** Snapshot interpolation at ~100 ms backbuffer; if snapshot stale > 200 ms, extrapolate by running `step()` locally for that player using broadcast `input_axes` (D-08), capped at 250 ms horizon. After cap, freeze sprite. On next snapshot: snap-or-lerp by D-10's rule.
- **D-12** Phaser `Scale.FIT` + `autoRound: true` + `Phaser.Textures.FilterMode.NEAREST` everywhere; CSS `image-rendering: pixelated` belt-and-suspenders; integer-scale snap on `window.resize`.

**Asset pipeline (AST-01, CLI-07)**
- **D-13** `tools/asset-pipeline/` standalone Node CLI **outside the workspace** (Phase 1 D-17 / Phase 2 D-15 boundary preserved).
- **D-14** Two modes:
  - `bootstrap` — reads `extracted/client-5-8/sprites/<id>/<frame>.bmp` + `meta.json`, decodes via sharp, emits `assets/source/sprites/<sprite_id>.{png,json}` (Aseprite-friendly horizontal strip + JSON sidecar). Output committed.
  - `build` — packs `assets/source/**` into multi-sprite atlas (`atlas-mvp.png` + Phaser-format `atlas-mvp.json`) + per-room background. Emits `tools/asset-pipeline/output/pipeline-manifest.json` with rich per-sprite metadata (frames, origin, hashes) referencing Vite-hashed paths post-build.
- **D-15** Aseprite-friendly source: `<sprite_id>.png` is horizontal-strip; `<sprite_id>.json` carries `{frame_w, frame_h, frame_count, origin: {x, y}, sprite_id}`. Aseprite CLI: `aseprite -b sprite.aseprite --sheet sprite.png --data sprite.json --format json-array`.
- **D-16** Hybrid manifest = Vite hashes (in `dist/manifest.json`) + asset-pipeline post-process into richer `pipeline-manifest.json`. Decouples Phase 7 AST-02..04 from Vite internals.
- **D-17** MVP atlas scope: NaviStandD/U/R + NaviRunD/U/R (player) + Tile1, TSide1, VBorder, HBorder + 1 background. Atlas budget 512 × 512 px target, 1024 × 1024 fallback. *(Confirmed against `extracted/client-5-8/sprites/` listing — sprite IDs 0000, 0023-0033 are the canonical MVP picks.)*

**Hosting + CLI-08 verification**
- **D-18** Same Fly app — `apps/server` serves static. Vite outputs to `apps/server/public/` or `apps/server/dist/client/` (planner picks). `app.use(express.static(...))` mounted **after** `/api/auth/*` routes and **before** Colyseus WS upgrade path. Single domain per env (`staging.rebno.decidel.com`, `rebno.decidel.com`); zero CORS; cookies `SameSite=Strict` viable.
- **D-19** CLI-08 verification = Playwright two-client merge-gate (in `pnpm verify:phase-6` + `.github/workflows/deploy-staging.yml`) + manual UAT recorded in `06-HUMAN-UAT.md`.

### Claude's Discretion (research-driven recommendations below)

- D-05 nameplate form-factor (Phaser canvas text vs DOM overlay)
- D-10 concrete divergence threshold (recommended 22 px; planner may pick 16/20/24)
- D-11 extrapolation cap concrete value (recommended 250 ms)
- D-15 `.aseprite` source storage location (committed under `assets/source/sprites/aseprite/` vs gitignored)
- D-17 MVP background image (selected after `tools/room-converter` outputs inspected — currently `mvp-lobby/000.json` has `bg_atlas_ref: "mvp-lobby:bg"` but no actual bg yet)
- D-18 static-mount path (`apps/server/public/` vs `apps/server/dist/client/`)
- Room rendering — Phaser `TilemapLayer` recommended; planner may flip to custom blit if awkward
- Phase 6 plan must coordinate D-07 + D-08 + D-09 server-side amendments

### Deferred Ideas (OUT OF SCOPE)

AST-02..04 (audio/fonts/full content-hashed manifest) → Phase 7. Multi-room transitions, friends, whispers, settings, account recovery → Phase 7. Full chat surface → Phase 7. `.bnu` migration → Phase 7. Modernized admin UI → Phase 7. Pre-game lobby, character creation, settings menu, message-board reader → Phase 7. Mobile / native ports → out of scope per PROJECT.md. Behavioural anti-cheat → out of scope. Pixel-diff verification of MVP room → Phase 7 PAR-03. OAuth/passkey providers → Phase 7 PAR-06. Phaser 4 migration → Phase 7 retro window. OTel browser SDK → v2 / Phase 7. Independent client deploy cadence → v2.

## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| REQ-CLI-01 | `apps/client` is a Vite-built TS Phaser bundle running in Chrome desktop | Standard Stack §Core, Architecture Patterns §Project Structure, Code Examples §Vite config |
| REQ-CLI-02 | Connect over WSS binary frames + PROTOCOL_VERSION handshake | §Pattern: Colyseus client SDK 0.17, §Code Examples §`joinOrCreate` |
| REQ-CLI-03 | Login screen authenticates via server, transitions to game | §Pattern: Better-Auth client + bearer token; §Code Examples §LoginScene + DOMElement |
| REQ-CLI-04 | Client-side prediction + entity interpolation + reconciliation | §Pattern: Prediction loop; §Code Examples §PredictionEngine |
| REQ-CLI-05 | Chat HUD with Enter-send + sender nameplates | §Pattern: Minecraft-pattern DOM overlay; §Code Examples §ChatHUD |
| REQ-CLI-06 | HiDPI nearest-neighbor + integer scale | §Pattern: Phaser Scale.FIT + autoRound + FilterMode.NEAREST + CSS pixelated |
| REQ-CLI-07 | Asset pipeline produces atlas + bg via content-hashed manifest | §Pattern: Asset pipeline two-mode CLI; §Code Examples §sharp BMP→PNG + atlas pack |
| REQ-CLI-08 | **MVP GATE**: Two players see each other move + chat over deployed server | §Validation Architecture §Playwright two-client harness |
| REQ-CLI-09 | Reconnect within grace restores same room + same character | §Pattern: Colyseus `reconnectionToken`; §Code Examples §ReconnectController |
| REQ-AST-01 | sharp BMP→PNG sprite atlas; per-sprite metadata preserved | §Pattern: bootstrap mode + sharp lossless config |

## Summary

Phase 6 is the **rewrite** stage of CLAUDE.md hard rule #6 — Phases 1–5 produced the schemas, the deployed server, the room layouts, and the legacy account migration. Phase 6 stands up `apps/client` as a thin authoritative-server consumer and proves CLI-08 end-to-end on staging.

**Three orthogonal subsystems land together** and the planner must coordinate them:

1. **Vite + Phaser scaffold** (CLI-01, CLI-06) — the static rendering substrate. Decisions are well-locked by Phaser 3.90 ADR + D-12 + D-18 same-Fly-app. Risk = LOW.
2. **Network + prediction layer** (CLI-02, CLI-04, CLI-09) — the protocol amendments (D-08 `input_axes`, D-09 event-driven inputs) require coordinated server patches landed before the client; reconciliation is the single hardest piece of new code in Phase 6. Risk = MEDIUM (well-trod ground per Gambetta / Overwatch netcode references, but the threshold tuning needs Phase 5 soak data the planner will pull at task time).
3. **Asset pipeline** (AST-01, CLI-07) — the BMP→PNG bootstrap is sharp-only and bounded; the build mode + hybrid Vite/pipeline manifest is novel but small; the 854 extracted sprites are reduced to ~10 picks for MVP (D-17) so atlas size never exceeds 1024 × 1024. Risk = LOW–MEDIUM.

**Primary recommendation:** TDD the Playwright two-client smoke FIRST (D-19), then build inside-out: protocol amendments + server patches → asset pipeline bootstrap → asset pipeline build → BootScene + LoginScene + Vite bundle → GameScene + prediction + chat HUD + reconnect UX. The Playwright gate stays red until CLI-08 turns green; this prevents regression while the rest of Phase 6 churns.

**Critical ordering constraint** (Phase 4 D-03 ritual): `packages/protocol` `PROTOCOL_VERSION` bump (1 → 2) + `PlayerState.input_axes` + `c2s.input` event-driven shape MUST land + redeploy on staging BEFORE the Phase 6 client bundle ships. This is one Phase 6 wave in itself (server amendments). Skipping the ritual = `s2c.error{code: 'PROTOCOL_VERSION_MISMATCH'}` for every client during the deploy window.

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|--------------|----------------|-----------|
| HTML/login form rendering | Browser DOM (overlaid via Phaser DOMElement) | — | a11y, password-manager, autofill all want native DOM (D-01) |
| Game scene rendering, sprites, camera | Browser (Phaser canvas) | — | ADR 0001 + CLI-06 nearest-neighbor scaling |
| Auth credential exchange | Express `/api/auth/*` (Better-Auth on `apps/server`) | Browser fetch | Server-authoritative discipline (CLAUDE.md #1); same-Fly-app means same-origin cookies |
| Session token issuance + verification | Express + Better-Auth | — | Phase 4 D-05/D-06 already locked |
| WSS connection + room lifecycle | Browser (`@colyseus/sdk`) ↔ `apps/server` Colyseus | — | CLI-02 |
| World-state authoritative | `apps/server` Colyseus Room | — | CLAUDE.md #1 |
| Local movement prediction | Browser (wraps `packages/game-logic` step) | — | CLI-04 — purely visual; never trusted by server |
| Reconciliation lerp/snap | Browser | Server (emits authoritative state via state-diff) | Server emits; client decides snap-vs-lerp |
| Remote-player extrapolation | Browser (runs `step()` against broadcast `input_axes` + signed-room layout) | — | D-08 + D-11 |
| Room-layout signature verification | Browser (Ed25519 verify against `VITE_ROOM_SIGNING_PUBKEY`) | — | ADR 0004 — engine-agnostic; client pins pubkey at build time |
| Chat send | Browser → Server (Server tags identity from `client.auth`, NOT wire) | — | CLI-05; Phase 4 onMessageHandlers verbatim |
| Chat receive + render | Server broadcast → Browser DOM overlay | — | CLI-05 + D-04 |
| Static asset CDN | Express `app.use(express.static(...))` on `apps/server` | (Tigris bucket — Phase 7 if traffic justifies) | D-18; single domain = zero CORS |
| Asset pipeline (BMP→PNG, atlas pack) | Build-time Node CLI (`tools/asset-pipeline`) | — | AST-01; outside workspace per D-13 |
| Asset hash + cache-busting | Vite (`build.manifest: true`) + asset-pipeline post-process | — | D-16 hybrid manifest |
| Prediction-error histogram capture | Browser → console / OTLP (deferred) | — | Phase 6 logs to console only; OTel browser → Phase 7 |

## Standard Stack

### Core

| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `phaser` | **3.90.0** locked by ADR 0001 | 2D engine, scenes, sprites, animations, input, camera, tilemap, DOMElement | `[VERIFIED: npm view phaser versions]` Phaser 4.1.0 IS GA but ADR 0001 explicitly defers to Phase 7 retro. Do NOT bump in Phase 6. |
| `@colyseus/sdk` | **0.17.42** | Browser WS client matched to `colyseus@0.17.10` server | `[VERIFIED: npm view @colyseus/sdk version]` — **NEW PACKAGE NAME** for the 0.17 client. The legacy `colyseus.js@0.16.22` is for 0.16 servers and DOES NOT MATCH the deployed 0.17 server. STACK.md still references `colyseus.js` — that is stale; planner must use `@colyseus/sdk`. (Soak script `scripts/soak-staging.mjs` already uses `@colyseus/sdk` — confirms the right package.) |
| `@colyseus/schema` | **4.0.23** (matches server pin) | Colyseus state-diff binary delta encoder; client-side decoder + reactive callbacks (`MapSchema.onAdd/onChange/onRemove`) | `[VERIFIED: server package.json + Context7 /colyseus/schema]` Same package both sides — pin must match server pin. |
| `vite` | **8.0.11** (latest 8.x) | Dev server + prod bundler; `manifest: true` + content-hashed paths; `--mode staging` / `--mode production` for per-env env files | `[VERIFIED: npm view vite version]` STACK.md says 8.0.10; 8.0.11 is current. Either pin works; lock at install time. |
| `typescript` | 5.6.3 (existing repo pin) | Source language | Repo-wide pin; do not bump in Phase 6. |
| `msgpackr` | 1.11.10 (matches server pin) | Browser decode of `s2c.*` events that aren't in state-diff (`chat_msg`, `room_layout`, `error`, `force_password_change`, `input_ack`) | Same package both sides; ESM `msgpackr/dist/index.mjs` for the browser. `[VERIFIED: existing apps/server/package.json + packages/protocol/src/events.ts]` |
| `zod` | 3.x (existing pin) | Re-use `cInputSchema` etc. for type-safe client-side `room.send()` | `[VERIFIED: packages/protocol/src/intents.ts]` |
| `better-auth` | **1.6.10** *(server pinned 1.6.9; client SDK matches)* | `createAuthClient` for `/api/auth/sign-in/email`, `getSession`, `signOut`, `changePassword` | `[VERIFIED: npm view better-auth version]` 1.6.10 is current. Use **same version as server** (1.6.9) to keep the client/server protocol surface identical. |

**Version verification (run before locking pins):**

```bash
npm view phaser version
npm view @colyseus/sdk version
npm view @colyseus/schema version
npm view vite version
npm view better-auth version
npm view sharp version
npm view @playwright/test version
```

Run on a fresh CI runner; record output in plan SUMMARY.md.

### Supporting

| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `sharp` | **0.34.5** (Phase 1 pin reused) | BMP decode + PNG encode for `tools/asset-pipeline bootstrap` (AST-01) | `[VERIFIED: npm view + Phase 1 plan 01-05 pin]` libvips 8.17.3 already proven deterministic in Phase 1. |
| `maxrects-packer` | **2.7.3** | MaxRects bin-packing for atlas build mode | `[VERIFIED: npm view maxrects-packer]` Pure-JS, no native deps, deterministic given sorted input. **Recommendation over `free-tex-packer-core`** — see Alternatives. |
| `@playwright/test` | **1.59.1** | Two-client merge-gate (D-19) — `browser.newContext()` per player | `[VERIFIED: npm view + CONTEXT canonical_refs]` |
| `vitest` | 3.x (existing pin) | Unit tests for prediction loop, reconciliation logic, atlas-pack determinism, manifest schema | Same engine as Vite; shared config. |
| `@types/node` | LTS (existing) | TS types for `tools/asset-pipeline` Node CLI | Standard. |

### Alternatives Considered

| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| `@colyseus/sdk@0.17.42` | `colyseus.js@0.16.22` | **REJECTED** — version-mismatched against `colyseus@0.17.10` server. STACK.md is stale on this point. |
| Phaser 3.90 | Phaser 4.1 | **REJECTED** by ADR 0001 — re-evaluation gate is Phase 7 retro, not Phase 6. |
| Phaser 3.90 | PixiJS 8.18 + `@pixi/tilemap` + `howler` | **REJECTED** by ADR 0001 — only fires on Phaser hard-knockout (none surfaced). |
| `maxrects-packer` | `free-tex-packer-core` | `free-tex-packer-core` ships built-in Phaser exporters but bundles 14 engines + a UI app; `maxrects-packer` is the bin-packing library that `free-tex-packer` itself wraps. For deterministic CLI output we want the leanest dep. **Use `maxrects-packer` directly + emit Phaser-format JSON ourselves** — fewer surface bugs, fully deterministic given sorted input. |
| `maxrects-packer` | Hand-rolled MaxRects | `[ASSUMED]` Reproducing MaxRects for ~10 sprites is not value-add; library is 2.7.3 stable, MIT, 6 KB. |
| Phaser `Tilemap` for `mvp-lobby` rendering | Custom blit on Phaser `Sprite` per tile | `mvp-lobby/000.json` has `tile_grid: [[0,0,0,0,0]]` (placeholder — single row of 5 zeros); the room is bordered by 4 rectangular `collision_polys`. **Recommendation**: Phaser `TilemapLayer` is overkill for this layout. Render the 4 walls as 4 `Phaser.GameObjects.Rectangle` (or 4 sprites if Tile1/TSide1/VBorder/HBorder are placed) and the floor as a tinted rect or repeating-texture image. **Phase 7 PAR-03** will revisit when `tools/room-converter` produces real `tile_grid` data — at that point `TilemapLayer` becomes correct. The wire format is unchanged either way. |
| Same-Fly-app static serve | Separate Fly app for client | **REJECTED** by D-18; couples deploy cadence intentionally for MVP simplicity. |
| Bearer token in WSS query (`?token=`) | Bearer in `sec-websocket-protocol` subprotocol | Phase 4 D-06 already locked the query-options approach: `client.joinOrCreate('rebno', { protocol_version, session_token, invite })`. Colyseus reads `options.token` server-side via `onAuth(client, options)`. **Use the existing pattern.** |
| Phaser canvas text for nameplates | DOM-positioned overlay synced to world→screen coords | Both work. **Recommendation: Phaser canvas `Text` (or `BitmapText` once AST-03 lands fonts in Phase 7).** Cheaper; co-located with sprite; no DOM-to-canvas-coord math; auto-scales with the Phaser camera. DOM wins on a11y but MVP doesn't ship screen-reader nameplates. Closes D-05 discretion. |

**Installation:**

```bash
# apps/client (new workspace member)
pnpm --filter @rebno/client add phaser@3.90.0 @colyseus/sdk@0.17.42 @colyseus/schema@4.0.23 msgpackr@1.11.10 better-auth@1.6.9 zod
pnpm --filter @rebno/client add -D vite@8 @playwright/test typescript @types/node vitest

# Workspace deps (already pinned)
pnpm --filter @rebno/client add @rebno/protocol@workspace:* @rebno/game-logic@workspace:*

# tools/asset-pipeline (standalone, NOT in workspace per D-13)
cd tools/asset-pipeline
pnpm init
pnpm add sharp@0.34.5 maxrects-packer@2.7.3
pnpm add -D typescript @types/node tsx vitest
```

## Architecture Patterns

### System Architecture Diagram

```
                                    Chrome (desktop)
                                          │
                       ┌──────────────────┼─────────────────┐
                       │                  │                 │
                  index.html         Phaser Game        DOM overlays
                  (Vite-built,       │  Scenes:         (chat HUD, login
                  hash-named)        │  ├─ BootScene    form, reconnect
                                     │  ├─ LoginScene   banner, force-reset
                                     │  └─ GameScene    overlay)
                                     │      │
                                     │      ├── PredictionEngine ──► step() in
                                     │      │   (local + remote)     packages/game-logic
                                     │      │                       (pure, deterministic)
                                     │      │
                                     │      ├── ReconcileEngine ──► snap-or-lerp
                                     │      │                       (D-10 threshold)
                                     │      │
                                     │      └── AtlasLoader  ◄── pipeline-manifest.json
                                     │                          + atlas-mvp.png
                                     │                          + atlas-mvp.json
                                     │
                  ┌──────────────────┼─────────────────┐
                  │                  │                 │
            fetch (JSON)        @colyseus/sdk     msgpackr (s2c.*)
            /api/auth/*         room.state        + zod (c2s.*)
                  │             room.send         room.onMessage
                  │             room.onMessage    
                  │                  │
══════════════════│══════════════════│══════════════════════ wss/https edge ═
                  │                  │
                  ▼                  ▼
            Express:           Colyseus:
            /api/auth/*        /matchmake/* (HTTP POST)
            (Better-Auth)      /colyseus    (WS upgrade)
                  │                  │
                  └────┬─────────────┘
                       │
                  apps/server (rebno-staging / rebno-prod, Fly lax, single machine)
                       │
                       ├── RebnoRoom (Colyseus Room — 20 Hz tick + state-diff)
                       │   ├── onAuth → Better-Auth getSession → AuthPayload
                       │   ├── onJoin → spawn at home portal (D-07; new code)
                       │   ├── onMessage('input') → store latest axes/seq (D-09)
                       │   ├── tickLoop → step() → broadcast PlayerState diff (with input_axes per D-08)
                       │   ├── onMessage('chat_send') → onMessageHandlers (verbatim from Phase 4)
                       │   └── onLeave → allowReconnection(client, 10s)
                       │
                       ├── RoomRegistry (fs.watch + Ed25519 — ADR 0004 verbatim)
                       │   └── on layout change → broadcast s2c.room_layout
                       │
                       └── express.static('apps/server/public/') (D-18; new mount)
                           └── serves Vite-built index.html + hashed assets
```

**Trace the primary CLI-08 flow:** Player A opens Chrome → Vite serves `index.html` + assets → Phaser BootScene → `GET /api/auth/me` → LoginScene shows form → Player A submits credentials → Better-Auth `/api/auth/sign-in/email` returns `{ session_token }` → GameScene mounts → `client.joinOrCreate('rebno', { protocol_version, session_token, invite? })` → Colyseus `onAuth` validates → `onJoin` spawns at home portal → state-diff with `input_axes` field broadcasts to A and B → A's keydown fires `c2s.input` event → server stores → server tick steps → state-diff sends new `PlayerState.x/y/input_axes` → both clients reconcile/extrapolate → CLI-08 ✅.

### Recommended Project Structure

```
apps/client/
├── package.json                  // @rebno/client; build:staging, build:prod, dev, test:e2e
├── vite.config.ts                // build.manifest:true, build.outDir resolves to apps/server/public/
├── tsconfig.json                 // extends ../../tsconfig.base.json; strict
├── index.html                    // <div id="game"> + <div id="dom-overlay">; CSP <meta>
├── .env.staging                  // VITE_WSS_URL, VITE_HTTP_BASE, VITE_ROOM_SIGNING_PUBKEY, VITE_STAGING_MODE=1
├── .env.prod                     // same keys; prod values; STAGING_MODE absent
├── .env.example                  // template w/ placeholder values; checked in
├── public/                       // static files Vite copies as-is (favicon)
├── src/
│   ├── main.ts                   // entry — new Phaser.Game(config) with BootScene
│   ├── env.ts                    // typed import.meta.env wrapper + Zod validation
│   ├── scenes/
│   │   ├── BootScene.ts          // GET /api/auth/me, fetch pipeline-manifest.json, preload atlas
│   │   ├── LoginScene.ts         // DOMElement form + 500ms spinner (D-02)
│   │   └── GameScene.ts          // tick loop, prediction, render, chat HUD
│   ├── net/
│   │   ├── ColyseusClient.ts     // wraps @colyseus/sdk Client; joinOrCreate; reconnect token cache
│   │   ├── handshake.ts          // PROTOCOL_VERSION sender + error display
│   │   └── reconnect.ts          // banner state machine (CLI-09 + D-06)
│   ├── prediction/
│   │   ├── PredictionEngine.ts   // local input ack ring buffer + replay
│   │   ├── ReconcileEngine.ts    // D-10 threshold-gated snap-or-lerp
│   │   ├── RemoteExtrapolator.ts // D-11 step()-driven; cap 250ms
│   │   └── inputDispatcher.ts    // D-09 keydown/keyup → c2s.input; 15s reaffirm
│   ├── ui/
│   │   ├── ChatHUD.ts            // D-04 Minecraft-pattern DOM overlay
│   │   ├── ReconnectBanner.ts    // D-06 yellow/orange top banner
│   │   ├── ForceResetOverlay.ts  // D-03 in-room password-change DOM form
│   │   └── Nameplate.ts          // D-05 Phaser canvas Text per remote player
│   ├── auth/
│   │   ├── client.ts             // createAuthClient w/ baseURL = '/api/auth' (same-origin)
│   │   └── session.ts            // localStorage / in-memory bearer token cache
│   ├── assets/
│   │   ├── atlasLoader.ts        // consumes pipeline-manifest.json, calls this.load.atlas
│   │   └── roomLayoutVerify.ts   // Ed25519 verify against VITE_ROOM_SIGNING_PUBKEY
│   └── types/
│       └── world.ts              // local mirror of WorldState/PlayerSim for prediction
└── test/
    ├── unit/                     // PredictionEngine, ReconcileEngine, atlas hash determinism
    ├── e2e/
    │   ├── playwright.config.ts
    │   ├── fixtures.ts           // 2 contexts, 2 logged-in pages
    │   └── cli-08.e2e.test.ts    // [int->REQ-CLI-08] — the gate
    └── mocks/                    // mock Colyseus room for unit tests

tools/asset-pipeline/             // standalone (NOT in pnpm workspace per D-13)
├── package.json
├── tsconfig.json
├── cli.ts                        // dispatcher: bootstrap | build | verify
├── src/
│   ├── bootstrap.ts              // BMP -> PNG via sharp; emit assets/source/sprites/
│   ├── build.ts                  // pack source -> atlas-mvp.{png,json}
│   ├── manifest.ts               // post-process Vite dist/manifest.json -> pipeline-manifest.json
│   ├── pack.ts                   // maxrects-packer wrapper; deterministic sort
│   └── types.ts                  // SpriteMeta, AtlasFrame, PipelineManifest
└── test/                         // determinism suite

assets/source/sprites/            // committed Aseprite-friendly source (D-15)
├── 0000-NaviStandD.{png,json}
├── 0023-Tile1.{png,json}
├── …
└── aseprite/                     // OPTIONAL .aseprite source files (planner discretion D-15)

apps/server/public/               // Vite build target (D-18; gitignored)
└── (created at build time)

scripts/
└── verify-phase-6.mjs            // composite gate (mirror verify-phase-5.mjs shape)

tools/scripts/
├── lint-asset-pipeline.mjs       // manifest schema validation + atlas integrity hash
└── lint-vite-env.mjs             // .env.staging/.env.prod carry required VITE_* keys, no secrets

docs/adr/
└── 0007-phase-6-protocol-amendments.md  // optional — locks D-07/D-08/D-09 contract for Phase 7
```

### Pattern 1: Phaser 3.90 game config — HiDPI nearest-neighbor + integer scale (D-12, CLI-06)

**What:** Game config that satisfies CLI-06 + D-12 in one shot.
**When to use:** Single source of truth in `src/main.ts`; never override scale settings elsewhere.
**Source:** `[CITED: docs.phaser.io/api-documentation/class/scale-scalemanager + Context7 /phaserjs/phaser]`

```ts
// src/main.ts
// [impl->REQ-CLI-01] [impl->REQ-CLI-06]
import Phaser from 'phaser';
import { BootScene } from './scenes/BootScene';
import { LoginScene } from './scenes/LoginScene';
import { GameScene } from './scenes/GameScene';

const game = new Phaser.Game({
  type: Phaser.AUTO,                    // WebGL preferred, Canvas fallback
  parent: 'game',                        // <div id="game"> in index.html
  pixelArt: true,                        // sets antialias=false + roundPixels=true
  roundPixels: true,                     // belt-and-suspenders
  scale: {
    mode: Phaser.Scale.FIT,              // D-12: aspect-ratio preserving fit-to-parent
    autoCenter: Phaser.Scale.CENTER_BOTH,
    width: 800,                          // internal render resolution (planner picks; matches mvp-lobby:1000x1000 minus margin OR a smaller pixel-art base)
    height: 600,
    autoRound: true,                     // D-12: round canvas dims to integer pixels (defeats fractional sub-pixel blur on retina)
    zoom: Phaser.Scale.MAX_ZOOM,         // largest integer multiplier that fits parent — KEY for crisp pixel art (Context7: skills/scale-and-responsive)
  },
  dom: {
    createContainer: true,               // D-01: enables Phaser.GameObjects.DOMElement
  },
  scene: [BootScene, LoginScene, GameScene],
  banner: false,                         // strip the Phaser banner from console
  // After atlas load, force NEAREST on every texture (D-12 belt-and-suspenders):
  // (done in BootScene.preload() complete handler)
});

// Re-snap to integer scale on resize (D-12)
window.addEventListener('resize', () => {
  game.scale.refresh();                  // re-runs FIT + autoRound + MAX_ZOOM math
});
```

`index.html` ships a CSS belt-and-suspenders for canvas:

```html
<style>
  #game canvas { image-rendering: pixelated; }
</style>
```

**Pitfall:** `Phaser.Scale.MAX_ZOOM` requires `autoCenter` to behave; without it, the canvas snaps to the parent corner. **Always pair them.**

### Pattern 2: Login DOM form via Phaser DOMElement (D-01, CLI-03)

**What:** Hybrid Phaser scene + DOM-overlay form pattern.
**When to use:** `LoginScene` only — keeps DOM lifecycle scoped to scene transitions.
**Source:** `[CITED: docs.phaser.io DOMElement + Phaser skills/game-setup]`

```ts
// src/scenes/LoginScene.ts
// [impl->REQ-CLI-03]
import Phaser from 'phaser';

export class LoginScene extends Phaser.Scene {
  constructor() { super('LoginScene'); }

  create(data: { fastPath?: boolean; username?: string }) {
    // Retro pixel-art backdrop (D-01)
    this.add.image(0, 0, 'login-bg').setOrigin(0, 0);

    if (data.fastPath) {
      // D-02: ~500 ms spinner with "Reconnecting as <username>..."
      this.add.text(400, 300, `Reconnecting as ${data.username}...`,
        { fontFamily: 'monospace', fontSize: 24 }).setOrigin(0.5);
      this.time.delayedCall(500, () => this.scene.start('GameScene'));
      this.input.keyboard?.once('keydown-ESC', () => {
        // User cancels fast-path — fall through to focused form
        this.scene.restart({ fastPath: false });
      });
      return;
    }

    // DOM form mounted via DOMElement (D-01)
    const form = this.add.dom(400, 300).createFromCache('login-form');
    // 'login-form' was preloaded as an HTML cache entry in BootScene:
    //   this.load.html('login-form', 'forms/login.html');
    form.addListener('click submit');
    form.on('submit', async (e: Event) => {
      e.preventDefault();
      const u = form.getChildByID('username') as HTMLInputElement;
      const p = form.getChildByID('password') as HTMLInputElement;
      try {
        await signInEmail(u.value, p.value);  // see Pattern 5
        this.scene.start('GameScene');
      } catch (err) {
        this.showError(form, err);
      }
    });

    // Focus the username field — Phaser's DOMElement does NOT auto-focus
    // (known issue: phaser.discourse.group/t/how-to-get-domelement-to-focus/8541)
    setTimeout(() => {
      (form.getChildByID('username') as HTMLInputElement).focus();
    }, 50);
  }

  private showError(form: Phaser.GameObjects.DOMElement, err: unknown) {
    const e = form.getChildByID('error') as HTMLDivElement;
    if (err instanceof ProtocolMismatchError) {
      e.textContent = `Server expects protocol v${err.expected}; client sends v${err.got}. Reload the page.`;
    } else if (err instanceof ServerDrainingError) {
      e.textContent = 'Server is restarting. Try again in 30 seconds.';
    } else {
      e.textContent = 'Wrong username or password.';
    }
  }
}
```

`forms/login.html` (loaded as `this.load.html('login-form', 'forms/login.html')` in BootScene):

```html
<form>
  <label>Username <input id="username" type="text" autocomplete="username" required></label>
  <label>Password <input id="password" type="password" autocomplete="current-password" required></label>
  <button type="submit">Log in</button>
  <div id="error" role="alert"></div>
</form>
```

**Pitfalls (verified from forum + Context7):**
- **DOMElement does not autofocus.** Use `setTimeout` after the create-from-cache cycle finishes (Phaser inserts the DOM nodes asynchronously).
- **Phaser 4 `DOMElement` REQUIRES a parent container.** Phaser 3.90 does not — but if Phase 7 retro flips to Phaser 4, this code needs a `setContainer(this.add.container(...))` retrofit. Document the migration delta in 06-PLAN comments.
- **Browser autofill works** because the inputs have proper `autocomplete` attributes — DON'T strip them in styling.

### Pattern 3: Colyseus 0.17 client SDK — connect, handshake, state events (CLI-02, CLI-04)

**What:** Idiomatic `@colyseus/sdk` 0.17 connection + state-diff binding.
**When to use:** `src/net/ColyseusClient.ts` — single instance per `GameScene`.
**Source:** `[VERIFIED: Context7 /colyseus/docs + soak-staging.mjs:78 + apps/server/src/RebnoRoom.js:114-180]`

```ts
// src/net/ColyseusClient.ts
// [impl->REQ-CLI-02] [impl->REQ-CLI-04] [impl->REQ-CLI-09]
import { Client, Room } from '@colyseus/sdk';
import { PROTOCOL_VERSION, type RoomState } from '@rebno/protocol';

const RECONNECT_TOKEN_KEY = 'rebno.reconnectionToken';

export async function joinRebnoRoom(opts: {
  wssUrl: string;        // import.meta.env.VITE_WSS_URL
  sessionToken: string;  // from Better-Auth
  invite?: string;       // import.meta.env.VITE_STAGING_MODE === '1' ? token : undefined
}): Promise<Room<RoomState>> {
  const client = new Client(opts.wssUrl);  // wss://staging.rebno.decidel.com (D-18: same domain)

  // Try resume first (CLI-09 + D-06 grace path)
  const cachedToken = sessionStorage.getItem(RECONNECT_TOKEN_KEY);
  if (cachedToken) {
    try {
      const resumed = await client.reconnect(cachedToken);
      bindHandlers(resumed);
      return resumed as Room<RoomState>;
    } catch {
      sessionStorage.removeItem(RECONNECT_TOKEN_KEY);
    }
  }

  // Fresh join (D-06 + D-07: new session = home portal spawn server-side)
  const room = await client.joinOrCreate<RoomState>('rebno', {
    type: 'auth',                              // matches cAuthSchema in @rebno/protocol
    protocol_version: PROTOCOL_VERSION,        // bumped from 1 to 2 in this phase (D-08)
    session_token: opts.sessionToken,          // Bearer for onAuth's getSession
    invite: opts.invite,                       // staging-only; ignored on prod
  });

  // Cache for next reconnect attempt within grace
  sessionStorage.setItem(RECONNECT_TOKEN_KEY, room.reconnectionToken);
  bindHandlers(room as Room<RoomState>);
  return room as Room<RoomState>;
}

function bindHandlers(room: Room<RoomState>) {
  // State-diff: PlayerState additions / removals / mutations
  room.state.players.onAdd((player, sessionId) => {
    if (sessionId === room.sessionId) {
      // Local player — initialise PredictionEngine baseline
      predictionEngine.initLocal(player.x, player.y, player.last_input_seq);
    } else {
      // Remote player — register for snapshot interp + extrapolation
      remoteExtrapolator.register(sessionId, player);
    }
  });
  room.state.players.onRemove((_p, sessionId) => {
    if (sessionId === room.sessionId) return;  // local handled by onLeave
    remoteExtrapolator.unregister(sessionId);
  });
  room.state.players.onChange((player, sessionId) => {
    if (sessionId === room.sessionId) {
      // Local — feed reconciler
      reconcileEngine.onServerSnapshot({
        x: player.x, y: player.y,
        last_input_seq: player.last_input_seq,
      });
    } else {
      // Remote — feed snapshot buffer (D-11)
      remoteExtrapolator.onSnapshot(sessionId, {
        x: player.x, y: player.y, vx: player.vx, vy: player.vy,
        input_axes: { x: player.input_axes_x, y: player.input_axes_y },  // D-08 new fields
        ts: performance.now(),
      });
    }
  });

  // msgpackr-encoded one-shot s2c.* events (chat_msg, room_layout, etc.)
  room.onMessage('s2c', (raw: Uint8Array) => {
    const evt = decodeS2C(raw);  // from @rebno/protocol
    switch (evt.type) {
      case 'chat_msg':              chatHud.append(evt); break;
      case 'room_layout':           handleRoomLayout(evt); break;
      case 'force_password_change': forceResetOverlay.show(evt); break;
      case 'error':                 handleServerError(evt); break;
      case 'input_ack':             /* unused — last_input_seq comes via state-diff */ break;
    }
  });

  // Reconnection lifecycle (CLI-09; @colyseus/sdk 0.17 API)
  room.onDrop?.((code, reason) => {
    reconnectBanner.show('Reconnecting...');
  });
  room.onReconnect?.(() => {
    reconnectBanner.hide();
  });
  room.onLeave((code) => {
    if (code !== 1000) {                       // 1000 = clean close
      // grace window expired — fall back to silent re-auth from cookie
      reconnectBanner.show('Reconnecting...');
      sessionStorage.removeItem(RECONNECT_TOKEN_KEY);
      retrySilentReauth();
    }
  });
  room.onError((code, msg) => {
    log.error({ code, msg }, 'colyseus_error');
    if (code === 4400 && /PROTOCOL_VERSION_MISMATCH/.test(msg)) {
      showFatalError('Server upgraded — please reload.');
    }
  });
}
```

**Pitfalls:**
- **`@colyseus/sdk` 0.17 reconnection API** (per Context7 `/colyseus/docs`):
  - `room.reconnectionToken` — opaque string cached in sessionStorage; survives page reload.
  - `client.reconnect(token)` — fresh `Client` instance, then resume; rejects if grace expired.
  - `room.onDrop` / `room.onReconnect` — automatic-reconnection lifecycle. Configurable via `room.reconnection.{maxRetries, maxDelay, minUptime}`.
- **Reconnect with stale token = exception**, not silent failure. Always wrap in try/catch and clear cache on fail.
- **`room.send(type, payload)` is binary** when `payload` is `Uint8Array`. Phase 6 `c2s.input` uses **typed channels** (`room.send('input', { ... })`) per `apps/server/src/onMessageHandlers.ts:92` — server has separate handlers per type. **Do NOT consolidate to a single `c2s` channel** — Phase 4 deprecated that path (`onMessageHandlers.ts:241`).
- **`onAdd` fires for ALL existing players when joining**, including pre-existing ones — same code path as new joins. Idempotency-safe registration logic required.

### Pattern 4: Client-side prediction + reconciliation (CLI-04 + D-10)

**What:** Local-input prediction with input ack ring buffer + threshold-gated reconciliation.
**When to use:** `src/prediction/PredictionEngine.ts` + `ReconcileEngine.ts`. Wraps `step()` from `@rebno/game-logic` — purity contract preserved.
**Source:** `[CITED: gabrielgambetta.com/client-side-prediction-server-reconciliation.html + Overwatch GDC netcode (gamereplays.org/overwatch netcode guide) + webgamedev.com/backend/prediction-reconciliation]`

```ts
// src/prediction/PredictionEngine.ts
// [impl->REQ-CLI-04]
import { step, type WorldState, type InputFrame, type PlayerSim } from '@rebno/game-logic';

const TICK_MS = 50;                      // 20 Hz, matches server SRV-05
const MAX_UNACKED_INPUTS = 256;          // ring buffer depth — at 20 Hz that's ~12.8s of inputs

interface UnackedInput {
  seq: number;
  axes: { x: -1|0|1; y: -1|0|1 };
  buttons_down: number;
  buttons_up: number;
  monotonic_at_ms: number;
}

export class PredictionEngine {
  private nextSeq = 1;
  private unacked: UnackedInput[] = [];   // ring buffer; trimmed by ack
  private localState: PlayerSim;          // mirror of server's PlayerState for our account
  private lastSeqAcked = 0;

  initLocal(x: number, y: number, lastInputSeq: number) {
    this.localState = { x, y, vx: 0, vy: 0, last_input_seq: lastInputSeq, sprite_id: 0, room_id: 0 };
    this.lastSeqAcked = lastInputSeq;
    this.nextSeq = lastInputSeq + 1;
  }

  /** Called from inputDispatcher on keydown/keyup transition (D-09) */
  enqueueInput(axes: {x: -1|0|1; y: -1|0|1}, buttons_down = 0, buttons_up = 0): UnackedInput {
    const input: UnackedInput = {
      seq: this.nextSeq++,
      axes, buttons_down, buttons_up,
      monotonic_at_ms: performance.now(),
    };
    this.unacked.push(input);
    if (this.unacked.length > MAX_UNACKED_INPUTS) this.unacked.shift();  // ring overflow guard
    return input;
  }

  /** Called from GameScene.update — runs prediction tick on local state */
  predictTick(dt_ms: number, roomLayout: RoomLayout): PlayerSim {
    // Pull the most recent input intent (D-09: server holds latest axis vector)
    const latest = this.unacked.at(-1);
    const inputs = new Map<string, InputFrame>();
    if (latest) {
      inputs.set('local', {
        seq: latest.seq,
        axis_x: latest.axes.x, axis_y: latest.axes.y,
        jump: !!(latest.buttons_down & 0x1),
        action_btns: latest.buttons_down,
      });
    }
    const prevState: WorldState = {
      rev: 0, rng_state: 0n,
      players: new Map([['local', this.localState]]),
      platforms: new Map(),
      room_layout: roomLayout,
    };
    const next = step(prevState, inputs, dt_ms);
    this.localState = next.players.get('local')!;
    return this.localState;
  }
}
```

```ts
// src/prediction/ReconcileEngine.ts
// [impl->REQ-CLI-04]
const DIVERGENCE_THRESHOLD_PX = 22;     // D-10 — half a 44x40 tile; planner locks final number
const LERP_DURATION_MS = 100;

export class ReconcileEngine {
  constructor(private prediction: PredictionEngine, private sprite: Phaser.GameObjects.Sprite) {}

  onServerSnapshot(snap: { x: number; y: number; last_input_seq: number }) {
    // Drop unacked inputs that the server has now seen
    this.prediction['unacked'] = this.prediction['unacked'].filter(i => i.seq > snap.last_input_seq);
    this.prediction['lastSeqAcked'] = snap.last_input_seq;

    const dx = snap.x - this.sprite.x;
    const dy = snap.y - this.sprite.y;
    const dist = Math.sqrt(dx*dx + dy*dy);

    if (dist >= DIVERGENCE_THRESHOLD_PX) {
      // Hard snap (D-10)
      this.sprite.setPosition(snap.x, snap.y);
      this.prediction['localState'].x = snap.x;
      this.prediction['localState'].y = snap.y;
      // Replay unacked inputs from server's snapshot forward (Gambetta replay)
      this.prediction.replayUnackedFromState(snap);
    } else {
      // Smooth lerp over LERP_DURATION_MS (D-10)
      this.sprite.scene.tweens.add({
        targets: this.sprite,
        x: snap.x, y: snap.y,
        duration: LERP_DURATION_MS,
        ease: 'Linear',
      });
      // Local prediction continues uninterrupted; the lerp closes the visible delta
    }
  }
}
```

**Pitfalls (verified from references):**
- **Re-replay unacked inputs from the server snapshot forward** on hard snap. If you don't replay, the player loses 100-200 ms of input on every snap (Gambetta §"Server reconciliation"). Replay is `for (const input of unacked) localState = step(localState, input)`.
- **Never mutate `localState.last_input_seq` from server** — server is authoritative on what it has seen, but the prediction's "what the player has done" pointer is monotonically `nextSeq - 1`.
- **`Math.sqrt` is fine** for the threshold check on Chrome V8 — no determinism worry because the result is consumed only for a UI branch, not fed back into `step()`.
- **Threshold tuning**: Phase 5 30-min staging soak telemetry isn't yet captured. Plan 06 should add a 1-line "log divergence histogram to console" path so the operator can pick the threshold from a real session before the merge gate locks in. Default 22 px is sound (≈ half-tile for 44 × 40 tiles).

### Pattern 5: Better-Auth client SDK — same-origin cookie + bearer for Colyseus (CLI-02, CLI-03)

**What:** Calls into `/api/auth/*` from the browser. Same-origin (D-18) means cookies just work; no CORS dance.
**When to use:** `src/auth/client.ts`.
**Source:** `[CITED: better-auth.com docs + Context7 /better-auth/better-auth + apps/server/src/index.ts:128-179 sign-in pre-middleware]`

```ts
// src/auth/client.ts
// [impl->REQ-CLI-02] [impl->REQ-CLI-03]
import { createAuthClient } from 'better-auth/client';

export const authClient = createAuthClient({
  baseURL: '/api/auth',                 // SAME-ORIGIN per D-18 — Vite-built bundle is served by Express
  fetchOptions: {
    credentials: 'include',             // send/receive cookies (same-origin works without; future-proof)
  },
});

/**
 * Phase 4 D-08 legacy migration is server-side: the email/username field accepts
 * either; the pre-middleware in apps/server/src/index.ts rewrites bare usernames
 * to legacy emails. Callers send EITHER username OR email here.
 */
export async function signInEmail(usernameOrEmail: string, password: string): Promise<{ session_token: string; must_force_reset: boolean; }> {
  const { data, error } = await authClient.signIn.email({
    email: usernameOrEmail,             // server middleware handles bare-username case
    password,
  });
  if (error) {
    throw new BadCredentialsError(error.message);
  }
  // Better-Auth returns a session object; extract the bearer token for Colyseus
  return {
    session_token: data.session.token,  // bearer to send into joinOrCreate options
    must_force_reset: !!data.user.forceReset,
  };
}

export async function getSession(): Promise<{ user: { id: string; username: string; forceReset: boolean } } | null> {
  const { data } = await authClient.getSession();
  return data ?? null;
}

export async function signOut(): Promise<void> {
  await authClient.signOut();
  sessionStorage.removeItem('rebno.reconnectionToken');
}

export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
  const { error } = await authClient.changePassword({ currentPassword, newPassword });
  if (error) throw new ChangePasswordError(error.message);
}
```

**Pitfalls (verified):**
- **Phase 4 D-05 says bearer; Better-Auth client emits BOTH cookie + bearer.** Resolution: same-origin (D-18) makes cookies work for `/api/auth/*` HTTP. The bearer token (`data.session.token`) is what goes into `joinOrCreate({ session_token })` for Colyseus `onAuth` (matches `apps/server/src/RebnoRoom.js:165` `auth.api.getSession({ headers: new Headers({ Authorization: \`Bearer ${session_token}\` }) })`). Use both: cookie for HTTP, bearer for WSS.
- **`SameSite=Strict` cookies**: same-Fly-app means same-origin, so Strict is fine for everything. No cross-origin path exists in MVP.
- **`POST /api/auth/sign-in/email` ALSO accepts bare username** because of `apps/server/src/index.ts:128-179` legacy middleware — pass `email: '<username>'` and the server rewrites to the synthetic legacy email. Don't add a separate username route.
- **`PROTOCOL_VERSION_MISMATCH` is a Colyseus 4400 error**, not a `/api/auth/*` error. It surfaces via `room.onError((code, msg) => …)` after `joinOrCreate`. Login can succeed and Colyseus join can still fail; LoginScene must surface both classes inline.
- **`SERVER_DRAINING`** is per Phase 4 D-16 — emitted as `s2c.error{code: 'SERVER_DRAINING', reconnect_after_ms}` during SIGTERM. Wire path: `room.onMessage('s2c', …)` decodes msgpackr, dispatches to error handler.

### Pattern 6: Vite 8 multi-mode build — manifest + env modes (CLI-01, CLI-07, D-16, D-18)

**What:** Per-env Vite bundles via `--mode staging|production`; `manifest: true` for content-hashed asset paths; `outDir` resolves to `apps/server/public/` (D-18).
**When to use:** `apps/client/vite.config.ts`. Build commands: `pnpm --filter @rebno/client build:staging` / `:prod`.
**Source:** `[VERIFIED: Context7 /vitejs/vite docs build.manifest, env-and-mode + apps/server/Dockerfile]`

```ts
// apps/client/vite.config.ts
// [impl->REQ-CLI-01] [impl->REQ-CLI-07]
import { defineConfig, loadEnv } from 'vite';
import path from 'node:path';

export default defineConfig(({ mode }) => {
  // Loads .env, .env.[mode], .env.local — only VITE_* vars exposed to client
  const env = loadEnv(mode, process.cwd(), 'VITE_');

  // Drift guard at config time: required env vars must be present per mode
  for (const k of ['VITE_WSS_URL', 'VITE_HTTP_BASE', 'VITE_ROOM_SIGNING_PUBKEY']) {
    if (!env[k]) throw new Error(`vite-env: missing ${k} in .env.${mode}`);
  }

  return {
    root: path.resolve(__dirname),
    publicDir: 'public',
    base: '/',                                    // served from root by Express (D-18)
    build: {
      outDir: path.resolve(__dirname, '../../apps/server/public'),  // D-18 — static-mount target
      emptyOutDir: true,
      manifest: true,                             // emits .vite/manifest.json — D-16 input
      sourcemap: mode === 'staging',              // staging keeps maps; prod hides them
      target: 'es2022',
      rollupOptions: {
        output: {
          entryFileNames: 'assets/[name]-[hash].js',
          chunkFileNames: 'assets/[name]-[hash].js',
          assetFileNames: 'assets/[name]-[hash][extname]',
        },
      },
    },
    server: {                                     // dev server (pnpm dev)
      port: 5173,
      proxy: {                                    // proxy /api/* + /matchmake/* + /colyseus to local server
        '/api': 'http://localhost:2567',
        '/matchmake': 'http://localhost:2567',
        '/colyseus': { target: 'ws://localhost:2567', ws: true },
        '/health': 'http://localhost:2567',
      },
    },
    define: {
      // Inline at build time — DOES NOT replace import.meta.env behavior; for explicit constants
      __PHASE_6_BUILD__: JSON.stringify(true),
    },
  };
});
```

`apps/client/package.json` scripts:

```json
{
  "scripts": {
    "dev":            "vite",
    "build:staging":  "vite build --mode staging",
    "build:prod":     "vite build --mode production",
    "preview":        "vite preview",
    "test":           "vitest run",
    "test:e2e":       "playwright test"
  }
}
```

`.env.staging`:

```ini
VITE_WSS_URL=wss://staging.rebno.decidel.com
VITE_HTTP_BASE=https://staging.rebno.decidel.com
VITE_ROOM_SIGNING_PUBKEY=VBSr0aj+rbf2vVkXax6vCvWACSv+dmsKdy0g0DsSfQM=
VITE_STAGING_MODE=1
# STAGING_INVITE_TOKEN: NOT here — supplied via URL `?invite=<token>` at first visit (Phase 5 D-04 + Pattern 12 below)
```

`.env.prod`:

```ini
VITE_WSS_URL=wss://rebno.decidel.com
VITE_HTTP_BASE=https://rebno.decidel.com
VITE_ROOM_SIGNING_PUBKEY=<extracted from rebno-prod via fly ssh — Phase 5 D-19 ritual>
# VITE_STAGING_MODE absent — staging-invite gate is server-side no-op on prod
```

**Pitfalls (verified):**
- **Only `VITE_*` vars are inlined.** `process.env.SECRET_TOKEN` won't be replaced — it'll be undefined at runtime (intended). Use this property: NEVER name a secret `VITE_SECRET_*`.
- **`build.manifest: true` writes `.vite/manifest.json`**, not `manifest.json`. Lint must look in `dist/.vite/manifest.json` — but since Phase 6 outDir is `apps/server/public/`, the manifest lands at `apps/server/public/.vite/manifest.json`. **Express must NOT serve this file** — add `.vite/` to the static middleware ignore list, or Vite's manifest leaks server-side asset names.
- **`emptyOutDir: true` deletes the static dir** before each build. Express must NOT have hot-reload state in `apps/server/public/`. Verify in CI that `/data/`, `/keys/`, etc. live OUTSIDE `public/`.
- **Hash collision is not a real concern** at MVP scope (default rollup uses 8-char hex hash from content), but the `pipeline-manifest.json` post-process (D-16) MUST sort manifest keys for determinism — Vite's manifest is unordered.

### Pattern 7: Asset pipeline — bootstrap + build + hybrid manifest (AST-01, CLI-07, D-13..D-17)

**What:** Standalone Node CLI; bootstrap mode decodes BMP via sharp, build mode packs PNG via maxrects-packer, manifest post-process stitches Vite hashes.
**When to use:** `tools/asset-pipeline/cli.ts`. Commands: `pnpm asset-pipeline bootstrap | build | verify`.
**Source:** `[CITED: sharp.pixelplumbing.com docs + npm view sharp@0.34.5 + Phase 1 plan 01-05 deterministic PNG settings + maxrects-packer README]`

**Bootstrap mode** — runs once per BMP set:

```ts
// tools/asset-pipeline/src/bootstrap.ts
// [impl->REQ-AST-01]
import sharp from 'sharp';
import { promises as fs } from 'node:fs';
import path from 'node:path';

const PNG_OPTS = {                       // Phase 1 plan 01-05 deterministic config
  compressionLevel: 9 as const,
  palette: false,
  effort: 10,
  progressive: false,
  adaptiveFiltering: false,
};

export async function bootstrap(extractedRoot: string, sourceRoot: string) {
  // MVP scope (D-17) — exact sprite IDs from extracted/client-5-8/sprites/ listing
  const MVP_SPRITES = [
    '0000-NaviStandD',
    '0023-Tile1', '0024-TSide1', '0025-VBorder', '0026-HBorder',
    '0027-NaviRunD', '0028-NaviRunR', '0029-NaviStandR',
    '0030-NaviStandU', '0031-NaviRunU',
    '0032-NaviRunL', '0033-NaviStandL',  // verify need vs D-17 (D + U + R only); planner picks
  ];

  for (const id of MVP_SPRITES) {
    const dir = path.join(extractedRoot, 'sprites', id);
    const meta = JSON.parse(await fs.readFile(path.join(dir, 'meta.json'), 'utf8'));
    const frameFiles = (await fs.readdir(path.join(dir, 'frames')))
      .filter(f => /^img_\d+\.bmp$/.test(f))
      .sort();                            // determinism: lexical sort

    // Decode each BMP, place horizontally
    const pngs: Buffer[] = [];
    for (const f of frameFiles) {
      const buf = await fs.readFile(path.join(dir, 'frames', f));
      const decoded = await sharp(buf).png(PNG_OPTS).toBuffer();
      pngs.push(decoded);
    }
    // Composite horizontally into one strip (Aseprite-canonical export)
    const canvas = sharp({
      create: {
        width: meta.width * pngs.length,
        height: meta.height,
        channels: 4,
        background: { r: 0, g: 0, b: 0, alpha: 0 },
      },
    });
    const composited = await canvas.composite(
      pngs.map((png, i) => ({ input: png, left: i * meta.width, top: 0 })),
    ).png(PNG_OPTS).toBuffer();

    await fs.mkdir(path.join(sourceRoot, 'sprites'), { recursive: true });
    await fs.writeFile(path.join(sourceRoot, 'sprites', `${id}.png`), composited);
    await fs.writeFile(path.join(sourceRoot, 'sprites', `${id}.json`),
      JSON.stringify({                    // Aseprite-friendly JSON sidecar (D-15)
        sprite_id: id,
        frame_w: meta.width,
        frame_h: meta.height,
        frame_count: pngs.length,
        origin: { x: meta.originX, y: meta.originY },
      }, null, 2) + '\n',
    );
  }
}
```

**Build mode** — pack source PNGs into single atlas:

```ts
// tools/asset-pipeline/src/build.ts
// [impl->REQ-AST-01]
import sharp from 'sharp';
import { MaxRectsPacker } from 'maxrects-packer';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';

interface Frame { sprite_id: string; frame_idx: number; w: number; h: number; png: Buffer; }

export async function build(sourceRoot: string, outRoot: string) {
  // 1. Slice each sprite strip back into individual frames
  const frames: Frame[] = [];
  const sidecars: Record<string, any> = {};
  const pngs = (await fs.readdir(path.join(sourceRoot, 'sprites')))
    .filter(f => f.endsWith('.png')).sort();   // determinism: lex sort
  for (const f of pngs) {
    const id = f.replace(/\.png$/, '');
    const sidecar = JSON.parse(await fs.readFile(path.join(sourceRoot, 'sprites', `${id}.json`), 'utf8'));
    sidecars[id] = sidecar;
    const stripBuf = await fs.readFile(path.join(sourceRoot, 'sprites', f));
    for (let i = 0; i < sidecar.frame_count; i++) {
      const png = await sharp(stripBuf)
        .extract({ left: i * sidecar.frame_w, top: 0, width: sidecar.frame_w, height: sidecar.frame_h })
        .png({ compressionLevel: 9, palette: false, effort: 10, progressive: false, adaptiveFiltering: false })
        .toBuffer();
      frames.push({ sprite_id: id, frame_idx: i, w: sidecar.frame_w, h: sidecar.frame_h, png });
    }
  }

  // 2. Pack via maxrects-packer (deterministic given sorted input)
  const packer = new MaxRectsPacker(512, 512, 0, { smart: true, pot: false, square: false });
  packer.addArray(frames.map(f => ({ width: f.w, height: f.h, data: f })));
  if (packer.bins.length !== 1) {
    // Fallback to 1024x1024 (D-17)
    const big = new MaxRectsPacker(1024, 1024, 0, { smart: true });
    big.addArray(frames.map(f => ({ width: f.w, height: f.h, data: f })));
    if (big.bins.length !== 1) throw new Error('atlas overflow > 1024x1024');
    packer.bins = big.bins;
  }
  const bin = packer.bins[0];

  // 3. Composite into a single atlas PNG
  const atlas = await sharp({
    create: { width: bin.width, height: bin.height, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
  }).composite(
    bin.rects.map(r => ({ input: (r.data as Frame).png, left: r.x, top: r.y })),
  ).png({ compressionLevel: 9, palette: false, effort: 10, progressive: false, adaptiveFiltering: false }).toBuffer();

  await fs.mkdir(outRoot, { recursive: true });
  await fs.writeFile(path.join(outRoot, 'atlas-mvp.png'), atlas);

  // 4. Emit Phaser-format atlas JSON (Phaser.Loader.FileTypes.AtlasFile)
  const atlasJson = {
    frames: Object.fromEntries(
      bin.rects.map(r => {
        const f = r.data as Frame;
        return [`${f.sprite_id}_${String(f.frame_idx).padStart(3, '0')}`, {
          frame: { x: r.x, y: r.y, w: r.width, h: r.height },
          rotated: false, trimmed: false,
          spriteSourceSize: { x: 0, y: 0, w: r.width, h: r.height },
          sourceSize: { w: r.width, h: r.height },
        }];
      }).sort(([a], [b]) => a.localeCompare(b)),       // determinism: sort frame keys
    ),
    meta: { app: 'rebno-asset-pipeline', version: '1.0', image: 'atlas-mvp.png',
            format: 'RGBA8888', size: { w: bin.width, h: bin.height }, scale: '1' },
  };
  await fs.writeFile(path.join(outRoot, 'atlas-mvp.json'),
    JSON.stringify(atlasJson, null, 2).replace(/\r\n/g, '\n') + '\n');

  // 5. Emit pipeline-manifest.json (richer than Vite's; sorted keys for determinism — D-16)
  const manifest = {
    schema_version: 1,
    sprites: Object.fromEntries(
      Object.entries(sidecars).sort(([a], [b]) => a.localeCompare(b))
        .map(([id, s]) => [id, {
          ...s,
          atlas_ref: 'atlas-mvp',
          sha256: crypto.createHash('sha256').update(
            frames.filter(f => f.sprite_id === id).map(f => f.png).reduce((acc, b) => Buffer.concat([acc, b]), Buffer.alloc(0)),
          ).digest('hex'),
        }]),
    ),
    atlases: { 'atlas-mvp': { json: 'atlas-mvp.json', png: 'atlas-mvp.png',
                              sha256_png: crypto.createHash('sha256').update(atlas).digest('hex') } },
  };
  await fs.writeFile(path.join(outRoot, 'pipeline-manifest.json'),
    JSON.stringify(manifest, null, 2).replace(/\r\n/g, '\n') + '\n');
}
```

**Manifest post-process** (after `vite build` runs):

```ts
// tools/asset-pipeline/src/manifest.ts
// [impl->REQ-AST-01]
// Reads apps/server/public/.vite/manifest.json (Vite hashes), reads
// tools/asset-pipeline/output/pipeline-manifest.json (sprite metadata),
// joins them, writes apps/server/public/pipeline-manifest.json with
// FINAL hashed paths the client fetches at runtime.
export async function postProcessManifest(viteManifestPath: string, pipelineManifestPath: string, outPath: string) {
  const vite = JSON.parse(await fs.readFile(viteManifestPath, 'utf8'));
  const pipeline = JSON.parse(await fs.readFile(pipelineManifestPath, 'utf8'));
  // Merge: for each atlas, swap unhashed filename for the Vite-hashed one
  for (const atlasName of Object.keys(pipeline.atlases)) {
    const a = pipeline.atlases[atlasName];
    const hashedJson = vite[`assets/${a.json}`]?.file ?? a.json;
    const hashedPng = vite[`assets/${a.png}`]?.file ?? a.png;
    pipeline.atlases[atlasName] = { ...a, json: hashedJson, png: hashedPng };
  }
  await fs.writeFile(outPath, JSON.stringify(pipeline, null, 2) + '\n');
}
```

Client consumes at boot:

```ts
// src/scenes/BootScene.ts
async preload() {
  const m = await fetch('/pipeline-manifest.json').then(r => r.json());
  this.load.atlas('mvp', m.atlases['atlas-mvp'].png, m.atlases['atlas-mvp'].json);
  this.load.html('login-form', 'forms/login.html');
}

create() {
  // Force NEAREST on every loaded texture (D-12 belt-and-suspenders)
  this.textures.list['mvp'].setFilter(Phaser.Textures.FilterMode.NEAREST);
  // Hand off
  fetch('/api/auth/me').then(/* D-02 fast-path */);
}
```

**Pitfalls (verified):**
- **Sharp PNG settings must EXACTLY match Phase 1 plan 01-05** for round-trippable hashes. Document the pin in the tool's README.
- **`maxrects-packer` is deterministic given sorted input.** Always sort sprite IDs lexically before `addArray`.
- **`pot: false`** (power-of-two: false) saves atlas-PNG bytes; Phaser does not require POT atlases. Lock this; `pot: true` would bloat the atlas to 1024 × 1024 unnecessarily.
- **JSON-array vs JSON-hash format.** Phaser supports BOTH `Phaser.Loader.FileTypes.AtlasFile` (hash, recommended) and `Phaser.Loader.FileTypes.MultiAtlasFile` (array). **Use hash format** — frame names are explicit and easy to look up via `sprite.setFrame(name)`.
- **Atlas overflow at 512 × 512** is a real risk if Aseprite users edit sprites larger than the originals. Fallback to 1024 × 1024 with hard-fail beyond. Lint: `lint-asset-pipeline.mjs` rejects atlases > 1024 × 1024 at build time.

### Pattern 8: Reconnect UX state machine (CLI-09, D-06)

**What:** Top-banner state machine driven by Colyseus reconnect events + grace timer.
**When to use:** `src/ui/ReconnectBanner.ts` + `src/net/reconnect.ts`.
**Source:** `[CITED: Context7 /colyseus/docs Connection Lifecycle Events]`

```ts
// src/ui/ReconnectBanner.ts
// [impl->REQ-CLI-09]
type State = 'idle' | 'reconnecting' | 'silent_reauth' | 'disconnected';

export class ReconnectBanner {
  private state: State = 'idle';
  private el: HTMLDivElement;
  private retryTimer?: number;

  constructor(parent: HTMLElement) {
    this.el = document.createElement('div');
    this.el.className = 'reconnect-banner hidden';
    parent.appendChild(this.el);
  }

  show(label: string, state: State = 'reconnecting') {
    this.state = state;
    this.el.textContent = label;
    this.el.className = `reconnect-banner ${state}`;
  }

  hide() { this.state = 'idle'; this.el.className = 'reconnect-banner hidden'; }

  /** D-06: world-mutating actions blocked during disconnect — guard at intent layer */
  isWorldMutationAllowed(): boolean {
    return this.state === 'idle';
  }
}
```

`world-mutating action blocker design at the intent layer` (Phase 7 forward-compat):

```ts
// src/net/inputDispatcher.ts
// [impl->REQ-CLI-09]
function sendWorldMutatingIntent(intent: WorldMutatingIntent) {
  if (!reconnectBanner.isWorldMutationAllowed()) {
    log.warn('blocked_during_reconnect', intent);
    return;
  }
  room.send(intent.type, intent.payload);
}
// Movement (c2s.input) and chat are NOT world-mutating in MVP — they continue.
// Phase 7 PAR-04 (item pickup, message-board posts) goes through the gate.
```

### Anti-Patterns to Avoid

- **Trusting client position** — never write a wire payload to `state.players[*].x` without validation. Already structurally enforced by zod `.strict()` (Phase 4 D-04, `cInputSchema` excludes `x`/`y`).
- **Manual room layout drawing** in client code (Pitfall C6). Always consume the signed `s2c.room_layout` wire frame; verify against pinned `VITE_ROOM_SIGNING_PUBKEY`.
- **Custom WS framing on top of Colyseus** (Pitfall B3). Colyseus already preserves message boundaries.
- **Lerp on local player movement** (Pitfall B10). The local player snaps to predicted position; only REMOTE players interpolate.
- **`setInterval` tick loops in browser code.** Use `Phaser.Scene.update(time, delta)` — Phaser already handles fixed-timestep correctly.
- **Sending `c2s.input` per server tick.** D-09 forbids this — event-driven on keydown/keyup transitions only, plus 15 s heartbeat reaffirmation.
- **Phaser 4 imports.** ADR 0001 locks Phaser 3.90; Phaser 4 retro is Phase 7 territory. Check `import 'phaser'` resolves to 3.90.0.
- **Using `colyseus.js@0.16.x`.** Server is 0.17.10; client must be `@colyseus/sdk@0.17.42`.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Atlas bin packing | Custom MaxRects | `maxrects-packer@2.7.3` | 6 KB MIT package; pure JS; deterministic given sorted input; battle-tested in `free-tex-packer` ecosystem |
| BMP → PNG decode | `node-bmp` / hand-rolled | `sharp@0.34.5` (Phase 1 pin) | libvips backbone; lossless; deterministic per-platform; already proven in Phase 1 |
| Auth flow | Manual cookie + bcrypt + session table | `better-auth@1.6.9` (server) + `better-auth/client` (browser) | Phase 4 already locked. Lucia is deprecated. |
| Game state delta encoding | JSON-over-WS or custom binary | `@colyseus/schema@4.0.23` | Already on the wire; client just decodes via `state.onChange` reactive callbacks |
| One-shot event encode | JSON | `msgpackr@1.11.10` (already used server-side) | 2-3× smaller than JSON; existing decoder in `packages/protocol/src/events.ts` |
| Reconnect state machine | Manual ws-reconnect with backoff | `@colyseus/sdk` 0.17 built-in `room.reconnection.{maxRetries, maxDelay}` + `client.reconnect(token)` | Battle-tested; integrates with `allowReconnection` server-side |
| HiDPI scaling | Custom canvas DPR math | Phaser `Scale.FIT` + `autoRound: true` + `MAX_ZOOM` | One-config solution; Phaser handles `window.resize` and DPR change events |
| WebSocket framing | Length-prefixed framer over `ws` | Colyseus default `ws` transport | Already configured server-side; PITFALLS B3 confirms WS preserves boundaries |
| E2E browser harness | Puppeteer + custom fixtures | `@playwright/test@1.59.1` | Multi-context support; first-class `BrowserContext` isolation; baked-in workers |
| Ed25519 verify (browser) | `tweetnacl` etc. | Web Crypto `crypto.subtle.verify('Ed25519', ...)` | Native to all 2026 Chrome versions; zero-dep; same primitives as server's `node:crypto` |
| Static asset hashing | Custom rev-manifest | `vite build --manifest` + asset-pipeline post-process | Vite's manifest is the source of truth for hashed paths; asset-pipeline only ADDS sprite metadata |

**Key insight:** Phase 6 is all "thin glue between proven libraries." If a task has a hand-rolled-replacement urge, default to "find the library; the planner will surface a real gap during plan-check if the library doesn't fit."

## Common Pitfalls

### Pitfall 1: PROTOCOL_VERSION rollout race (D-08, B8)

**What goes wrong:** Bump `PROTOCOL_VERSION` from 1 to 2, deploy server, deploy client. For the deploy window, half the connected clients send v1 and reject; new clients connect to old server and reject.
**Why it happens:** Schema bumps require coordinated server-before-client deploy.
**How to avoid:** Phase 4 D-03 ritual MUST be followed:
  1. Bump `PROTOCOL_VERSION` in `packages/protocol/src/version.ts` from `1` to `2`.
  2. Add `input_axes` field to `PlayerState` (Colyseus `@type` decorator handles new optional fields under delta-sync — backward-compat ZERO; v1 client cannot decode v2 PlayerState).
  3. Update `c2s.input` zod schema to event-driven shape (D-09).
  4. Land server-side patches (D-07 home-portal spawn, D-08 broadcast `input_axes`, D-09 event-driven storage).
  5. **Push tag → deploy-prod.yml deploys to rebno-prod first.**
  6. **THEN** ship the Phase 6 client bundle (which expects v2).
  Alternative: ship the server with backward-compat acceptance of both schemas (hard — Colyseus state-diff doesn't support per-client schema versions). **Reject the alternative**; ritual is sound.
**Warning signs:** During the deploy window, Sentry/OpenObserve errors `PROTOCOL_VERSION_MISMATCH expected=2 got=1` from old clients. Acceptable for a few minutes; if it persists, the client wasn't deployed.

### Pitfall 2: `last_input_seq` rolling backwards on server reset (D-10)

**What goes wrong:** Server crashes mid-session, restarts, player rejoins via `allowReconnection`. Server's `last_input_seq` for this player resets to 0; client's `nextSeq` is at 482; client thinks every input is unacked and replays them all on first reconcile.
**Why it happens:** SRV-08 SIGTERM grace flushes positions but NOT input-sequence counters. Server starts fresh seq=0.
**How to avoid:** Client treats a server `last_input_seq` LESS than `lastSeqAcked` as "server reset" — clear unacked queue, set `nextSeq = serverLastSeq + 1`, accept the snapshot as ground truth (no replay). Document as known-issue: a single-tick worth of unacked input is silently dropped on server reset; user perception is "tiny rubber-band on reconnect."
**Warning signs:** Test by killing the server mid-session; client should NOT teleport backwards on rejoin.

### Pitfall 3: Phaser DOMElement focus + browser autofill (D-01)

**What goes wrong:** Login form mounts, autofill fires, but Phaser's input.keyboard captures keystrokes BEFORE the browser delivers them to the form input. User types "admin" and the WASD-equivalent keystrokes leak into a not-yet-mounted GameScene.
**Why it happens:** Phaser's keyboard plugin captures globally by default in some configs; `addCapture` lists hijack browser default.
**How to avoid:**
  - In LoginScene: `this.input.keyboard?.removeAllKeys()` before mounting the form.
  - In GameScene: install `addCapture('UP,DOWN,LEFT,RIGHT,W,A,S,D,SPACE')` ONLY when the chat overlay is closed. When chat opens (D-04), call `removeCapture('W,A,S,D,SPACE')` so keystrokes flow to the input field. When chat closes, re-add.
  - Always include `autocomplete="username"` and `autocomplete="current-password"` on the form inputs — browser autofill respects them.
**Warning signs:** Logging in works only when typed manually; autofill produces a visible keyboard-capture flash. UAT must verify autofill round-trip.

### Pitfall 4: Same-Fly-app static-mount route ordering (D-18)

**What goes wrong:** `app.use(express.static('public'))` mounted BEFORE `/api/auth/*` → POST to `/api/auth/sign-in/email` returns 404 because static didn't find a matching file but consumed the request.
**Why it happens:** Express static is non-greedy on file misses (calls `next()`), but the order of `app.use(express.json())` matters for body parsing.
**How to avoid:** Mount in this order (per existing `apps/server/src/index.ts`):
  1. `app.use(makeStagingInvite(process.env))` — staging invite gate
  2. `app.use(createNodeMatchmakingMiddleware())` — Colyseus matchmaking, BEFORE express.json
  3. CORS middleware (only if `ALLOWED_ORIGINS` set)
  4. **`app.post('/api/auth/sign-in/email', express.json(), <legacy-login>)` — legacy migration pre-middleware**
  5. **`app.all('/api/auth/*', toNodeHandler(auth))` — Better-Auth catchall**
  6. `app.use(express.json())`
  7. **NEW Phase 6: `app.get('/health', …)` — keep before static so /health stays JSON, not file lookup**
  8. **NEW Phase 6: `app.use(express.static(staticDir, { index: false, immutable: true, maxAge: '1y' }))` — for hashed assets**
  9. **NEW Phase 6: `app.get('/', (req, res) => res.sendFile(path.join(staticDir, 'index.html')))` with `Cache-Control: no-cache` for index.html**
  10. WS upgrade on `/colyseus` (Colyseus already mounted via `colyseus.listen`)
**Warning signs:** Login form posts return 404; or matchmake POST hangs (express.json drained the body — Pitfall 4 in Phase 4 already documented). Lint-vite-env can't catch this; integration test required.

### Pitfall 5: Atlas hot-reload + cache-bust (D-16, CLI-07)

**What goes wrong:** Asset-pipeline rebuilds atlas; pipeline-manifest.json updates with new hash; client (already loaded) is still using old atlas; sprite frame names stale.
**Why it happens:** Browsers cache aggressively for hashed assets (immutable maxAge); pipeline-manifest.json itself isn't hashed.
**How to avoid:**
  - **`pipeline-manifest.json` ships with `Cache-Control: no-cache, must-revalidate`** so browsers always re-fetch on page load.
  - Hashed assets (`atlas-mvp-<hash>.png`, `atlas-mvp-<hash>.json`) ship with `Cache-Control: public, max-age=31536000, immutable`.
  - At runtime, atlas re-load on a hash change requires a full page reload (Phase 6 doesn't do live atlas swap; OK for MVP).
**Warning signs:** Stale sprites on returning user. Lint: `lint-asset-pipeline.mjs` warns if `pipeline-manifest.json` is checked in to git (it shouldn't be — it's a build artifact).

### Pitfall 6: Playwright two-context WS sharing (D-19)

**What goes wrong:** Both `BrowserContext`s in the same Playwright test share the same WebSocket connection because Playwright reuses a Node WS pool.
**Why it happens:** Misunderstanding — `BrowserContext` IS isolated per Playwright docs (separate cookies, storage, network). Each context creates separate browser processes / network stacks.
**How to avoid:** Verify in the test by asserting that both contexts get DIFFERENT `room.sessionId`. The Phaser canvas inside each context has its own `@colyseus/sdk` Client.
**Source:** Context7 `/microsoft/playwright` confirms: "Each context maintains independent cookies, local storage, and session data, allowing simulation of multiple users."

### Pitfall 7: Phaser depth ordering for chat overlay (D-04, D-05)

**What goes wrong:** Chat overlay rendered as Phaser DOMElement appears BEHIND the canvas because Phaser DOM containers don't always front-render.
**Why it happens:** D-04 chat HUD is RECOMMENDED as **plain DOM div absolutely positioned over the canvas** — NOT a Phaser DOMElement. CONTEXT D-04 says: "Chat is a DOM div absolutely positioned over the Phaser canvas." Plain DOM, separate from Phaser.
**How to avoid:** Use plain `document.createElement('div')` for chat HUD, reconnect banner, force-reset overlay. ONLY use Phaser DOMElement for `LoginScene` (D-01) where it's structurally inside a Phaser scene.
**Warning signs:** Chat appears garbled or below sprites; CSS `z-index` stacking bug.

### Pitfall 8: Vite outDir collisions with server runtime files (D-18)

**What goes wrong:** Vite's `emptyOutDir: true` deletes `apps/server/public/` before each build; if Phase 7 ever stages run-time-written files into `public/` (uploaded avatars, etc.), they're wiped.
**Why it happens:** Single mount target tries to serve both build artifacts and runtime state.
**How to avoid:** Lock the convention: `apps/server/public/` is BUILD-OUTPUT-ONLY; runtime state goes to `/data/...` on the Fly Volume. Document in the same ADR as D-18.
**Warning signs:** Mysterious file disappearance after deploy.

## Code Examples

### Phaser scene wiring + atlas load + NEAREST filter

```ts
// src/scenes/BootScene.ts
// [impl->REQ-CLI-01] [impl->REQ-CLI-06] [impl->REQ-CLI-07]
import Phaser from 'phaser';
import { getSession } from '../auth/client';

export class BootScene extends Phaser.Scene {
  constructor() { super('BootScene'); }

  preload() {
    this.load.html('login-form', 'forms/login.html');
    this.load.json('pipeline-manifest', 'pipeline-manifest.json');
  }

  async create() {
    const m = this.cache.json.get('pipeline-manifest');
    const atlas = m.atlases['atlas-mvp'];
    this.load.atlas('mvp', atlas.png, atlas.json);
    this.load.image('login-bg', 'login-bg.png');
    this.load.once('complete', async () => {
      // D-12 belt-and-suspenders: force NEAREST on the atlas texture
      this.textures.get('mvp').setFilter(Phaser.Textures.FilterMode.NEAREST);
      // D-02 fast-path
      const session = await getSession().catch(() => null);
      if (session) {
        this.scene.start('LoginScene', { fastPath: true, username: session.user.username });
      } else {
        this.scene.start('LoginScene', { fastPath: false });
      }
    });
    this.load.start();
  }
}
```

### Event-driven c2s.input dispatcher (D-09)

```ts
// src/prediction/inputDispatcher.ts
// [impl->REQ-CLI-04]
import type { Room } from '@colyseus/sdk';

const HEARTBEAT_INTERVAL_MS = 15_000;

export class InputDispatcher {
  private heldAxes = { x: 0 as -1|0|1, y: 0 as -1|0|1 };
  private heartbeatTimer?: number;

  constructor(private room: Room, private prediction: PredictionEngine) {
    this.heartbeatTimer = window.setInterval(() => this.reaffirmHeld(), HEARTBEAT_INTERVAL_MS);
  }

  onKeyDown(key: 'W'|'A'|'S'|'D') {
    const before = { ...this.heldAxes };
    if (key === 'A') this.heldAxes.x = -1;
    if (key === 'D') this.heldAxes.x = 1;
    if (key === 'W') this.heldAxes.y = -1;
    if (key === 'S') this.heldAxes.y = 1;
    if (this.heldAxes.x !== before.x || this.heldAxes.y !== before.y) this.dispatch();
  }

  onKeyUp(key: 'W'|'A'|'S'|'D') {
    if ((key === 'A' && this.heldAxes.x === -1) || (key === 'D' && this.heldAxes.x === 1)) this.heldAxes.x = 0;
    if ((key === 'W' && this.heldAxes.y === -1) || (key === 'S' && this.heldAxes.y === 1)) this.heldAxes.y = 0;
    this.dispatch();
  }

  private dispatch() {
    const intent = this.prediction.enqueueInput(this.heldAxes);
    this.room.send('input', {
      type: 'input',
      seq: intent.seq,
      dt_ms: 50,                                    // tick
      axis_x: this.heldAxes.x, axis_y: this.heldAxes.y,
      jump: false, action_btns: 0,
    });
  }

  private reaffirmHeld() {                          // D-09: 15s heartbeat reaffirmation
    if (this.heldAxes.x !== 0 || this.heldAxes.y !== 0) this.dispatch();
    this.room.send('heartbeat', { type: 'heartbeat' });
  }

  dispose() { if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); }
}
```

### Playwright two-client merge-gate (D-19)

```ts
// apps/client/test/e2e/cli-08.e2e.test.ts
// [int->REQ-CLI-08]
import { test, expect } from '@playwright/test';

test('CLI-08 hard milestone — two clients see each other move + chat', async ({ browser }) => {
  // Two isolated contexts = two independent users (cookies, storage, WS)
  const aCtx = await browser.newContext();
  const bCtx = await browser.newContext();
  const a = await aCtx.newPage();
  const b = await bCtx.newPage();

  // Staging access via invite token (Phase 5 D-04)
  const STAGE = process.env.STAGING_URL ?? 'https://staging.rebno.decidel.com';
  const INVITE = process.env.STAGING_INVITE_TOKEN!;

  await a.goto(`${STAGE}/?invite=${INVITE}`);
  await b.goto(`${STAGE}/?invite=${INVITE}`);

  // Log in as 2 seeded accounts
  await a.fill('#username', 'alice'); await a.fill('#password', process.env.A_PW!); await a.click('button[type=submit]');
  await b.fill('#username', 'bob');   await b.fill('#password', process.env.B_PW!);   await b.click('button[type=submit]');

  // Wait for both to enter game scene (canvas + nameplate visible)
  await expect(a.locator('[data-game-ready=true]')).toBeVisible({ timeout: 10000 });
  await expect(b.locator('[data-game-ready=true]')).toBeVisible({ timeout: 10000 });

  // Each client sees the OTHER's nameplate
  // (Nameplate is a DOM-positioned <span> alternative if D-05 → DOM; otherwise a data-* on canvas)
  // Recommendation: emit a <div data-nameplate=<username>> per remote player into a hidden a11y layer
  // for assertion purposes — keeps Phaser canvas fast AND Playwright assertable.
  await expect(a.locator('[data-nameplate="bob"]')).toBeVisible();
  await expect(b.locator('[data-nameplate="alice"]')).toBeVisible();

  // A presses D for 1s; B sees A move
  await a.keyboard.down('d'); await a.waitForTimeout(1000); await a.keyboard.up('d');
  // Assert via x-coord on b's view of alice (data-x-coord attribute, also for testability)
  const aliceX = await b.locator('[data-nameplate="alice"]').getAttribute('data-x-coord');
  expect(Number(aliceX)).toBeGreaterThan(150);     // started at home portal ~100, moved right

  // Chat round-trip
  await a.press('body', 'Enter');
  await a.fill('[data-chat-input]', 'hello');
  await a.press('[data-chat-input]', 'Enter');
  await expect(b.locator('.chat-line')).toContainText('alice: hello', { timeout: 3000 });

  // Reconnect grace: kill A's WS, reopen, A's character should resume
  await a.evaluate(() => (window as any).__rebno.room.connection.close());
  await a.waitForTimeout(2000);
  // Page is still open; Colyseus auto-reconnect via cached reconnectionToken
  await expect(a.locator('[data-game-ready=true]')).toBeVisible({ timeout: 15000 });
});
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `colyseus.js` | `@colyseus/sdk` | Colyseus 0.17 (2024) | New package name; required for 0.17 servers. STACK.md is stale. |
| Phaser `pixelArt: false` + manual NEAREST | `pixelArt: true` config flag (sets antialias=false + roundPixels=true) | Phaser 3.50+ | One-line config replaces 3-line workaround |
| `Scale.NONE` + manual resize math | `Scale.FIT` + `autoRound: true` + `MAX_ZOOM` | Phaser 3.30+ | Eliminates Pitfall C3 (HiDPI sub-pixel blur) |
| Lucia auth | Better-Auth | Lucia deprecated March 2025 | Phase 4 already migrated; Phase 6 client uses `better-auth/client` |
| `Buffer`-only msgpack | `msgpackr` returns plain `Uint8Array` (browser) and `Buffer` (Node) | msgpackr 1.10+ | `events.ts` already normalizes — Phase 6 client just consumes |
| Manual ws reconnect | Colyseus 0.17 built-in `room.reconnection.{maxRetries, maxDelay, minUptime}` + `client.reconnect(token)` | Colyseus 0.17 | Eliminates Pitfall B2 (zombie sessions) without custom heartbeat |
| `free-tex-packer-core` | `maxrects-packer` directly | 2024+ | Slimmer dep; we emit Phaser-format JSON ourselves |

**Deprecated/outdated:**
- `colyseus.js@0.16.x` — for 0.16 servers only; do not use.
- Phaser 4-specific patterns (`smoothPixelArt`, mandatory parent container for DOMElement) — do not use; we're on 3.90.
- `node-bmp` / `pngjs` for BMP decode — use sharp.

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `maxrects-packer@2.7.3` is fully deterministic given sorted input | Standard Stack §Supporting | Low — verifiable via determinism test in `tools/asset-pipeline/test/`. If wrong, swap to Rust `tex-packer` via napi binding (heavier dep). |
| A2 | Web Crypto `crypto.subtle.verify('Ed25519', …)` works in Chrome desktop 2026 baseline | Don't Hand-Roll | Low — Chrome ≥ 113 (May 2023) ships Ed25519 in WebCrypto. If polyfill needed, `tweetnacl` is 30 KB. |
| A3 | Phaser `Scale.FIT` + `MAX_ZOOM` + `autoRound: true` produces integer-scale canvas across all DPRs (1, 1.25, 1.5, 2, 3) | Pattern 1 | Medium — UAT must verify on retina + 125% Windows scaling. Fallback: manual integer-zoom calc on `window.resize`. |
| A4 | `BrowserContext` isolation in Playwright 1.59 produces fully independent WS connections (not pooled) | Pitfall 6 | Low — Context7 explicitly states "independent cookies, local storage, and session data." UAT must double-check via dev tools network panel. |
| A5 | Colyseus state-diff handles new optional `input_axes` field as additive (no v1-client compat) | Pitfall 1 | Medium — `@colyseus/schema` 4.x docs claim additive fields are backward-compat for *encoded* shape but NOT for *decoded* shape. **Requires manual verification** before deploy: decode a v2 state on a v1 client; if it crashes, the deploy ritual is non-negotiable (server-before-client + force-mismatch-error path). |
| A6 | The 4 wall sprites (Tile1/TSide1/VBorder/HBorder) plus 1 background plus 6 player frames fit in 512×512 | Pattern 7 | Low — extracted/client-5-8 sprite metadata shows player frames are 36×48; 6 frames = ~13 KB raw. 512×512 has 250 KB headroom. UAT verifies actual atlas size. |
| A7 | The current PROTOCOL_VERSION = 1 is correct; bumping to 2 is sound | Pattern 1, Pitfall 1 | Low — confirmed via `packages/protocol/src/version.ts:10`. |
| A8 | Phaser DOMElement focus delay (~50 ms) is sufficient for Chrome autofill timing | Pitfall 3 | Low — community workaround confirmed by Phaser forum. UAT must verify. |
| A9 | `@colyseus/sdk@0.17.42` exports `room.reconnection` config and `room.onDrop` / `room.onReconnect` callbacks | Pattern 3, 8 | Low — Context7 docs confirm. Verify by reading `node_modules/@colyseus/sdk/build/cjs/Room.d.ts` post-install. |
| A10 | Same-Fly-app static serve cost (D-18) is bounded — the Vite client bundle is < 5 MB and Express's static middleware can handle it on the existing shared-cpu-1x machine without affecting WS tick latency | Pitfall 4, Architecture | Low — Phaser ~1.5 MB minified + msgpackr 50 KB + @colyseus/sdk 80 KB + atlas 300 KB ≈ 2 MB total. Express static is async + cached at OS layer. |

## Open Questions

1. **Should remote-player nameplates use Phaser canvas Text or DOM overlays?**
   - What we know: D-05 explicitly delegates to planner; both work; Phaser canvas Text is recommended in Standard Stack §Alternatives.
   - What's unclear: Whether D-19 Playwright assertions need DOM nameplates for testability.
   - Recommendation: **Phaser canvas Text for the visible nameplate + a hidden `<div data-nameplate="<username>" data-x-coord=…>` mirror for Playwright assertions only.** Best of both — perf of canvas + assertability of DOM.

2. **Does the Phase 6 plan need ADR 0007 to lock the protocol amendments?**
   - What we know: D-07/D-08/D-09 amend Phase 4's wire contract permanently. Phase 7 PAR-04 (full chat surface) and PAR-05 (`.bnu` migration) inherit them.
   - What's unclear: Whether to amend ADR 0004 (which is room-hot-reload-specific) or write a new ADR.
   - Recommendation: **New ADR 0007 — Phase 6 client protocol amendments + prediction contract.** Keeps ADR 0004 narrowly scoped to room hot-reload. Locks D-07 (home-portal spawn), D-08 (`PlayerState.input_axes`), D-09 (event-driven `c2s.input`), D-10 (threshold-gated reconcile), D-11 (snapshot interp + extrapolation) as a single contract.

3. **Where does the MVP background image come from?**
   - What we know: `mvp-lobby/000.json` has `bg_atlas_ref: "mvp-lobby:bg"` but no actual background. `extracted/client-5-8/backgrounds/` has 12 backgrounds.
   - What's unclear: Which one is `mvp:yes` and small enough for the lobby.
   - Recommendation: Plan task that calls `pnpm catalog:client` and inspects the `mvp:yes` filter — likely a 32×32 or 128×128 background tileable across the 1000×1000 lobby. Defer concrete pick to plan time. **`StaticBackground` (32×64, ID 20) is a candidate** based on asset-catalog index.json.

4. **What's the exact divergence threshold for D-10?**
   - What we know: 22 px is the recommendation (≈ half a 44 × 40 tile). Phase 5 30-min staging soak DID run but did NOT capture per-player prediction-error histograms.
   - What's unclear: Whether 22 is too tight or too loose at real RTTs.
   - Recommendation: Plan adds a **debug logging path** that emits divergence histograms to console; operator runs UAT, picks final number from observed distribution. Lock at 22 if no data; revisit at Phase 6 verify time.

5. **Should the Phase 6 plan re-tune Phase 4 D-22's `input` rate-limit budget?**
   - What we know: D-09 says "planner may relax `input` budget from 25/s burst 35 given event-driven cadence." Event-driven inputs at 20 Hz + 15s heartbeat = ~3-5 messages/sec/active-player typical, with bursts on key-mash.
   - What's unclear: Whether relaxing breaks Phase 4 SRV-07 acceptance.
   - Recommendation: **Drop `input` budget to 10/s burst 15** (4× the typical cadence; tolerates legit mashing). Update `lint-rate-limit-budgets.mjs`. Easy reversal.

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node 22 LTS | Vite, sharp, asset-pipeline | ✓ | (pinned in Phase 1) | — |
| pnpm 10.x | Workspace + standalone tools | ✓ | (pinned) | — |
| sharp 0.34.5 | asset-pipeline bootstrap (BMP→PNG) | ✓ | 0.34.5 | — |
| Web Crypto Ed25519 | Browser room-layout signature verify | ✓ | Chrome ≥ 113 (2023) | `tweetnacl` polyfill (30 KB) |
| Aseprite CLI | Sprite editing for D-15 | ✗ (assumed not installed; optional dev tool) | — | Skip — devs without Aseprite use any PNG editor + JSON sidecar manually |
| Playwright Chromium | E2E test (D-19) | ✗ (will be installed by `pnpm playwright install chromium`) | bundled | — |
| Better-Auth client | LoginScene | ✓ | 1.6.9 (matches server) | — |
| `@colyseus/sdk` | ColyseusClient | ✓ | 0.17.42 | — |
| Fly.io staging deployment | Playwright merge-gate target | ✓ | (Phase 5 green) | Local docker-compose for unit tests |

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

**Missing dependencies with fallback:**
- Aseprite CLI — D-15 calls out the format; pipeline READS the canonical PNG strip + JSON-array; an editor that produces those is sufficient. Document in tool README.

## Validation Architecture

> CLI-08 is a **hard milestone**. The validation strategy is the most important section of this research because the Phase 6 plan TDD-bootstraps from the verification surface backward.

### Test Framework

| Property | Value |
|----------|-------|
| Framework — unit | `vitest@3.x` (existing repo pin) |
| Framework — E2E | `@playwright/test@1.59.1` (NEW; install in Phase 6 Wave 0) |
| Config file — unit | `apps/client/vitest.config.ts` (NEW; mirror server pattern) |
| Config file — E2E | `apps/client/test/e2e/playwright.config.ts` (NEW) |
| Quick run command | `pnpm --filter @rebno/client test` |
| Full suite command | `pnpm verify:phase-6` (NEW; mirror `verify-phase-5.mjs` shape) |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| REQ-CLI-01 | Vite-built bundle loads in Chrome | smoke | `pnpm --filter @rebno/client build:staging && pnpm playwright test --grep 'cli-01-build-loads'` | ❌ Wave 0 |
| REQ-CLI-02 | WSS handshake + PROTOCOL_VERSION check | int (Playwright) | included in `cli-08.e2e.test.ts` | ❌ Wave 0 |
| REQ-CLI-03 | Login flow → game transition | int (Playwright) | included in `cli-08.e2e.test.ts` | ❌ Wave 0 |
| REQ-CLI-04 | Prediction + reconciliation | unit | `vitest run prediction/` | ❌ Wave 0 |
| REQ-CLI-05 | Chat HUD + nameplates | int (Playwright) | included in `cli-08.e2e.test.ts` | ❌ Wave 0 |
| REQ-CLI-06 | HiDPI nearest-neighbor + integer scale | manual + visual | `06-HUMAN-UAT.md` Test 1 (screenshot at 1.0/1.5/2.0 DPR) | ❌ Wave 0 |
| REQ-CLI-07 | Atlas loads via content-hashed manifest | unit + int | `vitest run assets/` + Playwright network-panel assertion | ❌ Wave 0 |
| **REQ-CLI-08** | **Two-player movement + chat over deployed server** | **int (Playwright merge-gate)** | `pnpm playwright test cli-08.e2e.test.ts` | ❌ **Wave 0 — TDD start** |
| REQ-CLI-09 | Reconnect within grace window | int (Playwright) | included in `cli-08.e2e.test.ts` (kill ws → reopen) | ❌ Wave 0 |
| REQ-AST-01 | sharp BMP→PNG produces deterministic output | unit | `cd tools/asset-pipeline && pnpm test determinism.test.ts` | ❌ Wave 0 |

### Sampling Rate

- **Per task commit:** `pnpm --filter @rebno/client test` (vitest unit) + `pnpm --filter @rebno/client typecheck` (< 30 s)
- **Per wave merge:** `pnpm verify:phase-6` (full unit + lints + Playwright headless against local docker-compose server)
- **Phase gate:** Full suite green + Playwright two-client merge-gate green against `staging.rebno.decidel.com` AND `06-HUMAN-UAT.md` recorded artifact

### Wave 0 Gaps

- [ ] `apps/client/vitest.config.ts` — unit test runner config
- [ ] `apps/client/test/e2e/playwright.config.ts` — E2E config
- [ ] `apps/client/test/e2e/fixtures.ts` — 2-context login fixture (seeds via existing `pnpm migrate:legacy-accounts` ritual on staging — Phase 5 already done)
- [ ] `apps/client/test/e2e/cli-08.e2e.test.ts` — the merge gate (TDD-FIRST per D-19)
- [ ] `apps/client/test/unit/prediction/PredictionEngine.test.ts`
- [ ] `apps/client/test/unit/prediction/ReconcileEngine.test.ts`
- [ ] `apps/client/test/unit/assets/atlasLoader.test.ts`
- [ ] `tools/asset-pipeline/test/determinism.test.ts` — bootstrap + build are byte-identical given same input
- [ ] `scripts/verify-phase-6.mjs` — composite gate (mirror `verify-phase-5.mjs`)
- [ ] `tools/scripts/lint-asset-pipeline.mjs` — pipeline-manifest schema + atlas integrity
- [ ] `tools/scripts/lint-vite-env.mjs` — `.env.staging`/`.env.prod` carry required `VITE_*` keys, no secrets
- [ ] `.github/workflows/verify-phase-6.yml` — CI workflow (mirror `verify-phase-5.yml`)
- [ ] Extension to `.github/workflows/deploy-staging.yml` — add `pnpm --filter @rebno/client build:staging` step before docker build; add post-deploy Playwright merge-gate step
- [ ] Framework install: `pnpm --filter @rebno/client exec playwright install chromium`

### Cross-cutting validation: Replay-determinism harness (CLI-04)

Add a `tools/asset-pipeline/test/`-pattern replay harness for prediction:

```ts
// apps/client/test/unit/prediction/replay-determinism.test.ts
// [unit->REQ-CLI-04]
// Given a recorded input sequence + initial state, verify:
//   (a) PredictionEngine.localState matches a Node-side step() chain byte-for-byte
//   (b) ReconcileEngine snap+replay produces the same final position as the no-divergence path
import { step } from '@rebno/game-logic';
// ... compare prediction vs Node-side oracle step()
```

The harness validates the same property `packages/game-logic` already tests (golden-trajectory) but at the prediction-layer boundary. Catches "prediction layer dropped an input" bugs.

### CLI-08 merge-gate operational flow

1. Operator pushes to `main`.
2. `.github/workflows/deploy-staging.yml` runs `pnpm verify:phase-6` (composite gate, includes Playwright in headless against local docker-compose).
3. If green, builds Docker image (server + Vite client bundle) and deploys to `rebno-staging`.
4. Post-deploy step runs `pnpm playwright test cli-08.e2e.test.ts` against the deployed `wss://staging.rebno.decidel.com`.
5. If green, marks deploy success. If red, deploy is marked red (does NOT auto-rollback per Phase 5 D-08).
6. Operator sees red → fixes → repushes.
7. Merge to `main` is gated on the Playwright two-client smoke green.

Manual UAT (`06-HUMAN-UAT.md`):
- Operator opens 2 Chrome tabs against `staging.rebno.decidel.com` with `?invite=<token>`.
- Manually performs the same scenario as Playwright.
- Captures screenshot evidence + screen-recording video.
- Logs as the "we shipped CLI-08" milestone artifact.

## Security Domain

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes | Better-Auth + argon2id (server-side; Phase 4 SRV-09); client uses `better-auth/client` SDK |
| V3 Session Management | yes | Better-Auth session cookie + bearer token; sliding 30-day window (Phase 4 D-07) |
| V4 Access Control | yes | `STAGING_INVITE_TOKEN` middleware on staging; `auth.role` from Better-Auth (planner verifies admin paths are absent in Phase 6 — they are; CLAUDE.md #3) |
| V5 Input Validation | yes | zod `.strict()` schemas in `@rebno/protocol` already enforce wire shape (Phase 4 D-04); **client-side reuse**: typed `room.send` paths use the same zod schemas |
| V6 Cryptography | yes | Ed25519 room-layout sig verify via Web Crypto `crypto.subtle.verify('Ed25519', …)` — never hand-rolled |
| V8 Data Protection | yes | Session token in memory + sessionStorage (NOT localStorage — survives reload but not browser close); reconnectionToken same |
| V11 Business Logic | yes | World-mutating action gate (`reconnectBanner.isWorldMutationAllowed`) — Phase 6 has no world-mutating intents in MVP, but the affordance is baked in |
| V12 Files & Resources | yes | CSP headers from `apps/server` Express middleware: `script-src 'self'; connect-src 'self' wss://<env>.rebno.decidel.com; img-src 'self' data:; style-src 'self' 'unsafe-inline'` |
| V13 API & WebService | yes | All `/api/*` paths gate via Better-Auth or staging-invite; `/colyseus` WS gates via `onAuth` |
| V14 Configuration | yes | `.env.staging`/`.env.prod` lint enforces required `VITE_*` keys + forbids secrets; lint-vite-env.mjs |

### Known Threat Patterns for Phaser/Vite/Colyseus client

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Client trusts predicted state and overwrites server position | Tampering | Server-authoritative; client never sends `x`/`y`; zod `.strict()` (Phase 4 D-04) rejects forged fields |
| Reconnect token theft (XSS → exfil sessionStorage) | Spoofing/Tampering | CSP `script-src 'self'` blocks third-party JS; same-Fly-app means no cross-origin scripts; **`HttpOnly` cookies** for Better-Auth session (already Phase 4 D-05); reconnection token is short-lived (10s grace) |
| CSP gaps allow inline `<script>` exfil | Tampering/Disclosure | `script-src 'self'` + Vite-emitted `<script type="module" src="…">` only (no inline). `style-src` may need `'unsafe-inline'` for Phaser; document. |
| Supply-chain (sharp / Phaser / Vite plugins) | Tampering | All deps pin-versioned (no `^`); pnpm-lock.yaml committed; pnpm install --frozen-lockfile in CI; lint-asset-pipeline verifies sharp/maxrects-packer versions |
| Room-layout signature verify failure (forged room) | Tampering | Ed25519 verify against `VITE_ROOM_SIGNING_PUBKEY` (pinned at build time per env); on failure, drop the layout AND log to console (Phase 7: report to OTel) |
| `STAGING_INVITE_TOKEN` baked into prod bundle | Disclosure | `.env.prod` does NOT include it; `lint-vite-env.mjs` rejects any `VITE_*` value containing the staging token's prefix; Vite's mode-isolation already enforces |
| Malicious atlas PNG with embedded JS / SVG | Tampering | sharp lossless PNG re-encode strips metadata; `Phaser.Loader.atlas` does not eval anything; no SVG path in MVP |
| WebSocket upgrade hijack | Spoofing | Server `onAuth` validates session token via Better-Auth before accepting; same-origin means Origin-header check is meaningful |
| Login form CSRF | Spoofing | Better-Auth's CSRF flow + same-origin cookie + Phase 4 `ALLOWED_ORIGINS` Origin-header gate |
| Chat XSS via `s2c.chat_msg.text` | Tampering | Render with `textContent`, NEVER `innerHTML`; zod schema caps text length 500 chars (Phase 4 D-04) |
| `pipeline-manifest.json` MITM | Tampering | Same-origin HTTPS (Fly proxy auto-HTTPS); browser TLS verify; future hardening = SRI on the atlas PNG |
| Reconnection token replay across browsers | Spoofing | Token is bound to the original IP (Colyseus default behavior — verify in @colyseus/sdk docs at plan time); short 10s grace minimizes blast radius |
| Predicted state corruption via local debugger | Tampering | Server is authoritative — no impact beyond local visual glitches; reconciliation snaps client back |
| Force-reset overlay bypass | Spoofing | Server enforces `muted_until_password_change` flag in `RebnoRoom.onJoin` (`apps/server/src/RebnoRoom.js:198`); client overlay is UX, not security |

### CSP header (recommended for Express on `apps/server` static mount)

```
Content-Security-Policy: default-src 'none'; script-src 'self'; connect-src 'self' wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
```

`'unsafe-inline'` for `style-src` is unfortunate but Phaser injects inline canvas styles. Audit at Phase 7 hardening pass.

## Sources

### Primary (HIGH confidence)
- `[VERIFIED: npm registry 2026-05-09]` `phaser@4.1.0` (latest, but locked to 3.90.0 by ADR 0001), `@colyseus/sdk@0.17.42`, `@colyseus/schema@4.0.25` (server pinned 4.0.23), `colyseus@0.17.42` (server pinned 0.17.10), `vite@8.0.11`, `better-auth@1.6.10` (server pinned 1.6.9), `@playwright/test@1.59.1`, `sharp@0.34.5`, `maxrects-packer@2.7.3`, `msgpackr@2.0.1` (server pinned 1.11.10).
- Context7 `/phaserjs/phaser` — Scale.FIT, autoRound, MAX_ZOOM, pixelArt, DOMElement, keyboard input, FilterMode.NEAREST.
- Context7 `/colyseus/docs` — JavaScript client SDK, joinOrCreate, room.state.players.onAdd/onChange/onRemove, allowReconnection, room.reconnection lifecycle, reconnect token, onDrop/onReconnect/onError.
- Context7 `/vitejs/vite` — `build.manifest`, `loadEnv(mode, envDir, prefixes)`, `import.meta.env.VITE_*`, `--mode` env file precedence.
- Context7 `/better-auth/better-auth` — `createAuthClient`, `signIn.email`, `getSession`, `signOut`, `changePassword`, bearer plugin, cross-origin credentials.
- Context7 `/microsoft/playwright` — `browser.newContext()` two-context pattern, `BrowserContext` isolation, `page.routeWebSocket` (not used for our gate but available), `@playwright/test` fixtures.
- `apps/server/src/RebnoRoom.js`, `onMessageHandlers.ts`, `index.ts`, `staging-invite.ts` — Phase 4 + 5 source surfaces; verbatim contracts Phase 6 client must satisfy.
- `apps/server/rooms/mvp-lobby/000.json` — actual layout shape Phase 6 client renders.
- `packages/protocol/src/{state,intents,events,version}.ts` — current schema + zod intents + msgpackr events; Phase 6 amends `state.ts` (D-08) + `intents.ts` (D-09) + bumps `version.ts` (1 → 2).
- `packages/game-logic/src/{step,types}.ts` — pure step() consumed by client prediction.
- `docs/adr/{0001,0004,0005}.md` — Phaser 3.90 lock, room hot-reload contract, deploy topology.
- `.planning/phases/{04,05,06}-*/06-CONTEXT.md` — locked decisions.
- `scripts/{verify-phase-5,soak-staging}.mjs` — Phase 5 patterns Phase 6 mirrors.
- `.github/workflows/deploy-staging.yml` — CI workflow Phase 6 extends.

### Secondary (MEDIUM confidence)
- gabrielgambetta.com — "Client-Side Prediction and Server Reconciliation" (Parts I-IV); standard reference for the prediction loop pattern documented in Pattern 4.
- Overwatch GDC netcode talk via gamereplays.org — input buffering, server speed-up/slow-down, client re-simulation; informs D-10 + D-11 design.
- webgamedev.com/backend/prediction-reconciliation — modern (2025) restatement of Gambetta + Overwatch concepts.
- danieljimenezmorales.github.io/2025-06-20 — recent blog post confirming the prediction + reconciliation pattern is still SOA in 2025.
- phaser.discourse.group — DOMElement focus issue + autofill pitfall.
- npm/free-tex-packer-core, github/Latias94/tex-packer — alternatives matrix.

### Tertiary (LOW confidence — flag for validation)
- Phase 5 30-min staging soak prediction-error histograms — DO NOT EXIST yet. Plan Wave 0 task: re-run soak with debug-logging path and capture histograms before locking D-10's threshold.
- Exact Phaser 3.90 behavior of `Scale.MAX_ZOOM + autoRound + dom container` on Windows 125% scaling + macOS retina + standard 1080p — UAT must verify.
- Behavior of `room.reconnect(token)` after a server restart that wiped session state — needs hands-on test on staging.

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — versions verified against `npm view` 2026-05-09; all are stable releases with mature ecosystems.
- Architecture: HIGH — locked by Phase 4/5 + ADRs; Phase 6 only adds the client-side mirror of already-locked server contracts.
- Pitfalls: HIGH — most carry forward from Phase 4/5 + PITFALLS.md sections B/C; new pitfalls (Phaser DOMElement focus, atlas overflow, vite manifest path, PROTOCOL_VERSION rollout) are well-documented in cited references.
- Validation architecture: HIGH for shape; MEDIUM for the D-10 threshold tuning and A5 protocol-version compat (planner must verify hands-on).
- Asset pipeline: MEDIUM — sharp + maxrects-packer combo is novel for this repo; determinism test will surface drift fast.

**Research date:** 2026-05-09
**Valid until:** 2026-06-08 (30 days for stable stack); revisit Phaser 4 retro window pre-Phase 7 PR-01.

## RESEARCH COMPLETE
