# threads

id: 019e1490-22b5-7730-b9b9-3305fd9a6560
source: cli
model_provider: openai
cwd: C:\Users\decid\Documents\projects\rebno
sandbox_policy: {"type":"danger-full-access"}
approval_mode: never
git_sha: 1b7105cd115bb11bce772ae7dbacaab0956272e8
git_branch: main
git_origin_url: https://github.com/SaberMage/rebno.git
cli_version: 0.130.0
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
thread_source: user
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\10\rollout-2026-05-10T18-04-10-019e1490-22b5-7730-b9b9-3305fd9a6560.jsonl

title:
  [Image #1] please diagnose the issue with traceable-reqs and file a proper issue against `BigscreenVR/traceable-reqs` using the gh cli

first_user_message:
  [Image #1] please diagnose the issue with traceable-reqs and file a proper issue against `BigscreenVR/traceable-reqs` using the gh cli

preview:
  [Image #1] please diagnose the issue with traceable-reqs and file a proper issue against `BigscreenVR/traceable-reqs` using the gh cli


id: 019e1498-5e7b-7130-8894-ce7da7d56cbf
source: cli
model_provider: openai
cwd: C:\Users\decid\Documents\projects\rebno
title: [Image #1] what can we do about these codex cli warnings
sandbox_policy: {"type":"danger-full-access"}
approval_mode: never
git_sha: 1b7105cd115bb11bce772ae7dbacaab0956272e8
git_branch: main
git_origin_url: https://github.com/SaberMage/rebno.git
cli_version: 0.130.0
first_user_message: [Image #1] what can we do about these codex cli warnings
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
thread_source: user
preview: [Image #1] what can we do about these codex cli warnings
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\10\rollout-2026-05-10T18-13-10-019e1498-5e7b-7130-8894-ce7da7d56cbf.jsonl


id: 019e14ad-ea83-75b1-a064-f9579cdf6120
source: exec
model_provider: openai
cwd: C:\Users\decid\Documents\projects\rebno
sandbox_policy: {"type":"read-only"}
approval_mode: never
git_sha: fefba464b967c33c71e294ad9e2f1ceb1db44d45
git_branch: main
git_origin_url: https://github.com/SaberMage/rebno.git
cli_version: 0.130.0
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\10\rollout-2026-05-10T18-36-42-019e14ad-ea83-75b1-a064-f9579cdf6120.jsonl

title:
  # Cross-AI Plan Review Request — REBNO Phase 6 Gap-Closure

  You are reviewing 8 gap-closure plans (06-10..06-17) for Phase 6 of the REBNO project. Wave 7 (plans 06-01..06-09) shipped but HUMAN-UAT 2026-05-10 exposed 5 blocking findings (D-25, D-26, D-27, D-27a, plus secondary stale-dispatcher-room defect). The 8 plans below close those gaps and re-aim for CLI-08 (the hard milestone — two players move + chat over deployed server). CLI-08 is the go/no-go for the entire rebuild.

  Provide structured feedback on plan quality, completeness, and risks. Focus on missing edge cases, dependency ordering, scope creep, security, performance, and whether the plans actually achieve the phase goal.

  ## Project Context (PROJECT.md, first 80 lines)
  ```markdown
  # REBNO — Rebuild BN Online

  ## What This Is

  REBNO is a ground-up modernization of *BN Online*, a late-1990s/early-2000s multiplayer game shipped as a GameMaker 5.3a `.gmd` project. The work extracts the original client and server source from `BN Online Client 5-8.gmd` and `BN Online Master 5-4.gmd`, documents engine + server features, then rebuilds both halves on a modern web stack (TypeScript everywhere, Node server on Fly.io, Phaser 3 *or* PixiJS client running in Chrome) at full feature parity with a modernized UX.

  ## Core Value

  **Original BN Online players can open Chrome, log in with their migrated account, walk around, and chat with each other in real time — bug-free.** Everything else (graphics polish, full feature parity, scaling) flows from that working multiplayer baseline.

  ## Requirements

  ### Validated

  <!-- Brownfield: existing artifacts in repo. Code/wiki present, but nothing new shipped yet. -->

  - ✓ GameMaker 5.3a reverse-engineering wiki (17 docs in `decomp/wiki/`) — existing
  - ✓ Legacy archive of all `.gmd` revisions, server source, Unity remake attempt, Maya assets, audio — existing
  - ✓ Codebase map (`.planning/codebase/`, 7 docs) — existing
  - ✓ Stage 1 (Extraction) complete — Validated in Phase 1: extraction (2026-05-03)
    - `tools/extract-gmd/` TS Node CLI ports `LateralGM GmFileReader` semantics; 157/157 tests
    - `extracted/client-5-8/` (8307 files) + `extracted/server-5-4/` (1144 files) committed; reproducibility byte-identical
    - 13 wire-format bugs uncovered + documented in `decomp/wiki/03-gmd-format.md` errata
    - Sprite/background image data carried as opaque `imageBytes` (Option A pivot — BMP→PNG decode deferred to Phase 6/7 asset-pipeline; EXT-04 acceptance relaxed accordingly)

  ### Active

  #### Stage 1 — Extraction (DONE — Phase 1, 2026-05-03)

  #### Stage 2 — Client analysis (DONE — Phase 2, 2026-05-04)

  #### Stage 3 — Server analysis (DONE — Phase 3, 2026-05-05; status: human_needed — Linux CI verify-phase-3 deferred per HUMAN-UAT.md)

  #### Stage 4 — Server rebuild (NEXT)
  - [ ] Reimplement server in Node + TypeScript with binary WebSocket transport
  - [ ] Refactor as code is ported: identify and eliminate bugs from the original
  - [ ] Migrate original user accounts to a modern hash (bcrypt/argon2) — preserve usernames, force password reset on first login OR transparent re-hash on first successful auth
  - [ ] Authoritative server model — clients never trust each other for game state
  - [ ] Room/level layouts stored server-side and transmitted to client over the wire on room entry, integrity-verified; client bundle ships no static layout data (SRV-13)
  - [ ] Moving-platform live positions deterministic via `packages/game-logic` `step()` and broadcast authoritatively to all clients in the same state-diff envelope as player positions (SRV-14)

  #### Stage 5 — Deploy
  - [ ] Deploy new server to Fly.io with persistent volume + Litestream backup (or Postgres)
  - [ ] CI/CD pipeline: push to main → deploy
  - [ ] Health checks, structured logging, basic ops dashboard

  #### Stage 6 — Client rebuild (MVP)
  - [ ] Web client (Phaser 3 or PixiJS, TypeScript, Vite) running in Chrome
  - [ ] Modernized UX (HiDPI, responsive, keyboard + gamepad input)
  - [ ] **MVP gate: two players join the same room, see each other move, exchange chat messages — multiplayer over the new server**

  #### Stage 7 — Full parity
  - [ ] All client features from Stage 2 documentation reimplemented
  - [ ] All server features from Stage 3 documentation reimplemented
  - [ ] Original-game test cases pass against new client + server

  ### Out of Scope

  - **Mobile / native ports** — Chrome-first; no iOS, Android, Steam binaries in v1
  - **Engine-level emulation of GameMaker 5.3a** — we reimplement game *logic*, not the GM5 runtime
  - **Re-running the original Windows binary** — preservation belongs in `legacy/`, not the rebuild
  - **Server-side anti-cheat beyond authoritative model** — no behavioral detection in v1
  - **Modding API / user-generated content** — out of scope until parity ships
  - **3D / VR / new gameplay features** — faithful rebuild first, then iterate
  - **Public marketing / asset rights clearance** — license + distribution decision deferred until rebuild is functional (see Key Decisions)

  ## Context

  - **Repo layout** (see `.planning/codebase/STRUCTURE.md`): `decomp/wiki/` holds RE knowledge, `legacy/` holds all original artifacts (audio, maya-project, open-source-release, servers, source-archive, unity-project). Two prior remake attempts exist (`legacy/unity-project`, `legacy/open-source-release`); both can inform the new rebuild but are not the authoritative source — the `.gmd` files are.
  - **Source-of-truth files**: `legacy/open-source-release/BN Online Client 5-8.gmd` (client) and `legacy/open-source-release/BN Online Master 5-4.gmd` (server). Older revisions in `legacy/source-archive/` and `legacy/servers/` are reference-only.
  - **Engine constraints to overcome** (from wiki): GM5.3a is Delphi 5 / Win32, modern decompilers don't read it, network code is 39dll-only (TCP, no native sockets), assets are ZLIB-compressed inside a monolithic XOR-obfuscated payload appended to the runner stub. Extraction pipeline is documented in `decomp/wiki/15-extraction-pipeline.md`.
  - **Proprietary formats**: `.bno` / `.bnb` save data is bespoke — only readable by walking the extracted GML's `file_bin_*` call order, not via external tools.
  - **Bit-rot / IP risks** flagged in `.planning/codebase/CONCERNS.md` (229 lines) — read before committing scope.

  ## Constraints

  - **Tech stack — server**: Node + TypeScript. Mature ecosystem, best WS tooling (`ws`, `uWebSockets.js`), strong type story for shared client/server packet definitions.
  - **Tech stack — client**: TypeScript + Phaser 3 (preferred) or PixiJS (fallback). Decision deferred to end of Stage 2 once feature surface is known. Must run in Google Chrome, no plugins.
  - **Tech stack — transport**: WebSocket binary frames. Authoritative server, clients send intent, server emits state.
  - **Tech stack — persistence**: TBD in Stage 3 (likely SQLite + Litestream for <50 player target; Postgres if data model demands relational queries).
  ```

  ## Phase 6 — Roadmap Section
  ```markdown
  ### Phase 6: Client Rebuild — MVP Gate (CLI-08 HARD MILESTONE)
  **Goal**: A Vite-built TypeScript Phaser 3 (or PixiJS, per Phase 2 ADR) client runs in Chrome, connects to the deployed Fly.io server over WSS, authenticates, predicts movement against `packages/game-logic`, and lets two players in the same room see each other move and exchange chat — multiplayer over the deployed server, end-to-end. **CLI-08 is THE go/no-go for the rebuild.**
  **Depends on**: Phase 2 (engine ADR), Phase 5 (deployed server)
  **Requirements**: CLI-01, CLI-02, CLI-03, CLI-04, CLI-05, CLI-06, CLI-07, CLI-08, CLI-09, AST-01
  **Success Criteria** (what must be TRUE):
    1. A user opens the deployed client URL in Chrome desktop, sees a login screen, enters username + password, and is authenticated against the Fly.io server with PROTOCOL_VERSION verified during handshake.
    2. After login, the user sees their character in a single MVP room rendered with HiDPI nearest-neighbor + integer scaling (no blur on retro pixel art); movement uses client-side prediction (wrapping `packages/game-logic`) with server reconciliation when prediction diverges.
    3. The chat HUD accepts input — pressing Enter sends a message; messages from other players appear in the same HUD with sender nameplate.
    4. The asset-pipeline MVP slice (AST-01: BMP→PNG sprite atlas via sharp; one sprite atlas + one background) loads via a content-hashed manifest at client boot.
    5. **MVP GATE (CLI-08)**: Two players in the same Chrome window pair (or two separate machines) join the same room over the deployed server, see each other move smoothly, and exchange chat messages — demonstrated end-to-end. **This is THE go/no-go milestone for the entire rebuild.**
    6. Disconnect + reconnect within the grace window (closing the tab and re-opening) restores the player to the same room with the same character.
  **Plans**: 9 plans (0/9 complete)
  - [x] 06-01-PLAN.md - Wave 1 — apps/client + tools/asset-pipeline scaffold + Wave 0 RED test stubs (CLI-01, CLI-09)
  - [x] 06-02-PLAN.md - Wave 2 — tools/asset-pipeline bootstrap + build implementation; pipeline-manifest.json deterministic emit (AST-01, CLI-07)
  - [x] 06-03-PLAN.md - Wave 2 — packages/protocol PROTOCOL_VERSION 2 bump + apps/server D-07/D-08/D-09 patches (CLI-02, CLI-04, CLI-09)
  - [x] 06-04-PLAN.md - Wave 3 — BootScene + LoginScene + auth client SDK + atlas loader + GameScene placeholder (CLI-01, CLI-03, CLI-06, CLI-07)
  - [x] 06-05-PLAN.md - Wave 4 — apps/client/src/net colyseus-client + ReconnectBanner + reconnect state machine (CLI-02, CLI-09)
  - [x] 06-06-PLAN.md - Wave 4 — Prediction + Reconcile + Extrapolator + InputDispatcher; advanceAccumulator hoist (CLI-04)
  - [x] 06-07-PLAN.md - Wave 5 — Full GameScene wiring: ChatHUD + ForceResetOverlay + RoomRenderer + PlayerRenderer + Nameplate + roomLayoutVerify (CLI-04, CLI-05, CLI-06, CLI-07)
  - [x] 06-08-PLAN.md - Wave 6 — Playwright cli-08 GREEN + lint-asset-pipeline + lint-vite-env + deploy-staging.yml extension (CLI-08, CLI-07, AST-01)
  - [ ] 06-09-PLAN.md - Wave 7 — verify-phase-6.mjs composite gate + 06-HUMAN-UAT.md milestone artifact (cross-cutting CLI-01..09 + AST-01)
  **UI hint**: yes
  **Hard milestone**: CLI-08 — entire rebuild gates on this. Do not advance to Phase 7 until demonstrated.
  **Research flag**: Standard pattern. NEEDS DEEPER RESEARCH only if the Phase 2 ADR flips to PixiJS (then `@pixi/tilemap` + `howler` + scene wiring per SUMMARY.md).

  ```

  ## Requirements In Scope (Phase 6 CLI-* + AST-01)
  ```markdown
  - [x] **CLI-01**: `apps/client` is a Vite-built, TypeScript Phaser 3 (or PixiJS per CDOC-04 ADR) bundle running in Google Chrome desktop
  - [x] **CLI-02**: Client connects to deployed Fly.io server over WSS binary frames, completes handshake including PROTOCOL_VERSION check
  - [ ] **CLI-03**: Login screen accepts username + password, authenticates via server, transitions to game on success
  - [x] **CLI-04**: Movement uses client-side prediction (wrapping `packages/game-logic`) + entity interpolation (remote players) + server reconciliation when prediction diverges
  - [x] **CLI-05**: Chat HUD lets player type a message; pressing Enter sends it; messages from other players appear in the same HUD with sender nameplate
  - [x] **CLI-06**: HiDPI rendering uses nearest-neighbor + integer scale (no blur on retro pixel art)
  - [x] **CLI-07**: Asset pipeline produces at least one sprite atlas + one background for the MVP room; client loads via content-hashed manifest
  - [x] **CLI-08**: **MVP GATE**: Two players in the same Chrome window pair (or two separate machines) join the same room, see each other move smoothly, exchange chat messages — multiplayer over the deployed server, end-to-end. Demonstrable.
  - [x] **CLI-09**: Disconnect/reconnect: closing the tab and re-opening within the grace window restores the player to the same room with the same character

  ### Asset Pipeline (cross-stage, completed by Stage 7)

  - [x] **AST-01**: `tools/asset-pipeline` converts extracted BMP sprites to PNG atlases (sharp library); per-sprite metadata preserved
  ```

  ## CONTEXT.md (user decisions)
  ```markdown
  # Phase 6: Client Rebuild — MVP Gate (CLI-08 HARD MILESTONE) - Context

  **Gathered:** 2026-05-09
  **Status:** Ready for planning

  <domain>
  ## Phase Boundary

  Stand up `apps/client` — a Vite-built TypeScript Phaser 3.90 bundle running in Chrome desktop — that connects to the deployed Fly.io server (`wss://staging.rebno.decidel.com/colyseus`, then `wss://rebno.decidel.com/colyseus`) over WSS, authenticates via Better-Auth, runs `packages/game-logic` `step()` for client-side prediction with server reconciliation, renders the `mvp-lobby` room with HiDPI nearest-neighbor + integer scaling, and lets two players in the same room see each other move and exchange chat — demonstrated end-to-end. **CLI-08 is THE go/no-go for the rebuild.**

  In scope:
  - **`apps/client/`** workspace package (Vite 8 + Phaser 3.90 + TypeScript strict). Per-env builds via `pnpm --filter @rebno/client build:staging|build:prod` (carries forward Phase 5 D-19); env files (`.env.staging`, `.env.prod`) hold `VITE_WSS_URL`, `VITE_HTTP_BASE`, `VITE_ROOM_SIGNING_PUBKEY`. **Same-Fly-app hosting** — Vite build outputs to `apps/server/public/` (or equivalent path served by Express); single domain per env eliminates CORS, allows `SameSite=Strict` cookies (CLI-01).
  - **Login + session flow (CLI-02, CLI-03)** — hybrid Phaser scene + DOM-overlay form (DOM `<form>` rendered via `Phaser.GameObjects.DOMElement` over a retro Phaser backdrop). On boot, a `BootScene` calls `GET /api/auth/me`; valid session → ~500 ms spinner → transition to `GameScene`; missing/expired → focused login form. Errors surfaced inline (bad pw, `PROTOCOL_VERSION_MISMATCH`, `SERVER_DRAINING`). Force-reset users: server emits `s2c.force_password_change` after `onJoin`; client opens an in-room password-change overlay (movement frozen, no disconnect-reconnect cycle — Phase 4 D-07 carryover).
  - **Movement + prediction (CLI-04, CLI-06)** — `GameScene` wraps `packages/game-logic` `step()` for client-side prediction. Reconciliation is **threshold-gated** — small divergence (< ~half-tile, planner picks concrete number from 44 × 40 px tile) lerps over ~100 ms while replaying unacked inputs; large divergence hard-snaps. Remote-player rendering uses **snapshot interpolation with extrapolation on stale (>200 ms) snapshots**, extrapolated by running deterministic `step()` locally for remote players using their broadcast input axes (see D-08 — wire schema expands to carry remote inputs). HiDPI: Phaser `Scale.FIT` with `autoRound: true` + `Phaser.Textures.FilterMode.NEAREST` everywhere. Camera follows local player.
  - **Chat HUD (CLI-05)** — Minecraft-pattern: chat is a non-modal overlay (DOM div absolutely positioned over Phaser canvas). Default state shows last N messages fading out; **Enter or T** opens chat input + freezes movement keys; Enter sends, Esc cancels and releases focus. Remote-player nameplates rendered above sprite via Phaser text or DOM (planner picks). Phase 4 D-02 wire (`s2c.chat_msg`, `c2s.chat_send`) consumed unchanged.
  - **Reconnect UX (CLI-09)** — top-banner notification ("Reconnecting…") on WS drop. Local input continues to be predicted but **world-mutating actions** (item acquisition, message-board posts, etc. — none in MVP scope but affordance baked in) are blocked while disconnected. Auto-retry every 1 s. Within 10 s grace → Colyseus `allowReconnection` resumes same session, server-authoritative position restored. After 10 s → silent re-auth from cookie. New session (sign-out + sign-in or full session expiry) → spawn at home portal, NOT last position. Banner clears on success; final failure shows "Disconnected — click to retry."
  - **Asset pipeline (AST-01, CLI-07)** — `tools/asset-pipeline/` standalone Node CLI (Phase 1 D-17 pattern; outside the workspace). Two modes: (a) `bootstrap` — reads `extracted/client-5-8/sprites/<id>/<frame>.bmp` + `meta.json`, decodes via sharp, emits per-sprite Aseprite-export-compatible PNG strips + JSON sidecars at `assets/source/sprites/<sprite_id>.{png,json}` (one-shot; output committed so devs can edit `.aseprite` source files locally and re-export); (b) `build` — packs `assets/source/**` into a single multi-sprite atlas (`atlas-mvp.png` + Phaser-format `atlas-mvp.json`) plus a `background-mvp.png`, writes `tools/asset-pipeline/output/pipeline-manifest.json` with rich per-sprite metadata (frames, origin, hashes) referencing Vite-hashed paths from Vite's own `manifest.json` after build. Phaser loads via `this.load.atlas('mvp', …)`. MVP slice scope: NaviStandD/U/R + NaviRunD/U/R (player) + Tile1, TSide1, VBorder, HBorder (room walls/floor) + one background. Sourced from `extracted/client-5-8/sprites/0000-NaviStandD/` etc.
  - **Room rendering** — `apps/server/rooms/mvp-lobby/000.json` arrives via `s2c.room_layout`, signature verified against `import.meta.env.VITE_ROOM_SIGNING_PUBKEY`, then rendered via Phaser `TilemapLayer` (or custom blit if planner finds Phaser tilemap awkward for the canonical layout JSON — see Claude's discretion). Hot-reload from server (Phase 4 D-10): on receiving a fresh `s2c.room_layout` for the current room, client re-builds the tilemap in place; player position preserved.
  - **Wire / protocol coordination** — Phase 6 plan **expands `packages/protocol`** to broadcast remote players' last-input axes (see D-08). This bumps `PROTOCOL_VERSION` and adds an `input_axes` field to `PlayerState` schema; `apps/server` tick loop emits the held input vector per account. Coordinated re-deploy ritual per Phase 4 D-03 (server before client).
  - **Per-env Vite bundles** (Phase 5 D-19 carryover) — `pnpm --filter @rebno/client build:staging` injects staging WSS URL + staging Ed25519 pubkey; `:prod` injects prod equivalents. Bundle ships zero static layout data per Phase 4 SRV-13.
  - **CLI-08 verification harness** — Both: (a) **Playwright two-client smoke** in `pnpm verify:phase-6` + `.github/workflows/deploy-staging.yml` (extends Phase 5 D-11 soak) — spawns 2 headless Chromium contexts, logs in as 2 accounts, asserts move + chat round-trip + nameplates visible; gates merge to `main`. (b) **Manual UAT** recorded in `06-HUMAN-UAT.md` with screenshot/video evidence — milestone artifact for "we shipped CLI-08." Same UAT pattern as Phase 5.
  - **Lints** — `lint-asset-pipeline.mjs` (manifest schema validation + atlas integrity), `lint-vite-env.mjs` (`.env.staging`/`.env.prod` carry required `VITE_*` keys, no secrets), wired into `pnpm verify:phase-6`.
  - **CSP + HiDPI + Chrome-first runtime guarantees** — strict CSP headers via Express on `/`, `<canvas image-rendering: pixelated>` fallback, integer-scale snap on `window.resize`. Best-effort other browsers, blocking only Chrome desktop.

  Out of scope (belongs in Phase 7 or later):
  - AST-02 (MIDI→OGG via ffmpeg + vintage soundfont), AST-03 (BMP fonts → WOFF2), AST-04 (full content-hashed manifest spanning audio + fonts) — Phase 7. Phase 6 ships silent (no audio in `extracted/client-5-8/` per ADR 0001).
  - Full room set + pixel-diff verification — Phase 7 PAR-03. Phase 6 ships exactly `mvp-lobby` through Phase 4 SRV-13 contract.
  - Multi-room transitions, friends list + presence, whispers, account recovery, account-level settings — Phase 7 PAR-04/PAR-06.
  - `.bnu` per-user transactional character migration — Phase 7 PAR-05.
  - Modernized admin web UI — Phase 7 PAR-07.
  - Full chat surface (whispers, channels, ignore/block, profanity wordlist, history rolling buffer) — Phase 7 PAR-04. Phase 6 ships only `chat_send`/`chat_msg` broadcast.
  - Mobile / native ports — out of scope per PROJECT.md.
  - Behavioural anti-cheat beyond authoritative-server model — out of scope per PROJECT.md.
  - Pre-game lobby, character creation, settings menu, message-board reader — Phase 7.
  - World-mutating action surfaces (item acquisition, message-board posts) — Phase 7. Phase 6 only bakes the **affordance** (block these while reconnecting); none ship in MVP scope.

  </domain>

  <decisions>
  ## Implementation Decisions

  ### Login + session UX (CLI-02, CLI-03)

  - **D-01 (hybrid Phaser scene + DOM-overlay form):** `LoginScene` renders retro pixel-art backdrop (Phaser); login form is a DOM `<form>` mounted via `Phaser.GameObjects.DOMElement`. Browser password-manager + autofill + accessibility (screen readers, autofill, paste) all work. Errors render inline below the form (`PROTOCOL_VERSION_MISMATCH`, bad credentials, `SERVER_DRAINING`).
  - **D-02 (autologin → ~500 ms spinner on login scene):** `BootScene` calls `GET /api/auth/me` on app boot. Valid session → show `LoginScene` with spinner + "Reconnecting as `<username>`…" for ~500 ms (visible feedback even on fast paths) → transition to `GameScene`. Invalid/expired → focused login form. User can hit Esc during the spinner to cancel and re-enter credentials. Frictionless steer (Phase 4 D-07) preserved without making the login screen "flash."
  - **D-03 (in-room force-reset overlay):** On `s2c.force_password_change` after `onJoin`, `GameScene` opens a non-dismissible password-change overlay (DOM form): movement keys frozen, scene rendered behind, `POST /api/auth/change-password`, on success server clears `accounts.force_reset` (Phase 4 D-08), overlay closes, normal play resumes — **no disconnect-reconnect cycle**.

  ### Chat HUD (CLI-05)

  - **D-04 (Minecraft-pattern non-modal overlay):** Chat is a DOM div absolutely positioned over the Phaser canvas. Default: latest N messages render bottom-left, fading out after a few seconds. **Enter or T** opens the chat input field + freezes movement keys (input field becomes focus-target so browser stops bubbling keystrokes to Phaser). **Enter** sends + closes; **Esc** cancels + closes; either way, movement keys re-engage. Visual indicator (e.g. cursor blink, distinct background) shows "chat-mode" active.
  - **D-05 (remote-player nameplates):** Always-on text label above remote-player sprites showing their `username`. Form factor (Phaser canvas text vs DOM overlay) is planner's call — both work; canvas text is cheaper, DOM gives crisper anti-aliasing.

  ### Reconnect UX (CLI-09)

  - **D-06 (top-banner reconnect):** WS drop → top-of-viewport yellow/orange banner: "Reconnecting…" with auto-retry every 1 s. Local prediction continues so the player's character stays responsive on screen. **World-mutating actions blocked** while disconnected (Phase 6 ships none, but the affordance is baked into the input/intent layer for Phase 7). Within Phase 4's 10 s grace → `allowReconnection` reuses session token; server position is authoritative on rejoin (no client-claim trust). Past 10 s → silent re-auth from cookie. Banner clears on success. Final failure (cookie also expired or repeated server unreachable) → "Disconnected — click to retry" clickable banner.
  - **D-07 (no cross-session location persistence — home-portal spawn on fresh login):** **Refines Phase 4 D-14.** Position persists in `characters.x/y/room_id` only for grace-window reconnect; on new session (full sign-out + sign-in, or session-expiry-and-rejoin), server spawns the player at their home portal, ignoring `characters.x/y`. REBNO is exploration-driven; travel itself is the progression, so per-session reset is intentional. **Server-side change required** (Phase 4 D-14 amended): `onJoin` for a new Better-Auth session emits home-portal position, NOT the persisted `characters.x/y`. Anti-cheat trivially satisfied — server is authoritative; client never claims position.

  ### Movement prediction + reconciliation (CLI-04, CLI-06)

  - **D-08 (schema expansion — server broadcasts remote-player input axes):** **Refines Phase 4 D-01.** `PlayerState` schema (`packages/protocol`) gains `input_axes` (last received c2s.input axes for the player). Server tick-loop populates from per-account stored input vector; `@colyseus/schema` delta-syncs. Bumps `PROTOCOL_VERSION`. Coordinated server-before-client deploy (Phase 4 D-03 ritual). Enables clients to run deterministic `packages/game-logic` `step()` for **remote players** during stale-snapshot extrapolation, so extrapolated positions stay collision-correct against the local signed-room layout.
  - **D-09 (event-driven c2s.input — refines Phase 4 D-02):** Client sends `c2s.input` 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; tick loop reads stored value each step. Heartbeat-coupled refresh (every 15 s — Phase 4 D-22 heartbeat cadence) reaffirms held inputs to survive dropped keyup packets. Lowers wire traffic; lowers Phase 4 D-22 input rate-limiter need (planner may relax `input` budget from 25/s burst 35).
  - **D-10 (threshold-gated reconciliation):** Local prediction error vs server snapshot:
    - `divergence < ~22 px` (≈ half a 44 × 40 tile, planner locks concrete number): smooth lerp toward server position over ~100 ms, replaying unacked inputs.
    - `divergence ≥ threshold`: hard snap to server position; no lerp.
    Avoids visible rubber-banding on big corrections while keeping micro-corrections invisible. Standard Source/Overwatch netcode pattern.
  - **D-11 (snapshot interp + step()-driven extrapolation for remote players):** Remote players render at ~100 ms backbuffer, interpolating between received state snapshots. If snapshot stale > 200 ms: extrapolate by running `packages/game-logic` `step()` locally for that player using their broadcast `input_axes` (D-08), with collision against the local signed-room layout — bounded, never floats through walls. Cap extrapolation horizon at 250 ms; thereafter, freeze the sprite. On next valid snapshot, snap-or-lerp by D-10's threshold rule.
  - **D-12 (HiDPI nearest-neighbor + integer scale):** Phaser `Scale.FIT` with `autoRound: true`; `Phaser.Textures.FilterMode.NEAREST` set on every loaded atlas; CSS `image-rendering: pixelated` as belt-and-suspenders. Snap to integer scale on `window.resize`. Closes CLI-06.

  ### Asset pipeline (AST-01, CLI-07)

  - **D-13 (`tools/asset-pipeline/` standalone CLI):** New Phase 6 tool, parallel to `tools/extract-gmd`, `tools/asset-catalog`, `tools/protocol-doc`, `tools/save-format-doc`, `tools/room-converter`. **Outside the pnpm workspace** (Phase 1 D-17 / Phase 2 D-15 boundary preserved). Reused by Phase 7 AST-02..04 (audio + fonts + manifest expansion).
  - **D-14 (two-mode pipeline — bootstrap + build):**
    - `pnpm asset-pipeline bootstrap` (one-shot): reads `extracted/client-5-8/sprites/<id>/<frame>.bmp` + companion `meta.json` (origin, frame_count) via sharp, emits per-sprite Aseprite-export-compatible PNG strips + JSON sidecars at `assets/source/sprites/<sprite_id>.{png,json}`. Output **committed** to git so devs can edit `.aseprite` source files locally, export PNG strips back to `assets/source/sprites/`, re-run `build`, and ship without re-running `bootstrap`.
    - `pnpm asset-pipeline build`: reads `assets/source/**`, packs into a single multi-sprite atlas (`atlas-mvp.png` + Phaser-format `atlas-mvp.json` with `{frames: {sprite_id_frame_n: {x, y, w, h}}, anchors: {sprite_id: {x, y}}}`) plus per-room `background-<room>.png`. Emits `tools/asset-pipeline/output/pipeline-manifest.json` with rich per-sprite metadata (sha256, frame_count, origin, atlas_ref).
  - **D-15 (Aseprite-friendly source format):** `assets/source/sprites/<sprite_id>.png` is a horizontal-strip PNG (Aseprite's canonical export); `assets/source/sprites/<sprite_id>.json` carries `{frame_w, frame_h, frame_count, origin: {x, y}, sprite_id}`. Devs edit `.aseprite` files (kept in `assets/source/sprites/aseprite/<sprite_id>.aseprite`, gitignored or committed per repo size policy — planner picks), export to the canonical PNG strip + JSON via Aseprite CLI (`aseprite -b sprite.aseprite --sheet sprite.png --data sprite.json --format json-array`).
  - **D-16 (hybrid manifest = Vite hashes + asset-pipeline indexes):** Vite handles file hashing + cache-busting (writes `dist/manifest.json` with content-hashed paths); `tools/asset-pipeline` post-processes `dist/manifest.json` into a richer `pipeline-manifest.json` indexing per-sprite metadata against Vite-hashed paths. Client fetches `pipeline-manifest.json` on `BootScene` startup, then loads atlas. Decouples Phase 7 AST-02..04 (audio + fonts) expansion from Vite internals.
  - **D-17 (MVP atlas scope):** Sprites for `mvp-lobby`: NaviStandD/U/R + NaviRunD/U/R (player, ~6 sprites × frame counts) + tile sprites (Tile1, TSide1, VBorder, HBorder). One background image (sourced from the smallest navigable extracted background that fits `mvp-lobby` semantics — planner picks at plan time after `tools/room-converter` outputs are inspected). Atlas budget: 512 × 512 px target (overflow → 1024 × 1024 fallback).

  ### Hosting + CLI-08 verification

  - **D-18 (same Fly app — `apps/server` serves static):** Vite build outputs to `apps/server/public/` (or `apps/server/dist/client/` — planner picks); Express adds `app.use(express.static(...))` mounted **after** `/api/auth/*` routes and **before** the Colyseus WS upgrade path. Single domain per env (`staging.rebno.decidel.com`, `rebno.decidel.com`); zero CORS; cookies `SameSite=Strict` viable. `.github/workflows/deploy-staging.yml` adds a client-build step (`pnpm --filter @rebno/client build:staging`) before the Docker build so the static bundle ships in the image. **Couples** client redeploy to server redeploy — acceptable at MVP scope; revisit at v2 if independent client release cadence becomes valuable.
  - **D-19 (CLI-08 verification = Playwright merge-gate + manual UAT milestone):**
    - **Playwright two-client smoke** — `apps/client/test/cli-08.e2e.test.ts` spawns 2 headless Chromium contexts via `@playwright/test`, logs in as 2 accounts (seeded via `pnpm migrate:legacy-accounts` ritual on staging), navigates to `mvp-lobby`, drives WASD on each, asserts: (a) each client sees the other's nameplate + sprite, (b) chat round-trip (client A sends "hello", client B receives with sender=A), (c) reconnect grace works (kill ws on A, reopen, A's character resumes). Wired into `pnpm verify:phase-6` + `.github/workflows/deploy-staging.yml` post-deploy step (extends Phase 5 D-11 soak harness). Failure marks staging deploy red; gates merge to `main` (closes CLI-08).
    - **Manual UAT (`06-HUMAN-UAT.md`)** — operator opens 2 Chrome tabs against `staging.rebno.decidel.com`, repeats the Playwright scenario by hand, captures screenshot + screen-recording video. Logged as the milestone artifact ("we shipped CLI-08"). Mirrors Phase 5's HUMAN-UAT.md pattern.

  ### Carried forward from prior phases (locked, not re-decided)

  Every prior `D-*` decision stands. Phase 6 explicitly relies on:
  - **ADR 0001** (`docs/adr/0001-client-engine.md`) — Phaser 3.90 locked. No engine flip.
  - **Phase 4 D-01..D-04** wire/state model (Colyseus Schema state-diff + msgpackr events + zod intents + `PROTOCOL_VERSION` first byte of `c2s.auth`). Phase 6 D-08 amends `PlayerState` to add `input_axes`; protocol bump.
  - **Phase 4 D-05..D-08** auth flow (Better-Auth `/api/auth/sign-in/email` returns `session_token`; client opens `wss://server/colyseus?token=<bearer>`; cookie-backed silent reconnect; in-room force-reset overlay; `legacy_credentials_staging` → `accounts` rehash on first login).
  - **Phase 4 D-10..D-13** room-layout signing + hot-reload (`s2c.room_layout`, Ed25519 verify against `VITE_ROOM_SIGNING_PUBKEY`, hot-swap on file change preserving player state). Phase 6 client implements verify + tilemap rebuild on hot-swap.
  - **Phase 4 D-20** `packages/game-logic` purity (no `Date.now`, no `Math.random`, no I/O). Phase 6 wraps `step()` for client prediction without violating the lint.
  - **Phase 4 D-22** rate limiter (token-bucket per `(account_id, msg_type)`). Phase 6 may relax `input` budget given D-09 event-driven cadence (planner decides).
  - **Phase 5 D-02** domains (`staging.rebno.decidel.com`, `rebno.decidel.com`).
  - **Phase 5 D-04** staging access (Fly proxy IP allowlist + `STAGING_INVITE_TOKEN` middleware). Phase 6 client must accept `?invite=<token>` in WSS connect URL when `import.meta.env.VITE_STAGING_MODE === '1'`.
  - **Phase 5 D-19** Ed25519 keypair lifecycle — client embeds `VITE_ROOM_SIGNING_PUBKEY` extracted from prod machine via `fly ssh` per env at build time.
  - **Phase 2 D-04** `mvp: yes|no` tagging in `docs/extracted-engine/` — Phase 6 cherry-picks `mvp:yes` features only; everything else is Phase 7.

  ### Claude's Discretion

  User answered every surfaced gray area; deferred two specific sub-questions to best judgment (recorded above as decisions):
  - **D-13** asset-pipeline tool home → standalone (`tools/asset-pipeline/`, Phase 1 D-17 pattern preserved).
  - **D-16** manifest format → hybrid (Vite handles hashing, asset-pipeline emits richer pipeline-manifest.json).

  Other discretion items inside selected areas:
  - **D-05** remote-player nameplate form-factor (Phaser canvas text vs DOM overlay) — planner picks during plan time after a quick perf comparison.
  - **D-10** divergence threshold concrete value (~22 px is the recommendation given 44 × 40 tile size; planner may pick 24 / 20 / 16 based on prediction-error histograms from Phase 5 staging soak data).
  - **D-11** extrapolation cap horizon concrete value (~250 ms recommendation; planner may tune given measured RTT distribution).
  - **D-15** `.aseprite` source storage location (committed under `assets/source/sprites/aseprite/` vs gitignored) — planner picks per repo size policy.
  - **D-17** MVP background image — selected at plan time after `tools/room-converter` discovers `mvp-lobby` dimensions.
  - **D-18** static-mount path (`apps/server/public/` vs `apps/server/dist/client/`) — planner picks at plan time.
  - Room-rendering — Phaser `TilemapLayer` is the recommendation; planner may flip to custom blit if `TilemapLayer` proves awkward for the canonical layout JSON shape from Phase 4 D-09.
  - Phase 6 plan **must** coordinate D-07 (server-side spawn semantics) + D-08 (schema expansion) + D-09 (event-driven inputs) into a small set of `apps/server` + `packages/protocol` patches landed before or alongside `apps/client`.

  Override any decision in planning if research or codebase reading surfaces a conflict.

  ### Folded Todos

  None — `gsd-sdk query todo.match-phase 6` returned 0 matches.

  </decisions>

  <canonical_refs>
  ## Canonical References

  **Downstream agents (gsd-phase-researcher, gsd-planner, gsd-pattern-mapper, gsd-executor) MUST read these before planning or implementing.**

  ### Project planning
  - `.planning/PROJECT.md` — Stage 6 Active scope ("MVP gate: two players join the same room, see each other move, exchange chat messages — multiplayer over the new server"); core value statement
  - `.planning/REQUIREMENTS.md` §"Client Rebuild — MVP (Stage 6)" — CLI-01..CLI-09 + AST-01 acceptance text
  - `.planning/ROADMAP.md` §"Phase 6: Client Rebuild — MVP Gate (CLI-08 HARD MILESTONE)" — goal + 6 success criteria + hard-milestone language
  - `.planning/STATE.md` — current position (Phase 05 complete; Phase 06 next)
  - `.planning/research/STACK.md` — Phaser 3.90 + Vite 8 + version pins; Fly.io specifics
  - `.planning/research/FEATURES.md` §"MVP-Critical (Stage 6 gate)" — defines `mvp:yes` cherry-pick scope
  - `.planning/research/ARCHITECTURE.md` — three-pipeline topology; where `apps/client` sits relative to `packages/protocol` + `packages/game-logic`
  - `.planning/research/PITFALLS.md` §B1 (server-authoritative — D-07 anti-cheat foundation), §C1 (HiDPI scale-mode pitfalls)
  - `.planning/codebase/CONCERNS.md` — repo-private-through-Phase-7 reminder; client deploy stays internal until cleared

  ### Prior CONTEXT.md (decisions consumed verbatim)
  - `.planning/phases/02-client-engine-documentation/02-CONTEXT.md` — D-04 `mvp` tagging convention
  - `.planning/phases/04-server-rebuild-mvp/04-CONTEXT.md` — D-01..D-25 (wire/state, auth+Colyseus, room hot-reload, persistence, lints); Phase 6 D-07/D-08/D-09 amend D-14/D-01/D-02 respectively
  - `.planning/phases/05-deploy/05-CONTEXT.md` — D-02 (region/domains), D-04 (staging invite token), D-19 (Ed25519 pubkey extract ritual + per-env Vite bundles)

  ### ADRs (locked decisions — Phase 6 consumes)
  - `docs/adr/0001-client-engine.md` — Phaser 3.90 locked (Phase 6 is where it lands)
  - `docs/adr/0002-persistence-layer.md` — SQLite + Litestream → Tigris (server-side; informs D-07 server-side spawn semantics)
  - `docs/adr/0003-canonical-snapshot.md` — `legacy/servers/enlyzeam-current/` import source (Phase 4 already consumed; Phase 6 has nothing new here)
  - `docs/adr/0004-room-hot-reload.md` — Phase 6 client implements verify + tilemap rebuild on `s2c.room_layout` hot-swap
  - `docs/adr/0005-deploy-topology.md` — two Fly apps + region `lax` (Phase 6 client ships into the same apps per D-18)
  - `docs/adr/0006-observability-stack.md` — OpenObserve self-hosted (Phase 6 client telemetry via OTel browser SDK is **deferred**; Phase 7 / v2 territory)

  ### Phase 1–5 outputs (consumed as input)
  - **Extracted assets:** `extracted/client-5-8/sprites/<sprite_id>/` + `meta.json` — `tools/asset-pipeline bootstrap` source (D-14)
  - `extracted/client-5-8/backgrounds/` — background-image source for D-17
  - `extracted/client-5-8/rooms/` — `tools/room-converter` already consumed for `mvp-lobby` (Phase 4)
  - **Server:** `apps/server/rooms/mvp-lobby/000.{json,sig}` — already seeded (Phase 4 D-12); Phase 6 client renders this layout
  - **Server (extension required):** `apps/server/src/RebnoRoom.{ts,js}` — gains `input_axes` broadcast (D-08); `onJoin` adds home-portal-spawn-on-new-session logic (D-07); `onMessage('input')` switches from per-tick to event-driven storage (D-09)
  - **Protocol:** `packages/protocol/src/state.ts` (or equivalent) — `PlayerState` schema gains `input_axes`; `PROTOCOL_VERSION` bumps (D-08)
  - **Protocol:** `packages/protocol/src/intents.ts` — `c2s.input` zod schema updated to event-driven shape `{seq, axes, buttons_down, buttons_up, monotonic_at_ms}` (D-09)
  - **Game-logic:** `packages/game-logic/src/step.ts` — pure `step(state, inputs, dt)` reused **as-is** for client-side prediction + remote-player extrapolation (D-11). No new game-logic in Phase 6.
  - **Tools:** `tools/asset-catalog/output/index.json` — sprite metadata cross-referenced during D-14 bootstrap step
  - **Tools:** `tools/room-converter/` — informs `mvp-lobby` JSON shape; Phase 6 doesn't call it at runtime
  - **Verification:** `apps/server/test/authority.integ.test.ts` + `apps/server/test/reconnect.integ.test.ts` (Phase 4) — Playwright two-client harness pattern reused (D-19)
  - **Verification:** `scripts/soak-staging.mjs` (Phase 5 D-11) — Phase 6 Playwright smoke extends this harness
  - **CI:** `scripts/verify-phase-5.mjs` + `.github/workflows/deploy-staging.yml` (Phase 5) — Phase 6 mirrors shape for `verify-phase-6.mjs` + adds client-build step

  ### Reverse-engineering wiki + extracted-engine docs
  - `docs/extracted-engine/README.md` — task-keyed jump table for engine subsystems
  - `docs/extracted-engine/MATRIX.md` + `MATRIX-rows.json` — engine choice rationale (informs Phaser feature usage in Phase 6)
  - `docs/extracted-engine/rendering.md` — `mvp:yes` rendering primitives Phase 6 must implement
  - `docs/extracted-engine/input.md` — keyboard event handling on the original (informs Phase 6 input layer)
  - `docs/extracted-engine/scene-room-model.md` — original room model; informs Phaser scene wiring + camera follow
  - `docs/extracted-engine/animation.md` — sprite frame/animation conventions for atlas packing (D-14 sidecar JSON shape)
  - `docs/extracted-engine/ui-and-menus.md` — chat HUD overlay pattern reference for D-04
  - `docs/extracted-engine/asset-catalog/index.json` — sprite cross-reference for AST-01 MVP-scope picks
  - `decomp/wiki/15-extraction-pipeline.md` — context for what `extracted/client-5-8/` carries
  - `decomp/wiki/16-bno-bnb-notes.md` — context only (Phase 7 PAR-05 territory; Phase 6 doesn't touch `.bnu`)

  ### External implementation references
  - Phaser 3.90 docs (`phaser.io/docs/3.90`) — Scene lifecycle, `Scale.FIT` + `autoRound`, `DOMElement`, `TilemapLayer`, `Tweens` for D-10 lerp, `Textures.FilterMode.NEAREST` for D-12
  - Colyseus 0.17 client docs (`docs.colyseus.io/colyseus/getting-started/javascript-client/`) — `Client.joinOrCreate`, room `state.onChange`, message handlers, `client.room.send()`, reconnect tokens
  - Vite 8 docs (`vitejs.dev/guide/`) — env modes, `import.meta.env`, manifest output, asset hashing, multi-env build via `--mode`
  - msgpackr browser bundle (`github.com/kriszyp/msgpackr#browser`) — encoder/decoder reuse on client side
  - zod docs — schema sharing between server (Phase 4) + client (Phase 6 reuses zod intents for type-safe send paths)
  - @colyseus/schema docs — client-side `state.onChange` + `MapSchema.onAdd/onChange/onRemove` patterns for player join/leave/move
  - Better-Auth docs (`better-auth.com`) — client SDK (`createAuthClient`) for `/api/auth/sign-in/email`, `signOut`, `me`, cookie semantics; `SameSite` config
  - Playwright docs (`playwright.dev`) — `@playwright/test` multi-context patterns for D-19 two-client harness
  - sharp docs (`sharp.pixelplumbing.com`) — BMP decode + PNG encode for D-14 bootstrap; lossless settings
  - Aseprite CLI docs (`aseprite.org/docs/cli/`) — canonical export to PNG strip + JSON-array sidecar
  - TexturePacker / `free-tex-packer` / sharp-bin-pack — atlas packing algorithm reference for D-14 build mode
  - HiDPI canvas + integer-scale references (`webgl-fundamentals.org/webgl/lessons/webgl-resizing-the-canvas.html`, Phaser `Scale.Manager`) — D-12

  ### What NOT to read in Phase 6
  - `legacy/servers/{enlyzeam-archive,local-current}/` — non-canonical per ADR 0003
  - `legacy/source-archive/` — older Client revisions, reference only
  - `legacy/unity-project/`, `legacy/maya-project/` — abandoned remake attempts; not authoritative
  - Modern decompilers (UTMT, Altar.NET) — incompatible with GM 5.3a (PITFALLS A1)
  - LiteFS / Postgres docs — explicitly out of scope per ADR 0002
  - Phaser 4 docs — explicitly rejected for MVP per ADR 0001 (Phase 7 retro re-evaluates)
  - PixiJS docs — engine choice locked Phaser 3.90 per ADR 0001

  </canonical_refs>

  <code_context>
  ## Existing Code Insights

  ### Reusable Assets

  - **`packages/game-logic/src/step.ts`** (Phase 4 D-20) — pure `step(state, inputs, dt) → state`; runs in browser unmodified. Phase 6 wraps it twice: (a) for the local player's prediction loop, (b) for remote-player extrapolation when snapshots go stale (D-11). Lint-enforced purity (no `Date.now`, no `Math.random`, no I/O) means it's safe in a Phaser scene update without breaking determinism.
  - **`packages/protocol/src/{state,intents,events}.ts`** (Phase 4) — Colyseus Schema classes, zod intents, msgpackr event types. Phase 6 imports browser-side; bumps `PROTOCOL_VERSION`; extends `PlayerState` (D-08) and `c2s.input` (D-09).
  - **`apps/server/src/RebnoRoom.js`** + `apps/server/src/onMessageHandlers.js` (Phase 4) — already handle `c2s.input`, `c2s.chat_send`, `c2s.heartbeat`, `c2s.room_join`. Phase 6 adds `input_axes` broadcast (D-08), event-driven input semantics (D-09), home-portal-spawn-on-new-session in `onJoin` (D-07).
  - **`apps/server/rooms/mvp-lobby/000.{json,sig}`** — already seeded (Phase 4 D-12). Phase 6 client consumes via `s2c.room_layout`.
  - **`apps/server/src/health.ts`** (Phase 4 D-21) + Phase 5 Express boot — Phase 6 adds `app.use(express.static(...))` mount for the Vite client bundle (D-18).
  - **`apps/server/src/auth-bootstrap.js`** + Better-Auth Express routes (Phase 4 D-05) — Phase 6 client SDK calls `/api/auth/sign-in/email`, `/api/auth/me`, `/api/auth/sign-out`, `/api/auth/change-password` with same-origin cookies.
  - **`extracted/client-5-8/sprites/0000-NaviStandD/` etc.** + `meta.json` — `tools/asset-pipeline bootstrap` source for AST-01 (D-14). 854 sprites total; MVP scope ~10 picks.
  - **`docs/extracted-engine/asset-catalog/index.json`** (Phase 2) — sprite metadata cross-reference; informs MVP-pick decisions (D-17).
  - **`tools/extract-gmd/src/types.ts`** — canonical extraction types reused by `tools/asset-pipeline`.
  - **`scripts/verify-phase-5.mjs`** + `.github/workflows/verify-phase-5.yml` (Phase 5) — Phase 6 mirrors shape for `verify-phase-6.mjs` (composite gate including Playwright two-client smoke + asset-pipeline lint + Vite env lint + trace:check).
  - **`scripts/soak-staging.mjs`** (Phase 5 D-11) — Phase 6 Playwright smoke (D-19) extends harness; reuses staging-deploy infrastructure.

  ### Established Patterns

  - **TS-everywhere** (PROJECT.md). `apps/client` is TypeScript-strict, Vite-built.
  - **pnpm workspace** (Phase 4 D-18). `apps/client` becomes the second `apps/*` workspace member; existing `tools/*` stay standalone (Phase 1 D-17 / Phase 2 D-15 boundary preserved → `tools/asset-pipeline` follows suit per D-13).
  - **Headless, CI-runnable** (Phase 1 D-01) — Phase 6 verification surface (Playwright headless Chromium, asset-pipeline lints, Vite env lint) runs on `ubuntu-latest` per Phase 5 precedent.
  - **Reproducibility-first** (Phase 1 D-15) — `tools/asset-pipeline build` output deterministic (sorted keys in manifest, pinned PNG encoder settings via sharp).
  - **Lint-as-forcing-function** (Phase 2/3/4/5 D-2X pattern) — `lint-asset-pipeline.mjs` (manifest schema + atlas integrity), `lint-vite-env.mjs` (`.env.staging`/`.env.prod` carry required `VITE_*` keys, no secrets), wired into `pnpm verify:phase-6`.
  - **Mvp tagging** (Phase 2 D-04) — Phase 6 cherry-picks `mvp:yes` features only.
  - **Anti-port reference** — Phase 6 ships **none** of the original `Ctrl+E` clipboard RCE / `Ctrl+Q` inspect / `Ctrl+O` codes admin model. Phase 7 PAR-07 fills the modernized admin UI; Phase 6 client has zero admin surface.
  - **ADR-on-first-lock** — Phase 6 produces no new ADR; ADR 0001 already locks the engine, ADR 0004 already locks hot-reload. If D-07/D-08/D-09 prove load-bearing in Phase 7 retro, an ADR captures the contract then.

  ### Integration Points

  - **Inputs:**
    - All prior phases' outputs (workspace + tools + extracted assets + signed-room layout + ADRs + research).
    - Phase 4 server runtime (extended with D-07/D-08/D-09 patches landed in Phase 6 plan).
    - Phase 5 deployed staging + prod Fly apps + per-env domains.
    - `legacy/open-source-release/BN Online Client 5-8.gmd` (already extracted; Phase 6 reads the extracted PNG/BMP outputs only).
  - **Outputs:**
    - `apps/client/` — full Vite + Phaser + TS app (`src/scenes/`, `src/net/`, `src/prediction/`, `src/ui/`, `src/auth/`, `src/assets/`, `src/main.ts`, `index.html`, `vite.config.ts`, `package.json`)
    - `apps/client/.env.staging` + `apps/client/.env.prod`
    - `apps/client/test/cli-08.e2e.test.ts` — Playwright two-client smoke (D-19)
    - `tools/asset-pipeline/` — new standalone Node CLI (D-13/D-14/D-15/D-16)
    - `assets/source/sprites/<sprite_id>.{png,json}` — committed Aseprite-export source files (output of `bootstrap`)
    - `apps/client/dist/` (gitignored) — Vite build artefact; shipped via `apps/server/public/` mount per D-18
    - `apps/server/src/RebnoRoom.js` patch — `input_axes` broadcast (D-08), event-driven input storage (D-09), home-portal `onJoin` (D-07)
    - `packages/protocol/src/state.ts` patch — `PlayerState.input_axes`, `PROTOCOL_VERSION` bump (D-08)
    - `packages/protocol/src/intents.ts` patch — `c2s.input` event-driven shape (D-09)
    - `tools/scripts/lint-asset-pipeline.mjs` + `tools/scripts/lint-vite-env.mjs`
    - `scripts/verify-phase-6.mjs` composite gate
    - `.github/workflows/deploy-staging.yml` extension — client-build step + Playwright smoke
    - `06-HUMAN-UAT.md` — milestone artifact (D-19)
  - **Downstream consumers:**
    - **Phase 7 PAR-01** (full client surface) — extends `apps/client/src/scenes/` with multi-room transitions, friends list, whispers, settings menu, message board reader; reuses `BootScene` + `LoginScene` + `GameScene` skeleton.
    - **Phase 7 PAR-03** (full room set) — uses `tools/room-converter` to populate every room; client tilemap rendering generalises from `mvp-lobby` to N rooms; D-04 hot-reload contract preserved.
    - **Phase 7 AST-02..04** (audio + fonts + content-hashed manifest) — extends `tools/asset-pipeline` per D-13/D-16 hybrid manifest.
    - **Phase 7 PAR-04** (full chat) — extends `apps/client/src/ui/chat/` from D-04 baseline (whispers, channels, ignore/block, profanity wordlist, history rolling buffer).
    - **Phase 7 PAR-05** (`.bnu` per-user character migration) — server-side; client unchanged at the protocol layer because home-portal spawn (D-07) means migrated character location is irrelevant on first login.
    - **Phase 7 PAR-07** (admin UI) — separate web UI; Phase 6 client surface has zero admin affordance.

  </code_context>

  <specifics>
  ## Specific Ideas

  - **CLI-08 is THE go/no-go for the rebuild** (ROADMAP.md). Phase 6 plan writes the Playwright smoke FIRST (TDD shape), then implements `apps/client` against it; CI gate prevents regression while the rest of Phase 6 builds out (D-19).
  - **Minecraft-pattern chat is the explicit user lock** (D-04). Enter or T toggles non-modal overlay; movement keys freeze on chat-mode; nameplate text always-on. Planner must NOT silently swap to a modal-popup chat UI.
  - **Anti-cheat via authoritative server is sufficient** (D-07). Server is authoritative; client never claims position. Reconnect "accept new location securely" = server's last-known x/y for the same session; new session = home portal regardless of client state. No behavioural anti-cheat in v1 (out of scope per PROJECT.md).
  - **Exploration is the progression model** (D-07). Cross-session location is intentionally NOT persisted — travel itself matters. This is the explicit user steer; Phase 4 D-14 (`characters.x/y` persisted on graceful-disconnect) is amended for Phase 6+.
  - **Schema expansion is acceptable Phase 6 cost** (D-08). Adding `PlayerState.input_axes` + bumping `PROTOCOL_VERSION` is preferred over client-only collision-clipped extrapolation, because it lets `packages/game-logic` `step()` drive remote-player extrapolation deterministically against the local signed-room layout. Coordinated server-before-client deploy ritual (Phase 4 D-03).
  - **Event-driven inputs** (D-09) — client sends on keydown/keyup transitions; server holds last-known axis vector. Saves bandwidth, simplifies server tick. Planner must add a heartbeat-coupled "still-pressed" reaffirmation message (every 15 s) to survive dropped keyup packets.
  - **Threshold-gated reconciliation with concrete tile size** (D-10) — tiles are 44 × 40 px; threshold candidate ~22 px (≈ half-tile). Planner picks final number from prediction-error histograms collected during the Phase 5 30-min staging soak (D-11). Invisible micro-corrections, no rubber-banding on big corrections.
  - **Aseprite-friendly asset source** (D-15) — devs edit `.aseprite` source files locally; pipeline reads PNG-strip + JSON-array exports (Aseprite's canonical CLI output). Keeps the 30+ year roadmap of "human dev edits a sprite" cheap.
  - **Same-Fly-app hosting** (D-18) — couples client + server deploy intentionally. MVP simplicity wins; CORS-free; cookies stay `SameSite=Strict`. Revisit at v2 if independent client release cadence becomes valuable.
  - **Playwright merge-gate + manual UAT milestone** (D-19) — CI keeps regression risk near zero; manual UAT provides the human "we shipped CLI-08" artifact + screen-recording video for posterity.
  - **Phaser 4 retro window** (ADR 0001) — Phase 6 ships on Phaser 3.90; Phase 7 retro re-evaluates Phaser 4. Phase 6 plan must not silently jump to Phaser 4.

  </specifics>

  <deferred>
  ## Deferred Ideas

  - **AST-02 (MIDI→OGG via ffmpeg + vintage soundfont) + AST-03 (BMP fonts → WOFF2) + AST-04 (full content-hashed manifest)** — Phase 7. Phase 6 ships silent (no audio in `extracted/client-5-8/` per ADR 0001) + uses Phaser default font. Hybrid manifest (D-16) is shaped to extend cleanly.
  - **Multi-room transitions, friends list + presence, whispers, account recovery, account-level settings** — Phase 7 PAR-04 / PAR-06.
  - **Full chat surface** (channels, ignore/block, profanity wordlist, history rolling buffer, whispers) — Phase 7 PAR-04. Phase 6 ships only `chat_send`/`chat_msg` broadcast on top of the D-04 HUD.
  - **`.bnu` per-user transactional character migration** — Phase 7 PAR-05.
  - **Modernized admin web UI** — Phase 7 PAR-07. Phase 6 client surface has zero admin affordance.
  - **Pre-game lobby, character creation, settings menu, message-board reader** — Phase 7.
  - **Mobile / native ports** — out of scope per PROJECT.md.
  - **OTel browser SDK / client-side telemetry to OpenObserve** — v2 / Phase 7. Phase 6 logs to console only; server-side OTel (Phase 5 D-12..D-16) covers the wire side.
  - **Independent client deploy cadence (separate Fly app for static client)** — v2; D-18 same-Fly-app coupling is acceptable at MVP scope.
  - **Behavioural anti-cheat** — out of scope per PROJECT.md. Authoritative server (D-07) is sufficient for v1.
  - **Pixel-diff verification of MVP room against original screenshots** — Phase 7 PAR-03. Phase 6 ships `mvp-lobby` only; pixel-perfect parity against original is Phase 7 territory.
  - **OAuth/passkey providers, public username changes** — Phase 7 PAR-06 + v2.
  - **Phaser 4 migration** — gated on ADR 0001 retro window (post-CLI-08, pre-Phase-7-PR-01).
  - **Independent input cadence rate-limit budget reduction** — D-09 makes Phase 4 D-22's 25/s burst 35 input budget overkill; planner may relax during Phase 6 plan if it doesn't add friction.

  ### Reviewed Todos (not folded)

  None — `gsd-sdk query todo.match-phase 6` returned 0 matches.

  </deferred>

  ---

  # Gap-Closure Addendum (2026-05-10)

  **Trigger:** 06-HUMAN-UAT.md executed by operator decidel 2026-05-10. All 5 tests block CLI-08 closure. Per user decision, all five gap-closure items scoped to **Phase 6** — no Phase 7 advance until CLI-08 truly green.

  **See:** `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md ## Findings` for finding details + sequencing recommendation.

  <gap_closure_decisions>

  ### Finding #1 — Login screen text invisible (high)

  - **D-23 (small CSS fix; one plan):** LoginScene CSS enforces UI-SPEC color tokens — title + field labels switch from `#000` → `#FFFFFF` (or token foreground). No design discussion needed; UI-SPEC §LoginScene already locks tokens. Add a Playwright contrast assertion (axe-core or simple computed-style check) to prevent regression.

  ### Finding #5 — No logout affordance (medium)

  - **D-24 (Esc menu — also fires on game-view click):** Add an in-GameScene **Esc menu** modal overlay (BNO-faithful command-screen UX feel). Menu options: **Logout** (clears Better-Auth session cookie + returns to LoginScene), **Resume** (closes overlay), **Settings** (placeholder, deferred to Phase 7). Trigger conditions: (a) operator presses Esc, (b) operator clicks the game canvas (single click). Movement keys frozen while overlay open. Logout flow: `POST /api/auth/sign-out` → clear cookie → `LoginScene.start()` (do NOT preserve invite token; user re-enters via fresh `?invite=` URL or saved bookmark). One plan.

  ### Finding #2 — Reconnect / cookie auto-login renders blank GameScene (critical)

  - **D-25 (investigate via /gsd-debug FIRST, then plan from findings):** Root cause unknown. Hypothesis space large (HUD mount idempotency, scene re-entry init, Phaser DOMElement re-create, Better-Auth cookie path bypassing BootScene → GameScene init). Run `/gsd-debug` session to isolate before writing a fix plan. Output: a debug summary doc that the next plan consumes verbatim. Fix plan ships with two new Playwright e2e tests added to 06-08's `cli-08.e2e.test.ts` family: (a) cookie-auto-login → GameScene renders HUD + remotes + self, (b) WS-kill → reconnect → GameScene re-renders HUD + remotes + self.

  ### Finding #3 — Movement does not match BNO (critical)

  - **D-26 (split into TWO plans; numerics first, sprite-state after #4):**
    - **Plan A (no asset dep):** Reverse numeric movement constants from extracted Client 5-8 GML and pin into `packages/game-logic`. Files to read (canonical refs below): `extracted/client-5-8/scripts/0285-player_update.gml`, `extracted/client-5-8/scripts/0347-iv_keyactions.gml`, `extracted/client-5-8/scripts/0355-pcode_mover.gml`, **plus the player object's GML events at `extracted/client-5-8/objects/0000-server/`** (⚠ in original BNO the player character object is named `server` — this is the canonical mover, NOT `0042-player` or any `navi*` script). Derive: walk speed (px/tick), key-buffering window, friction/accel if any, sub-pixel accumulator behavior, tick rate assumption. Update `PredictionEngine` + `step()` to use these constants. Add unit tests pinning the derived values (regression guard for any future tweak). **No asset dependency** — runs alongside #4 in parallel waves.
    - **Plan B (depends on Plan A + Finding #4):** 8-direction sprite-state machine — facing direction (D/U/L/R/DR/DL/UR/UL) + walk-cycle phase derived from velocity vector + cycle counter. Maps onto the player sprite atlases that #4 lands. Frame-perfect to BNO (consult `0000-server` events for facing-on-input semantics). Sequenced AFTER Plan A (numerics) AND #4 (atlases land).

  ### Finding #4 — No real BNO assets — first-pass (critical)

  - **D-27 (player sprites + 1 room = BNCentral):** First-pass real assets =
    - **Player sprite set:** all 8-direction Stand + Run sprites — `extracted/client-5-8/sprites/0027-NaviRunD/`, `0028-NaviRunR/` *(R-facing Run; corrects original enumeration omission flagged by plan-checker 2026-05-10)*, `0029-NaviStandR/`, `0030-NaviStandU/`, `0031-NaviRunU/`, `0032-NaviRunL/`, `0033-NaviStandL/`, `0036-NaviStandDR/`, `0037-NaviRunDR/`, `0038-NaviStandUR/`, `0039-NaviRunUR/`, `0040-NaviStandUL/`, `0041-NaviRunUL/`, `0042-NaviStandDL/`, `0043-NaviRunDL/`, plus the existing `0000-NaviStandD/` (already on disk). Total: 16 sprites (1 already-on-disk + 15 new). Bridge through the existing `tools/asset-pipeline/` plumbing (06-08 built the lint + manifest schema; never piped real input). Add a `bootstrap` mode (per existing CONTEXT D-13 reference) that reads `extracted/client-5-8/sprites/<id>/frames/img_*.bmp` + `meta.json` → emits PNG strips + Aseprite-compatible JSON sidecars at `assets/source/sprites/<id>.{png,json}`. Then `build` mode packs into `atlas-mvp.{png,json}`. Phaser loads via existing PlayerRenderer (replaces blue rectangle).
    - **Room:** `extracted/client-5-8/rooms/0058-BNCentral/` — **the actual latest online lobby iteration** in original BNO (NOT `0004-Online_Lobby`, which is older). Bridge `instances.json` + `tiles.json` + `creation-code.gml` + `backgrounds.json` into a server-emittable room-layout JSON consumable by `apps/server/rooms/<room>/000.json` and the Phase 4 SRV-13 contract + Phase 6 D-12 hot-reload protocol. Replaces the synthetic `mvp-lobby` room used in 06-07. Tile sprites needed for BNCentral come along with the bootstrap (the room's `tiles.json` references sprite IDs already covered by extracted/sprites/).
    - **Replaces** the `mvp-lobby` placeholder. Server room registration updated to use `bn-central` (or planner's preferred slug); Playwright e2e (06-08 + new D-25 tests) updated to assert `data-room-id="bn-central"`.
    - **Nametag (user-supplied 2026-05-10):** original BNO renders player display name as a cyan text label always-on above the sprite head. First-pass: cyan (`#22D3EE` accent already in UI-SPEC, or exact BNO cyan if extractable from `0000-server` draw events / `scr_draw*` text-color calls — verify before locking). Anchored to sprite top-center, follows movement, visible for self + remotes. Belongs to player-render plan (06-16 sprite-state Plan B is the natural home since it owns the per-player render path), not a separate plan. Confirm exact font/size/outline against extracted draw code before commit.
    - First-pass scope deliberately excludes audio (Phase 7 AST-02), fonts (Phase 7 AST-03), and the other 9 extracted rooms (Phase 7 PAR-03).

  ### Sequencing (advisory — locked at /gsd-plan-phase 6 time)

  | Wave | Plan | Finding | Depends on |
  |------|------|---------|------------|
  | 8 | 06-10: LoginScene contrast fix + Playwright assertion | #1 | — |
  | 8 | 06-11: Esc menu logout (incl. game-view click) | #5 | — |
  | 9 | 06-12: /gsd-debug session for reconnect/cookie blank-render | #2 | — |
  | 9 | 06-13: Reverse movement numeric constants from extracted GML (Plan A) | #3a | — |
  | 10 | 06-14: Asset-pipeline bootstrap → atlas-mvp + BNCentral room layout | #4 | — |
  | 11 | 06-15: Reconnect/cookie blank-render fix + 2 e2e tests + stale-dispatcher-room fix | #2 | 06-12 (debug findings) |
  | 11 | 06-16: 8-direction sprite-state machine (Plan B) | #3b | 06-13 + 06-14 |
  | 12 | 06-17: Re-run 06-HUMAN-UAT.md, capture real `06-CLI-08-milestone.mp4` | all | 06-10..06-16 |

  Wave 8 and 9 plans run in parallel within their wave (no shared files). Wave 10 (06-14) sequential — touches asset pipeline + server room registration. Wave 11 plans parallel within wave (different file sets). Wave 12 sequential (final UAT + close-out).

  ### Plan-tagging guidance for /gsd-plan-phase 6

  - 06-10 → `[doc->REQ-CDOC-*]` + `[impl/unit->REQ-CLI-06]` (visual/contrast under HiDPI umbrella) — or expand REQ list if a fresh contrast REQ-ID added.
  - 06-11 → `[impl->REQ-CLI-02]` + `[impl->REQ-CLI-03]` (logout is auth lifecycle).
  - 06-12 + 06-15 → `[impl/int->REQ-CLI-09]` (reconnect grace) + `[impl->REQ-CLI-04]` (re-entry render). 06-15 also folds in the secondary defect surfaced by `/gsd-debug` (`GameScene.ts:238-252` `if (!this.inputDispatcher)` guard keeps stale `room` reference after silent_reauth; movement intents would `room.send` through closed old room). Fix: refresh dispatcher's room ref on every reconnect, not only on first connect. Add unit test: dispatcher `.room` matches `GameScene.room` after silent_reauth cycle.
  - 06-13 + 06-16 → `[impl/unit->REQ-CLI-04]` (movement prediction; numerics + sprite-state).
  - 06-14 → `[impl->REQ-AST-01]` + `[impl->REQ-CLI-07]` (room rendering); evaluate whether AST-02..04 stay Phase 7 or partially absorb here (planner call).
  - 06-17 → `[doc->REQ-CLI-08]` (final milestone artifact).

  </gap_closure_decisions>

  <canonical_refs_addendum>

  ### Added 2026-05-10 (gap-closure)

  - `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md` — operator UAT findings; **MUST read** before planning gap-closure.
  - `extracted/client-5-8/objects/0000-server/` — original BNO player character object (named `server`, NOT `player`); contains the canonical mover events for Finding #3.
  - `extracted/client-5-8/scripts/0285-player_update.gml` — player tick/update logic.
  - `extracted/client-5-8/scripts/0347-iv_keyactions.gml` — input-vector keyactions mapping.
  - `extracted/client-5-8/scripts/0355-pcode_mover.gml` — mover pcode (per-tick movement application).
  - `extracted/client-5-8/rooms/0058-BNCentral/` — first-pass room (the actual latest online lobby iteration, replaces synthetic `mvp-lobby`).
  - `extracted/client-5-8/sprites/0000-NaviStandD/` through `extracted/client-5-8/sprites/0043-NaviRunDL/` — full 8-direction Stand+Run player sprite atlases (BMP frames + meta.json) + tiles + doors needed for BNCentral.
  - `tools/asset-pipeline/` (existing 06-08 plumbing) — bootstrap mode addition needed; `build` mode already shipped.

  </canonical_refs_addendum>

  <gotchas_addendum>

  ### Added 2026-05-10 (gap-closure)

  - **Player object naming:** BNO names the player character object `server` (`extracted/client-5-8/objects/0000-server/`), not `player`. `0042-player` is something else (likely lobby/session-related). Movement-fidelity work MUST consult `0000-server` events.
  - **Room slug:** original BNO uses `BNCentral` (room id 58); the room `0004-Online_Lobby` (id 4) is older. Use `0058-BNCentral` for first-pass real-asset work.
  - **Asset pipeline plumbing exists:** 06-08 shipped `tools/asset-pipeline/` (lint + manifest schema + build mode) but never piped real input. Don't rebuild — extend with `bootstrap` mode that consumes `extracted/client-5-8/sprites/<id>/frames/*.bmp`.
  - **`mvp-lobby` was synthetic:** 06-07 GameScene + Playwright assertions reference `mvp-lobby` room id. Updating to `bn-central` requires coordinated changes in `apps/server/rooms/`, `apps/client/src/scenes/`, and 06-08's `cli-08.e2e.test.ts` selectors.

  </gotchas_addendum>

  ---

  *Gap-closure addendum captured: 2026-05-10*
  *Operator: decidel*
  *Next step: `/gsd-plan-phase 6` to scaffold plans 06-10..06-17 per sequencing table above.*
  ```

  ## HUMAN-UAT.md (defects driving gap-closure)
  ```markdown
  ---
  phase: 6
  slug: client-rebuild-mvp-gate-cli-08-hard-milestone
  status: failed
  last_updated: 2026-05-10
  operator: decidel
  operator_run: 2026-05-10
  ---

  # Phase 6 — Human UAT (CLI-08 Milestone Artifact)

  > Five manual verifications driven by `06-VALIDATION.md §Manual-Only Verifications`. Test 2 (CLI-08) is THE milestone artifact for the rebuild — Capcom-derived BNO has had ~25 years between original and rebuild; this video is the proof.

  [doc->REQ-CLI-01] [doc->REQ-CLI-02] [doc->REQ-CLI-06] [doc->REQ-CLI-08] [doc->REQ-CLI-09] [doc->REQ-AST-01]

  > **2026-05-10 result:** ALL FIVE TESTS BLOCK CLI-08 CLOSURE. Networking + chat protocol works; UI/asset/UX layers fall short of "log in with their migrated account, walk around, and chat — bug-free" (CLAUDE.md core value). Five findings documented in `## Findings` below; per user decision (2026-05-10), all five gap-closure items are scoped to Phase 6 (no Phase 7 advance until CLI-08 truly green).

  ## Test 1 — HiDPI nearest-neighbor + integer scale (REQ-CLI-06)

  **Why manual:** Pixel-correctness on real Retina hardware not reproducible in headless Chromium at varying DPI matrices.

  **Instructions:**
  1. Open `https://staging.rebno.decidel.com/?invite=$STAGING_INVITE_TOKEN` on a 4K Retina display
  2. Open the same on a 1080p external monitor
  3. Open the same on a Windows machine with display scaling at 125% AND at 150%
  4. Log in, transition to GameScene
  5. For each environment, take a 4× zoom-in screenshot of the player sprite (NaviStandD)
  6. Visually inspect: pixel edges must be crisp, no anti-aliasing or sub-pixel blur

  **Acceptance:**
  - All sprites render with crisp pixel edges
  - No fractional-scale blur on Retina or Windows 125%/150%
  - Canvas snaps to integer scale on `window.resize`

  **Operator Notes:** HiDPI scale check itself was not exercised because of two upstream blockers surfaced before per-DPI sweeps were possible:
  - (a) Login screen text is unreadable — black title + black field labels on near-black `#0A0E1A` surface (see `uat-test-1-1.png`). Cannot read prompts in any DPI environment. **Finding #1.**
  - (b) GameScene renders only blue rectangles + nameplates — no real BNO assets piped through (see `uat-test-1-2.mp4`). Pixel-correctness on rectangles is meaningless; the test is for sprites. **Finding #4.**

  **Screenshots / Evidence:**
  - `uat-test-1-1.png` — login screen on 1080p Chrome (Windows 125% scaling), black-on-near-black title + labels
  - `uat-test-1-2.mp4` — two-window GameScene side-by-side showing blue-rectangle players (uat_a + uat_b) over empty black background

  **Status:** ⬛ FAIL — blocked by Findings #1 + #4. Re-run after gap-closure plans land real assets + readable login.

  ---

  ## Test 2 — CLI-08 MILESTONE: Two-player MVP gate (REQ-CLI-08)

  **Why manual:** The "we shipped CLI-08" milestone artifact requires human capture per CONTEXT D-19. Playwright provides regression coverage; this is the human deliverable.

  **Instructions:**
  1. Operator opens 2 Chrome browser windows (or two physical machines) against `https://staging.rebno.decidel.com/?invite=$STAGING_INVITE_TOKEN`
  2. Sign in as `uat_a` in window 1 and `uat_b` in window 2 (per psyche carry-over; original doc said `alice`/`bob` — those accounts do not exist in seeded staging DB; **doc bug, fixed in this revision**)
  3. Wait for both clients to enter `mvp-lobby` GameScene (`data-game-ready=true`)
  4. Start a screen recording covering BOTH browser windows (OBS / Loom / built-in screen recorder)
  5. In window 1 (uat_a): walk toward uat_b using WASD (~5 seconds of movement)
  6. In window 2 (uat_b): walk toward uat_a
  7. Confirm visually: each window shows BOTH player sprites + nameplates moving smoothly
  8. In window 1 (uat_a): press Enter, type `hello uat_b`, press Enter
  9. Confirm: window 2 shows `uat_a: hello uat_b` in chat HUD within 2 seconds
  10. In window 2 (uat_b): press Enter, type `hi uat_a`, press Enter
  11. Confirm: window 1 shows `uat_a: hi uat_a`
  12. Stop the recording
  13. Save the video as `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CLI-08-milestone.mp4`

  **Acceptance:**
  - Both clients see each other's sprite + nameplate move
  - Bidirectional chat works in real time
  - Video file exists and is committed (or externally linked)
  - Operator types "approved — CLI-08 shipped" in the resume signal

  **Operator Notes:** Networking + protocol layer works on the fresh-login path. Both clients see each other move; chat lands bidirectionally. BUT the full milestone fails on three counts:
  - (a) Movement does not match BNO (no friction/accel/sub-pixel feel reproduced from extracted GML — uses arbitrary client values). **Finding #3.**
  - (b) Visuals are blue rectangles + cyan nameplates only — no NaviStandD sprite, no room/tile background, no walls. **Finding #4.**
  - (c) Reconnect / cookie auto-login renders only the canvas frame — chat HUD missing, game content empty. Re-entry path is broken; only first-fresh-login renders correctly. **Finding #2.**

  The fresh-login two-player movement+chat slice was captured as `uat-test-1-2.mp4` (operator labeled it as Test 1 evidence; serves dual purpose). This is **NOT** the CLI-08 milestone video — that requires #2/#3/#4 fixed first.

  **Video:** `uat-test-1-2.mp4` (interim — fresh-login slice only, blue rectangles, no rooms). Final `06-CLI-08-milestone.mp4` blocked on Findings #2/#3/#4.

  **Status:** ⬛ FAIL — partial (networking + protocol confirmed working; presentation + reconnect path not). Re-run after Findings #2/#3/#4 close.

  ---

  ## Test 3 — Asset-pipeline Aseprite round-trip (REQ-AST-01)

  **Why manual:** `.aseprite` editor is a desktop app; export-and-rebuild is a human ergonomic check, not a CI assertion.

  **Instructions:**
  1. Open `assets/source/sprites/aseprite/0000-NaviStandD.aseprite` in Aseprite (skip if .aseprite source files were not committed per D-15 discretion)
  2. Modify a single pixel in frame 0 (any visible change)
  3. Export via Aseprite CLI:
     `aseprite -b 0000-NaviStandD.aseprite --sheet ../../source/sprites/0000-NaviStandD.png --data ../../source/sprites/0000-NaviStandD.json --format json-array`
  4. Run `pnpm asset-pipeline:build`
  5. Run `pnpm --filter @rebno/client build:staging`
  6. Reload `http://localhost:5173` (or staging) — confirm the modified pixel is visible
  7. Revert the change

  **Acceptance:**
  - Aseprite export to PNG strip + JSON sidecar succeeds
  - `asset-pipeline:build` re-packs the atlas without errors
  - Client renders the modified sprite

  **Operator Notes:** Cannot exercise — there are no real BNO assets in the project to edit. `assets/source/sprites/aseprite/0000-NaviStandD.aseprite` does not exist; no rooms, no tiles, no sprites are piped through the asset pipeline. The pipeline plumbing (lint:asset-pipeline + asset-pipeline:build/postprocess) was built in 06-08 but never fed real input. **Finding #4.**

  **Status:** ⬛ BLOCKED — by Finding #4. Re-run once first-pass real assets land (NaviStandD + at least one room/tile set extracted from BNO source per CLAUDE.md hard rule #6: extract → document → rewrite).

  ---

  ## Test 4 — Browser password manager + autofill (REQ-CLI-02)

  **Why manual:** Password manager integrations (1Password, Chrome built-in) are not headless-driveable.

  **Instructions:**
  1. Open `https://staging.rebno.decidel.com/?invite=$STAGING_INVITE_TOKEN` in Chrome with built-in password manager enabled (no extensions for first run)
  2. Sign in with a fresh test account (not previously saved)
  3. Confirm: Chrome prompts "Save password?" — accept
  4. Sign out
  5. Reload the page
  6. Confirm: Chrome auto-fills the password field
  7. Repeat with 1Password browser extension if available

  **Acceptance:**
  - Chrome built-in password save prompt appears
  - Autofill works on subsequent visits
  - (Optional) 1Password extension recognises the form
  - `autocomplete="username"` and `autocomplete="current-password"` attributes verified via DevTools (UI-SPEC §Login screen)

  **Operator Notes:** Cannot exercise — there is no logout affordance in the client. Once cookies are set, the LoginScene auto-bypasses to GameScene. The "Sign out" step in the instructions cannot be performed. Need a logout button (or `/logout` route + cookie/session clear) before this test is runnable. **Finding #5.**

  **Status:** ⬛ BLOCKED — by Finding #5. Re-run once logout affordance exists.

  ---

  ## Test 5 — Reconnect grace banner UX (REQ-CLI-09)

  **Why manual:** Banner timing + visual prominence is a subjective UX call beyond "did it reconnect".

  **Instructions:**
  1. Log in to staging; transition to GameScene
  2. Open Chrome DevTools → Console
  3. Run: `(window).colyseus?.connection?.transport?.ws?.close?.() ?? window.__rebno?.room?.connection?.close?.()`
  4. Confirm: amber banner appears within 1 s with text `Reconnecting…`
  5. Wait for auto-reconnect (within 10 s grace per Phase 4 D-22)
  6. Confirm: banner shows `Reconnected.` (transient flash, ~1 s) then disappears
  7. Confirm: player position is preserved (within-grace reconnect path)
  8. Repeat: kill WS + wait > 15 s (forces past grace) → banner should show `Disconnected — click to retry` (destructive red); click → reconnect succeeds → home portal spawn (per D-07 cross-session reset)

  **Acceptance:**
  - Banner appears within 1 s of WS close
  - Banner clears within 1 s of reconnect
  - Reconnect within grace preserves position
  - Reconnect past grace spawns at home portal (D-07)
  - Visual transitions feel smooth (no jank)

  **Operator Notes:** Networking layer reconnects (WS comes back), but post-reconnect the client renders only the canvas frame — no chat HUD, no remote players, no own player visible. Same root cause as Finding #2 (re-entry to GameScene does not re-mount HUD/renderers; init only fires on first fresh-login from LoginScene). Banner timing was not separately graded because the rendered content behind the banner is empty. **Finding #2.**

  **Status:** ⬛ FAIL — by Finding #2. Re-run after re-entry/reconnect path correctly re-mounts game scene.

  ---

  ## Findings (block CLI-08 closure)

  Per user decision 2026-05-10: all five findings scoped to **Phase 6** as gap-closure plans. No Phase 7 advance until CLI-08 truly green.

  | # | Finding | Severity | Suggested gap-closure |
  |---|---------|----------|----------------------|
  | 1 | Login screen text invisible — black title + labels on `#0A0E1A` surface | high (UX blocker) | One LoginScene CSS plan: enforce UI-SPEC color tokens (foreground white/cyan); add visual regression test |
  | 2 | Reconnect / cookie auto-login renders blank GameScene (canvas frame only, no HUD, no remotes, no self) | **critical (regression)** | Plan to fix re-entry init: HUD mount + renderer wiring must idempotently re-fire on every GameScene enter, not only first transition from LoginScene. Add Playwright e2e covering cookie-auto-login + WS-kill-then-reconnect rendering paths (current 06-08 e2e only covers fresh login) |
  | 3 | Movement doesn't match BNO — no friction/accel/sub-pixel feel from extracted client GML | **critical (scope/identity)** | Plan to derive PredictionEngine constants from extracted Client 5-8 GML (`player_step`, `friction`, speed values, key-buffering) per CLAUDE.md hard rule #5/#6. Update game-logic + add unit tests pinning the derived values |
  | 4 | No real BNO assets — blue rectangles only; no rooms, tiles, sprites, atlas content | **critical (scope/identity)** | Plan(s) to extract first-pass assets from BNO source: at minimum NaviStandD sprite atlas + one room (tiles + collision + spawn points). Pipe through existing asset-pipeline:build → atlas → Phaser texture. Tag REQ-AST-01..04 + REQ-PAR-* as appropriate |
  | 5 | No logout — sticky cookie auto-login blocks Test 4 + general UX | medium | Plan to add logout button to GameScene (top-right or Esc menu); cleanly clear Better-Auth session cookie + return to LoginScene |

  **Sequencing recommendation (advisory, lock in `/gsd-discuss-phase 6 --gaps` if used):**
  - Wave 8: #1 (login contrast — small, no deps)
  - Wave 9: #5 (logout button — small, unblocks Test 4)
  - Wave 10: #2 (re-entry render fix — critical regression, must precede final UAT)
  - Wave 11: #3 (movement fidelity from extracted GML — needs Phase 1 extraction artifacts)
  - Wave 12: #4 (first-pass assets from BNO source — needs Phase 1 extraction artifacts; largest)
  - Wave 13: re-run 06-HUMAN-UAT.md, capture final `06-CLI-08-milestone.mp4`

  ## Sign-Off

  | Test | Requirement | Status |
  |------|-------------|--------|
  | 1 — HiDPI | REQ-CLI-06 | ⬛ FAIL (blocked by #1, #4) |
  | 2 — CLI-08 milestone | REQ-CLI-08 | ⬛ FAIL (partial: networking ok; #2 + #3 + #4 fail) |
  | 3 — Aseprite round-trip | REQ-AST-01 | ⬛ BLOCKED (#4) |
  | 4 — Password manager | REQ-CLI-02 | ⬛ BLOCKED (#5) |
  | 5 — Reconnect banner | REQ-CLI-09 | ⬛ FAIL (#2) |

  **Operator approval:** decidel, 2026-05-10 — **NOT APPROVED.** All five tests block CLI-08 milestone closure. Five gap-closure plans required in Phase 6 before re-running UAT.

  **Phase 6 status:** IN_PROGRESS — UAT failed; gap-closure plans scoped (Findings #1–#5).
  ```

  ## Gap-Closure Plans

  ### Plan 06-10
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 10
  type: execute
  wave: 8
  depends_on: []
  files_modified:
    - apps/client/public/forms/login.html
    - apps/client/src/scenes/LoginScene.ts
    - apps/client/test/e2e/login-contrast.e2e.test.ts
    - apps/client/test/e2e/fixtures.ts
  autonomous: true
  requirements: [CLI-06, CLI-02]
  must_haves:
    truths:
      - "Login form heading + Username/Password labels + input text + CTA are readable on the #0A0E1A LoginScene background — measured contrast ratio ≥ 4.5:1 (WCAG AA normal text) for every UI-SPEC §LoginScene element"
      - "Computed-style colors match UI-SPEC §Color tokens exactly — heading + labels render #F3F4F6 (text-on-dominant), CTA renders #0A0E1A on #22D3EE (text-on-accent), error text renders #EF4444"
      - "Playwright contrast assertion fires on every CI run; any future regression that drops a token below 4.5:1 fails the e2e suite (BLOCKER guard against UAT Finding #1 recurrence)"
      - "Operator opening the staging URL on Windows 125% scaling can READ the form prompts (Finding #1 reproducer no longer reproduces)"
    artifacts:
      - path: apps/client/public/forms/login.html
        provides: "Login form HTML with explicit color tokens applied to title, labels, inputs, CTA, error region — no relying on UA defaults that produced the black-on-near-black UAT failure"
        contains: "color: #F3F4F6"
      - path: apps/client/src/scenes/LoginScene.ts
        provides: "Phaser-side LoginScene wires color tokens for any in-canvas backdrop text (Reconnecting-as-username spinner) so backdrop + DOM form share the contract"
        contains: "#F3F4F6"
      - path: apps/client/test/e2e/login-contrast.e2e.test.ts
        provides: "Playwright e2e — visits LoginScene, queries getComputedStyle for every UI-SPEC §LoginScene element, asserts foreground/background contrast ratio ≥ 4.5:1 via WCAG luminance formula"
        contains: "4.5"
    key_links:
      - from: apps/client/public/forms/login.html
        to: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md §Color
        via: "every color: declaration in the form references a token from the UI-SPEC table (#0A0E1A, #1F2937, #22D3EE, #EF4444, #F3F4F6) — no ad-hoc colors"
        pattern: "#(0A0E1A|1F2937|22D3EE|EF4444|F3F4F6)"
      - from: apps/client/test/e2e/login-contrast.e2e.test.ts
        to: apps/client/public/forms/login.html
        via: "page.goto(baseURL/forms/login.html) → page.evaluate(getComputedStyle) → contrast assertion"
        pattern: "getComputedStyle"
      - from: apps/client/playwright.config.ts (existing)
        to: apps/client/test/e2e/login-contrast.e2e.test.ts
        via: "Playwright auto-discovers *.e2e.test.ts under apps/client/test/e2e/; new test runs alongside cli-08.e2e.test.ts"
        pattern: "test/e2e"
  ---

  <objective>
  Close UAT Finding #1 (D-23): the login screen renders black-on-near-black text against the `#0A0E1A` background, making the form unreadable on every DPI environment the operator tested. Repaint LoginScene's DOM form with the UI-SPEC §Color tokens (foreground `#F3F4F6` on dominant `#0A0E1A`, accent `#22D3EE` for CTA), and add a Playwright contrast-ratio assertion as a regression guard so any future CSS drift below 4.5:1 fails CI before it reaches UAT.

  Purpose: per CONTEXT D-23 — UI-SPEC §LoginScene already locks the design tokens; this plan enforces them in code and pins them with an automated check. No design discussion or new tokens. CSS-only intervention plus one regression test.

  Output: a repainted login form (HTML + matching Phaser-side color references), a new Playwright e2e file that asserts WCAG AA contrast on every UI-SPEC §LoginScene element, and a green `pnpm --filter @rebno/client test:e2e` run including the new file. Per gap-closure addendum line 359 tag guidance — `[impl/unit->REQ-CLI-06]` (HiDPI / visual umbrella) plus `[doc->REQ-CLI-02]` (login lifecycle) on the markdown.
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-PATTERNS.md
  @apps/client/public/forms/login.html
  @apps/client/src/scenes/LoginScene.ts
  @apps/client/test/e2e/cli-08.e2e.test.ts
  @apps/client/test/e2e/fixtures.ts
  @apps/client/playwright.config.ts

  <interfaces>
  <!-- UI-SPEC §Color tokens (06-UI-SPEC.md lines 87-96) — locked. -->
  <!-- Every CSS color declaration in the login form MUST reference one of these. -->

  Dominant (60%):  #0A0E1A   (page + form background)
  Secondary (30%): #1F2937   (input field background, divider lines)
  Accent (10%):    #22D3EE   (CTA button background, focused-input border)
  Destructive:     #EF4444   (inline error text)
  Text on dominant:#F3F4F6   (body / label / heading text on #0A0E1A)
  Text on accent:  #0A0E1A   (text on the #22D3EE CTA)

  <!-- Typography (UI-SPEC §Typography lines 67-75): -->
  Body / label:    16px / 400 / line-height 1.5 / monospace stack
  Heading:         24px / 600 / line-height 1.2 / monospace stack
  Display (Phaser spinner): 28px / 600 / line-height 1.2 / monospace stack
  Font stack:      ui-monospace, "SF Mono", Menlo, Consolas, monospace

  <!-- Copy contract (UI-SPEC §Copywriting Contract lines 118-133) — DO NOT alter copy: -->
  Heading:         "Log in to BN Online"
  Username label:  "Username" / autocomplete="username"
  Password label:  "Password" / autocomplete="current-password"
  CTA:             "Log in"
  Spinner copy:    "Reconnecting as <username>…" (single-char ellipsis U+2026)
  Esc hint:        "Press Esc to log in as someone else"

  <!-- WCAG contrast formula (used by the new Playwright assertion) -->
  relativeLuminance(R,G,B):
    for c in [R,G,B]: c /= 255; c = (c <= 0.03928) ? c/12.92 : ((c+0.055)/1.055)**2.4
    return 0.2126*R + 0.7152*G + 0.0722*B
  contrastRatio(L1,L2):
    return (max(L1,L2) + 0.05) / (min(L1,L2) + 0.05)
  WCAG AA normal text: ≥ 4.5
  WCAG AA large text (≥ 18pt or ≥ 14pt bold): ≥ 3.0
  For Phase 6 we use ≥ 4.5 across the board (no large-text exemption — heading at 24px/600 still passes the stricter bar with the chosen tokens).

  <!-- Existing fixtures (apps/client/test/e2e/fixtures.ts) carry baseURL via Playwright config -->
  loginAs(page, account)              — drives form submit; reused as smoke check post-fix
  waitForGameReady(page)              — waits for canvas[data-game-ready=true]; not needed here
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: Apply UI-SPEC §Color tokens to LoginScene form HTML + matching Phaser backdrop colors</name>
    <files>
      apps/client/public/forms/login.html,
      apps/client/src/scenes/LoginScene.ts
    </files>
    <read_first>
      apps/client/public/forms/login.html (current black-on-near-black state),
      apps/client/src/scenes/LoginScene.ts (current Phaser backdrop + spinner color),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md §Color (lines 84-110) + §Typography (lines 67-79) + §Copywriting Contract §Login screen (lines 118-133),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md Test 1 + Findings table row #1 (the operator's exact failure description)
    </read_first>
    <behavior>
      - Form heading "Log in to BN Online" rendered in #F3F4F6 / 24px / weight 600 / monospace stack on #0A0E1A surface
      - Username + Password labels rendered in #F3F4F6 / 16px / weight 400 on #0A0E1A
      - Username + Password input text rendered in #F3F4F6 on #1F2937 (secondary surface) with 1px #1F2937 border that switches to #22D3EE on :focus (UI-SPEC §Color "chat-mode-active cursor / input border" pattern, applied to login inputs as well — accent reserved for the four explicit uses; this is one)
      - CTA button: background #22D3EE, text #0A0E1A, 16px / 400, monospace; :hover lightens to #67E8F9 (NOT in UI-SPEC strictly but standard hover-treatment of accent is acceptable and noted in done-criteria; if checker objects, swap to a 1.1 alpha bump on the same accent)
      - Inline error region rendered in #EF4444 / 16px / 400 — empty by default, populated on submit failure with the UI-SPEC copy ("Wrong username or password.", etc.)
      - Phaser-side LoginScene backdrop fillStyle stays #0A0E1A (matches form so the DOMElement mount blends seamlessly); fast-path "Reconnecting as <username>…" Phaser text uses color #F3F4F6 / monospace 28px (UI-SPEC Display role)
      - autocomplete="username" + autocomplete="current-password" attributes preserved (do not strip — RESEARCH §Pattern 2 pitfall)
      - All form copy strings unchanged (see <interfaces> Copy contract)
    </behavior>
    <action>
  **1. apps/client/public/forms/login.html** — Tag `<!-- [impl->REQ-CLI-02] [impl->REQ-CLI-06] -->` near the top. Replace any existing `color:` and `background:` declarations on `body`, `form`, `h1`, `label`, `input`, `button`, and the error region with the UI-SPEC tokens above. Wire `:focus` border on inputs to `#22D3EE`. Preserve all `autocomplete` attributes, all `id` attributes (`#username`, `#password`), all copy strings, and the form `<button type=submit>` structure (used by `loginAs` in fixtures.ts).

  Use a single inline `<style>` block (matches the file's current shape — do NOT introduce a separate CSS file; per 06-UI-SPEC.md `Tool: none` minimal-chrome posture).

  Concrete style block (paste as a block; adjust indentation to match file):
  ```css
  body {
    margin: 0;
    background: #0A0E1A;
    color: #F3F4F6;
    font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
    font-size: 16px;
    line-height: 1.5;
  }
  form#login-form {
    max-width: 320px;
    margin: 64px auto;
    padding: 24px; /* UI-SPEC spacing token "lg" */
    background: #0A0E1A;
    display: flex;
    flex-direction: column;
    gap: 16px; /* token "md" */
  }
  form#login-form h1 {
    margin: 0 0 16px 0;
    font-size: 24px;
    font-weight: 600;
    line-height: 1.2;
    color: #F3F4F6;
  }
  form#login-form label {
    display: flex;
    flex-direction: column;
    gap: 4px; /* token "xs" */
    color: #F3F4F6;
  }
  form#login-form input[type=text],
  form#login-form input[type=password] {
    font-family: inherit;
    font-size: 16px;
    font-weight: 400;
    line-height: 1.5;
    padding: 8px; /* token "sm" */
    color: #F3F4F6;
    background: #1F2937;
    border: 1px solid #1F2937;
    outline: none;
  }
  form#login-form input[type=text]:focus,
  form#login-form input[type=password]:focus {
    border-color: #22D3EE;
  }
  form#login-form button[type=submit] {
    font-family: inherit;
    font-size: 16px;
    font-weight: 400;
    line-height: 1.5;
    padding: 8px 16px;
    color: #0A0E1A;
    background: #22D3EE;
    border: none;
    cursor: pointer;
  }
  form#login-form button[type=submit]:hover {
    background: #67E8F9; /* same hue, lighter — accepted hover treatment */
  }
  #login-error {
    min-height: 1.5em;
    color: #EF4444;
    font-size: 16px;
  }
  ```

  **2. apps/client/src/scenes/LoginScene.ts** — Tag `// [impl->REQ-CLI-02] [impl->REQ-CLI-06]` near the LoginScene class header. Find the Phaser backdrop fill and the fast-path spinner text:
  - Backdrop fillStyle / camera background → confirm 0x0A0E1A (matches form bg). If currently a different hex (e.g. plain black 0x000000), update to 0x0A0E1A.
  - Fast-path spinner Phaser.GameObjects.Text → set `color: '#F3F4F6'`, `fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace'`, `fontSize: '28px'`, `fontStyle: '600'` (UI-SPEC §Typography Display role; UI-SPEC line 77 explicitly upgrades the prior 24px to 28px for this surface).
  - Esc hint Phaser text under spinner: same color / family, 16px / 400.

  Do not alter the form-mount logic, fast-path timer logic, or any scene-transition logic — those belong to Findings #2 (06-15) and the existing fast-path code paths.
    </action>
    <verify>
      <automated>node -e "const fs=require('fs'); const html=fs.readFileSync('apps/client/public/forms/login.html','utf-8'); const ts=fs.readFileSync('apps/client/src/scenes/LoginScene.ts','utf-8'); const tokens=['#0A0E1A','#F3F4F6','#22D3EE','#EF4444','#1F2937']; for (const t of tokens) { if (!html.includes(t)) { console.error('MISSING in login.html:', t); process.exit(1); } } if (!ts.includes('#F3F4F6') &amp;&amp; !ts.includes('0xF3F4F6')) { console.error('MISSING #F3F4F6 in LoginScene.ts'); process.exit(1); } console.log('contrast tokens present');"</automated>
    </verify>
    <done>
      apps/client/public/forms/login.html contains explicit color tokens for body, form, h1, label, input (default + :focus), button, and #login-error — every color references a UI-SPEC §Color value (no ad-hoc hex). apps/client/src/scenes/LoginScene.ts uses #F3F4F6 (or 0xF3F4F6) for the spinner + Esc hint and 0x0A0E1A for the backdrop. Operator can read the form on a #0A0E1A page on Windows 125% scaling (manual smoke; locked by Task 2's automated check).
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Playwright contrast e2e — getComputedStyle + WCAG luminance assertion ≥ 4.5</name>
    <files>
      apps/client/test/e2e/login-contrast.e2e.test.ts,
      apps/client/test/e2e/fixtures.ts
    </files>
    <read_first>
      apps/client/test/e2e/cli-08.e2e.test.ts (existing e2e shape — copy the imports + describe pattern),
      apps/client/test/e2e/fixtures.ts (existing baseURL handling),
      apps/client/playwright.config.ts (auto-discovery glob — confirm *.e2e.test.ts is picked up)
    </read_first>
    <behavior>
      - Test 1: visiting `/forms/login.html` (or whatever path the LoginScene loads it from — fixtures.ts knows; if the form is mounted dynamically by Phaser, navigate to the app root, wait for the form to appear, then probe) yields a heading element with computed color = rgb(243,244,246) on parent computed background = rgb(10,14,26) — contrast ratio ≥ 4.5
      - Test 2: same probe for both `<label>` elements, both `<input>` elements (default state — no focus), the `<button type=submit>`, and the `#login-error` region (assert ratio ≥ 4.5 even though the region is empty by default — when populated, error text MUST contrast against form bg)
      - Test 3: focusing an input via `.focus()` and re-probing computed border-color asserts `rgb(34, 211, 238)` (#22D3EE) — guards the accent-on-focus contract
      - All three tests use a shared `getContrast(page, selector)` helper that reads getComputedStyle for color + (effective parent background, walking the ancestor chain until a non-transparent background is found) and runs the WCAG luminance formula
    </behavior>
    <action>
  **1. apps/client/test/e2e/fixtures.ts** — Add (do not replace) a small helper export. Tag `// [unit->REQ-CLI-06]` above the export.

  ```ts
  // [unit->REQ-CLI-06]
  export type ContrastProbe = { fg: string; bg: string; ratio: number };

  /** Run inside page.evaluate. Walks ancestors for first opaque background. */
  export const computeContrastInPage = `
    (selector) => {
      const el = document.querySelector(selector);
      if (!el) return null;
      const fg = getComputedStyle(el).color;
      let node = el;
      let bg = 'rgba(0, 0, 0, 0)';
      while (node) {
        const c = getComputedStyle(node).backgroundColor;
        if (c && !c.includes('rgba(0, 0, 0, 0)') && !c.includes('transparent')) { bg = c; break; }
        node = node.parentElement;
      }
      if (bg === 'rgba(0, 0, 0, 0)') bg = getComputedStyle(document.body).backgroundColor;
      const parse = (s) => {
        const m = s.match(/rgba?\\(([^)]+)\\)/);
        if (!m) return [0,0,0];
        return m[1].split(',').slice(0,3).map((v) => parseInt(v.trim(), 10));
      };
      const lum = (rgb) => {
        const cs = rgb.map((v) => { const n = v / 255; return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4); });
        return 0.2126*cs[0] + 0.7152*cs[1] + 0.0722*cs[2];
      };
      const Lfg = lum(parse(fg));
      const Lbg = lum(parse(bg));
      const ratio = (Math.max(Lfg, Lbg) + 0.05) / (Math.min(Lfg, Lbg) + 0.05);
      return { fg, bg, ratio };
    }
  `;
  ```

  **2. apps/client/test/e2e/login-contrast.e2e.test.ts** — New file. Tag `// [int->REQ-CLI-06] [unit->REQ-CLI-02]` at top. Mirror cli-08.e2e.test.ts's imports + describe shape.

  ```ts
  // [int->REQ-CLI-06] [unit->REQ-CLI-02]
  // Regression guard for UAT Finding #1 (D-23): black-on-near-black login text.
  // Asserts WCAG AA contrast ≥ 4.5:1 for every UI-SPEC §LoginScene element.

  import { test, expect } from '@playwright/test';
  import { computeContrastInPage } from './fixtures';

  const ELEMENTS = [
    { sel: 'form#login-form h1', label: 'heading' },
    { sel: 'form#login-form label[for="username"]', label: 'username label' },
    { sel: 'form#login-form label[for="password"]', label: 'password label' },
    { sel: 'form#login-form input#username', label: 'username input' },
    { sel: 'form#login-form input#password', label: 'password input' },
    { sel: 'form#login-form button[type=submit]', label: 'submit CTA' },
  ];

  test.describe('LoginScene contrast (UI-SPEC §Color)', () => {
    test.beforeEach(async ({ page }) => {
      // Navigate to the app root; LoginScene mounts the DOM form into the page.
      // If the form is served as a static include at /forms/login.html, that
      // path also works for a pure-form contrast check; both are valid because
      // the styles are inline.
      await page.goto('/');
      // Wait for the form to be in the DOM (LoginScene mounts it on create()).
      await page.waitForSelector('form#login-form h1', { timeout: 10000 });
    });

    for (const { sel, label } of ELEMENTS) {
      test(`${label} contrast >= 4.5:1`, async ({ page }) => {
        const r = await page.evaluate(
          new Function('selector', 'return (' + computeContrastInPage + ')(selector);') as any,
          sel,
        ) as { fg: string; bg: string; ratio: number } | null;
        if (!r) throw new Error(`element not found: ${sel}`);
        expect(r.ratio, `${label}: fg=${r.fg} bg=${r.bg} ratio=${r.ratio.toFixed(2)}`).toBeGreaterThanOrEqual(4.5);
      });
    }

    test('focused input border switches to accent #22D3EE', async ({ page }) => {
      await page.locator('form#login-form input#username').focus();
      const borderColor = await page.locator('form#login-form input#username').evaluate(
        (el) => getComputedStyle(el as HTMLElement).borderColor,
      );
      // rgb(34, 211, 238) === #22D3EE
      expect(borderColor.replace(/\s+/g, '')).toMatch(/rgb\(34,211,238\)|#22D3EE/i);
    });
  });
  ```

  **3. Run locally** with the dev server already booted by `playwright.config.ts`'s two-mode webServer (per 06-08 SUMMARY): `pnpm --filter @rebno/client test:e2e -- --grep "LoginScene contrast"`. All 7 tests (6 elements × ratio + 1 focus) GREEN before commit.
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test:e2e -- --grep "LoginScene contrast"</automated>
    </verify>
    <done>
      apps/client/test/e2e/login-contrast.e2e.test.ts exists with 7 GREEN tests (6 contrast probes + 1 focus-border probe). fixtures.ts exports computeContrastInPage helper. The new file is auto-discovered by playwright.config.ts and runs in both local and CI modes. Any CSS regression that drops a UI-SPEC §LoginScene element below 4.5:1 contrast fails the e2e suite. Operator's UAT Finding #1 reproducer is now machine-checked.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Untrusted CSS / theme drift → user readability | Future PRs may swap a token without re-running UI-SPEC review; the contrast e2e is the forcing function |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-10-01 | Tampering | Adversary or accidental edit drops form text below WCAG 4.5:1 | mitigate | Playwright contrast e2e (login-contrast.e2e.test.ts) gates merge in apps/client/test:e2e CI run |
  | T-06-10-02 | Disclosure | Inline styles leak design tokens to public bundle | accept | UI-SPEC tokens are not secrets; CSS hex values are public-information by design |
  | T-06-10-03 | Repudiation | Operator claims contrast was OK at UAT but evidence missing | mitigate | The e2e provides per-element contrast ratio in failure output; logs preserved per Playwright reporter |
  </threat_model>

  <verification>
  - `pnpm --filter @rebno/client test:e2e -- --grep "LoginScene contrast"` exits 0 (7 tests green)
  - `pnpm --filter @rebno/client typecheck` exits 0 (TypeScript happy with the new fixtures export)
  - Visual smoke: open `apps/client/public/forms/login.html` in a browser locally — heading + labels + inputs + CTA visibly readable on the dark navy background
  - `pnpm verify:phase-6` (after this plan + downstream Wave 11/12 plans land) passes the e2e step including the new contrast file
  - `pnpm trace:check` reports `[impl->REQ-CLI-06]` and `[unit->REQ-CLI-06]` tags now present for visual contrast coverage
  </verification>

  <success_criteria>
  - UAT Finding #1 (D-23) closed: login form is readable on the #0A0E1A surface across all DPI environments
  - All UI-SPEC §LoginScene §Color tokens enforced in code (heading + labels + inputs + CTA + error region)
  - Playwright contrast e2e (`login-contrast.e2e.test.ts`) is part of `pnpm verify:phase-6` step 9 — regression guard active
  - Phase 6 UAT re-run (06-17) can read the form prompts on first inspection, unblocking all DPI sweep evidence in Test 1 (HiDPI nearest-neighbor)
  - Tags `[impl/unit->REQ-CLI-06]` + `[doc->REQ-CDOC-*]` present (per gap-closure addendum line 359)
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-SUMMARY.md` recording:
  (a) Final color tokens applied (heading / labels / inputs / CTA / error / focus border)
  (b) Computed contrast ratios for each UI-SPEC §LoginScene element (paste from the Playwright reporter output for posterity)
  (c) Confirmation the new e2e file runs in both local + CI modes
  (d) Any deviations from the UI-SPEC §Color table (should be zero — flag the hover treatment if checker objects)
  (e) Confirmation operator can read the form on the next staging deploy (manual smoke check; final lock comes at 06-17 UAT re-run)
  </output>
  </content>
  </invoke>```

  ### Plan 06-11
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 11
  type: execute
  wave: 8
  depends_on: []
  files_modified:
    - apps/client/src/ui/EscMenu.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/auth/client.ts
    - apps/client/src/__test__/esc-menu.test.ts
    - apps/client/test/e2e/logout.e2e.test.ts
    - apps/client/index.html
  autonomous: true
  requirements: [CLI-02, CLI-03]
  must_haves:
    truths:
      - "Operator pressing Esc inside GameScene opens a non-modal menu overlay with three options: Logout / Resume / Settings (placeholder)"
      - "Operator clicking the Phaser game canvas (single click anywhere on the canvas) ALSO opens the same Esc menu — equivalent UX surfaces per D-24"
      - "While the Esc menu is open, movement keys (WASD + arrows) are frozen — keystrokes do not reach the InputDispatcher → no c2s.input over the wire"
      - "Selecting Logout calls POST /api/auth/sign-out, clears the Better-Auth session cookie via Better-Auth client SDK, drops the Colyseus room (room.leave()), and transitions back to LoginScene with no session"
      - "Settings option is rendered but is a no-op placeholder ('Settings — coming in Phase 7') — does NOT enable a deferred Phase 7 surface"
      - "Resume closes the overlay and re-engages movement keys without any auth/network side-effect"
      - "Logout flow does NOT preserve the staging invite token — operator re-enters via fresh `?invite=` URL or saved bookmark per D-24"
      - "Playwright e2e covers: open via Esc, open via canvas click, Logout returns to LoginScene with empty session cookie"
    artifacts:
      - path: apps/client/src/ui/EscMenu.ts
        provides: "DOM overlay component — non-modal-style Esc menu with Logout / Resume / Settings buttons; emits onLogout / onResume / onSettings events; mount/unmount lifecycle similar to existing ChatHUD"
        contains: "EscMenu"
      - path: apps/client/src/scenes/GameScene.ts
        provides: "Wires Esc keydown + canvas pointerdown → escMenu.open(); escMenu.onLogout → signOut + room.leave() + scene.start('LoginScene'); escMenu.open()/close() toggles InputDispatcher freeze state"
        contains: "escMenu"
      - path: apps/client/src/auth/client.ts
        provides: "signOut() helper — wraps Better-Auth SDK signOut + clears any sessionStorage reconnection-token cache; idempotent so double-click of Logout doesn't error"
        contains: "signOut"
      - path: apps/client/src/__test__/esc-menu.test.ts
        provides: "vitest unit suite — EscMenu mount/unmount idempotency, button-click event emission, focus-trap-not-required (non-modal per D-24)"
        contains: "EscMenu"
      - path: apps/client/test/e2e/logout.e2e.test.ts
        provides: "Playwright e2e — login + Esc → menu visible; click Logout → back at LoginScene; document.cookie no longer contains better-auth session"
        contains: "Logout"
      - path: apps/client/index.html
        provides: "DOM-overlay slot for #esc-menu (sibling of #chat-hud + #reconnect-banner under #dom-overlay)"
        contains: "esc-menu"
    key_links:
      - from: apps/client/src/scenes/GameScene.ts
        to: apps/client/src/ui/EscMenu.ts
        via: "import + instantiate in create(); call escMenu.open() on Esc keydown OR canvas pointerdown"
        pattern: "EscMenu|escMenu"
      - from: apps/client/src/ui/EscMenu.ts
        to: apps/client/src/auth/client.ts
        via: "Logout button → onLogout event → GameScene calls signOut() then room.leave() then scene.start('LoginScene')"
        pattern: "signOut"
      - from: apps/client/src/scenes/GameScene.ts
        to: apps/client/src/scenes/LoginScene.ts
        via: "scene.start('LoginScene', { fastPath: false }) on logout — DO NOT pass username/sessionToken; user must re-authenticate"
        pattern: "scene.start.*LoginScene"
      - from: apps/client/src/scenes/GameScene.ts
        to: InputDispatcher freeze state
        via: "while escMenu.isOpen, InputDispatcher.setFrozen(true) — same pattern as chat-mode freeze"
        pattern: "setFrozen|isFrozen"
  ---

  <objective>
  Close UAT Finding #5 (D-24): there is no logout affordance, so operators are stuck with cookie auto-login and can never re-test the Login screen, password manager flow, or "log in as someone else" scenario. Add a BNO-faithful Esc menu overlay (Logout / Resume / Settings-placeholder) wired to BOTH Esc keydown AND a single click on the game canvas. Logout cleanly drops the Better-Auth session cookie + the Colyseus room, returns to LoginScene; Resume closes the overlay; Settings is a Phase 7 deferred placeholder.

  Purpose: per CONTEXT D-24 — the Esc menu UX feels right for BNO's command-screen aesthetic, and dual triggers (Esc + canvas click) cover both keyboard-led and mouse-led operator habits. Unblocks UAT Test 4 (browser password manager + autofill — currently BLOCKED by Finding #5) and adds the missing auth-lifecycle surface for CLI-02/CLI-03.

  Output: a new DOM-overlay component (EscMenu), GameScene wiring for both triggers, an idempotent signOut helper, a vitest unit suite for the overlay, and a Playwright e2e that proves the Logout cookie clear + LoginScene transition. Per gap-closure addendum line 360 — `[impl->REQ-CLI-02] [impl->REQ-CLI-03]`.
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-PATTERNS.md
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/auth/client.ts
  @apps/client/src/ui/ChatHUD.ts
  @apps/client/src/ui/reconnect-banner.ts
  @apps/client/index.html
  @apps/client/test/e2e/cli-08.e2e.test.ts
  @apps/client/test/e2e/fixtures.ts

  <interfaces>
  <!-- Existing patterns to mirror — read each file fully before writing EscMenu. -->

  ChatHUD shape (apps/client/src/ui/ChatHUD.ts) — canonical DOM-overlay reference:
  - export class ChatHUD { mount(parent: HTMLElement); unmount(); ... }
  - if (this.mounted) return; idempotency guard (line 81)
  - mounts under #dom-overlay (apps/client/index.html line 18)
  - emits events via callback constructor args, not DOM CustomEvent
  - styles inline via `el.style.X = ...` (matches UI-SPEC `Tool: none` posture)

  ReconnectBanner shape (apps/client/src/ui/reconnect-banner.ts) — second canonical reference:
  - mount(parent), update(state), unmount()
  - DOM div positioned: fixed; styled inline

  Better-Auth client SDK (apps/client/src/auth/client.ts existing exports):
  - signIn.email({...}) — already used by LoginScene form submit
  - getSession() — already used by BootScene cookie probe
  - signOut() — Better-Auth client SDK exports this; verify it's wired into the createAuthClient instance; if not, expose it from auth/client.ts

  InputDispatcher freeze pattern (existing — chat-mode uses this):
  - this.inputDispatcher.setFrozen(true)  → c2s.input messages suppressed
  - this.inputDispatcher.setFrozen(false) → resume normal input flow
  - If this method does not exist, add it; existing chat-mode wiring per D-04 implies SOME freeze mechanism — reuse it

  GameScene Esc-key handling: Phase 4 D-22 + Phase 6 D-04 reserve Esc for "cancel chat input". The new Esc menu fires when Esc is pressed AND chat is NOT in chat-mode. Order: chat-mode-aware Esc handler runs first; if chat input is closed, falls through to esc-menu open. Implement as a precedence check inside the existing Esc handler.

  Phaser canvas click trigger: this.input.on('pointerdown', () => { if (!escMenu.isOpen() && !chatHud.isChatMode()) escMenu.open(); }). Single click is sufficient (D-24 — "operator clicks the game canvas").

  DOM-overlay slot (apps/client/index.html):
  <div id="dom-overlay">
    <!-- ChatHUD mounts here -->
    <!-- ReconnectBanner mounts here -->
    <!-- EscMenu mounts here (new) -->
  </div>

  UI-SPEC §Color tokens (06-UI-SPEC.md lines 87-96) — Esc menu uses:
  - Background:        #0A0E1A (dominant; matches login form for visual consistency)
  - Button background: #1F2937 (secondary)
  - Logout button accent: #EF4444 (destructive — Logout is the destructive option per UI-SPEC §Destructive reserved-for "destructive-looking surfaces" — explicitly logout DROPS the session, equivalent to the disconnect-banner pattern)
  - Resume button accent: #22D3EE (accent — primary safe action)
  - Settings (disabled placeholder): #1F2937 with #F3F4F6 text at 0.5 opacity to signal disabled
  - Text on dominant: #F3F4F6
  - Font: monospace 16px / 400 (UI-SPEC §Typography body)
  - Heading "Menu": 24px / 600 (UI-SPEC §Typography heading)

  Spacing: padding 24px (token "lg"), gap 16px (token "md") between buttons.

  Position: centered absolutely, 320px wide (matches login form), z-index above canvas + ChatHUD but below ReconnectBanner.
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: EscMenu DOM-overlay component + idempotency unit tests</name>
    <files>
      apps/client/src/ui/EscMenu.ts,
      apps/client/src/__test__/esc-menu.test.ts,
      apps/client/index.html
    </files>
    <read_first>
      apps/client/src/ui/ChatHUD.ts (canonical mount/unmount pattern + event-callback shape),
      apps/client/src/ui/reconnect-banner.ts (second reference for inline styles + state updates),
      apps/client/src/__test__/chat-hud.test.ts (if exists — copy vitest harness shape; if absent, model after any apps/client/src/__test__/*.test.ts using JSDOM),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md §Color + §Spacing + §Typography
    </read_first>
    <behavior>
      - mount() — appends #esc-menu (hidden by default) under #dom-overlay; idempotent (`if (this.mounted) return;`)
      - open() — sets display, populates buttons, emits no-op (caller responsible for freeze); idempotent (`if (this._open) return;`)
      - close() — hides, idempotent
      - isOpen() — returns boolean
      - constructor takes { onLogout, onResume, onSettings } callback bag; Logout/Resume/Settings buttons invoke each respectively
      - unmount() — removes the DOM node; idempotent
      - Settings button is rendered but disabled (button.disabled = true; visible 'Settings — coming in Phase 7' tooltip / sub-text)
      - All inline styles use UI-SPEC §Color tokens (assert via test: button.style.color === 'rgb(243, 244, 246)' etc.)
    </behavior>
    <action>
  **1. apps/client/index.html** — Tag `<!-- [impl->REQ-CLI-02] [impl->REQ-CLI-03] -->` near the dom-overlay div. Add a comment slot for #esc-menu (no actual node — EscMenu mounts dynamically per the ChatHUD pattern):
  ```html
  <!-- [impl->REQ-CLI-02] [impl->REQ-CLI-03] EscMenu mounts here on demand -->
  ```

  **2. apps/client/src/ui/EscMenu.ts** — Tag `// [impl->REQ-CLI-02] [impl->REQ-CLI-03]` at top.

  ```ts
  // [impl->REQ-CLI-02] [impl->REQ-CLI-03]
  // Esc menu overlay (D-24). Non-modal — does NOT trap focus. Movement freeze
  // is the caller's responsibility (GameScene wires inputDispatcher.setFrozen).

  export interface EscMenuCallbacks {
    onLogout: () => void | Promise<void>;
    onResume: () => void;
    onSettings?: () => void; // intentionally unused in Phase 6 — Settings is a Phase 7 placeholder
  }

  export class EscMenu {
    private root: HTMLDivElement | null = null;
    private mounted = false;
    private _open = false;
    constructor(private cbs: EscMenuCallbacks) {}

    mount(parent: HTMLElement): void {
      if (this.mounted) return;
      const root = document.createElement('div');
      root.id = 'esc-menu';
      root.setAttribute('data-testid', 'esc-menu');
      root.style.cssText = [
        'position: fixed', 'top: 50%', 'left: 50%', 'transform: translate(-50%, -50%)',
        'width: 320px', 'padding: 24px', 'background: #0A0E1A',
        'color: #F3F4F6',
        'font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace',
        'font-size: 16px', 'line-height: 1.5',
        'display: none', 'flex-direction: column', 'gap: 16px',
        'z-index: 100',
      ].join('; ') + ';';

      const heading = document.createElement('div');
      heading.textContent = 'Menu';
      heading.style.cssText = 'font-size: 24px; font-weight: 600; line-height: 1.2;';

      const mkBtn = (label: string, bg: string, fg: string, onClick: () => void, opts: { disabled?: boolean; title?: string; testid?: string } = {}) => {
        const b = document.createElement('button');
        b.type = 'button';
        b.textContent = label;
        b.disabled = !!opts.disabled;
        if (opts.title) b.title = opts.title;
        if (opts.testid) b.setAttribute('data-testid', opts.testid);
        b.style.cssText = [
          'font-family: inherit', 'font-size: 16px', 'font-weight: 400',
          'padding: 8px 16px', `color: ${fg}`, `background: ${bg}`,
          'border: none', 'cursor: pointer',
          opts.disabled ? 'opacity: 0.5' : '',
        ].filter(Boolean).join('; ') + ';';
        b.addEventListener('click', () => { if (!b.disabled) onClick(); });
        return b;
      };

      // Resume = primary safe action (accent)
      const resumeBtn = mkBtn('Resume', '#22D3EE', '#0A0E1A', () => this.cbs.onResume(), { testid: 'esc-menu-resume' });
      // Settings = placeholder (Phase 7)
      const settingsBtn = mkBtn('Settings', '#1F2937', '#F3F4F6', () => this.cbs.onSettings?.(), {
        disabled: true, title: 'Settings — coming in Phase 7', testid: 'esc-menu-settings',
      });
      // Logout = destructive (drops session)
      const logoutBtn = mkBtn('Logout', '#EF4444', '#0A0E1A', async () => {
        logoutBtn.disabled = true; // guard against double-click
        try { await this.cbs.onLogout(); } finally { logoutBtn.disabled = false; }
      }, { testid: 'esc-menu-logout' });

      root.append(heading, resumeBtn, settingsBtn, logoutBtn);
      parent.appendChild(root);
      this.root = root;
      this.mounted = true;
    }

    open(): void {
      if (!this.mounted || this._open) return;
      if (this.root) this.root.style.display = 'flex';
      this._open = true;
    }

    close(): void {
      if (!this._open) return;
      if (this.root) this.root.style.display = 'none';
      this._open = false;
    }

    isOpen(): boolean { return this._open; }

    unmount(): void {
      if (!this.mounted) return;
      this.root?.remove();
      this.root = null;
      this.mounted = false;
      this._open = false;
    }
  }
  ```

  **3. apps/client/src/__test__/esc-menu.test.ts** — Tag `// [unit->REQ-CLI-02] [unit->REQ-CLI-03]`.

  ```ts
  // [unit->REQ-CLI-02] [unit->REQ-CLI-03]
  import { describe, it, expect, beforeEach, vi } from 'vitest';
  import { EscMenu } from '../ui/EscMenu';

  describe('EscMenu', () => {
    let parent: HTMLElement;
    beforeEach(() => {
      document.body.innerHTML = '<div id="dom-overlay"></div>';
      parent = document.getElementById('dom-overlay')!;
    });

    it('mounts idempotently', () => {
      const m = new EscMenu({ onLogout: vi.fn(), onResume: vi.fn() });
      m.mount(parent); m.mount(parent);
      expect(parent.querySelectorAll('#esc-menu').length).toBe(1);
    });

    it('opens and closes', () => {
      const m = new EscMenu({ onLogout: vi.fn(), onResume: vi.fn() });
      m.mount(parent);
      expect(m.isOpen()).toBe(false);
      m.open(); expect(m.isOpen()).toBe(true);
      m.close(); expect(m.isOpen()).toBe(false);
    });

    it('open/close are idempotent', () => {
      const m = new EscMenu({ onLogout: vi.fn(), onResume: vi.fn() });
      m.mount(parent);
      m.open(); m.open(); expect(m.isOpen()).toBe(true);
      m.close(); m.close(); expect(m.isOpen()).toBe(false);
    });

    it('Logout button invokes onLogout callback', async () => {
      const onLogout = vi.fn();
      const m = new EscMenu({ onLogout, onResume: vi.fn() });
      m.mount(parent); m.open();
      (parent.querySelector('[data-testid=esc-menu-logout]') as HTMLButtonElement).click();
      expect(onLogout).toHaveBeenCalledOnce();
    });

    it('Resume button invokes onResume callback', () => {
      const onResume = vi.fn();
      const m = new EscMenu({ onLogout: vi.fn(), onResume });
      m.mount(parent); m.open();
      (parent.querySelector('[data-testid=esc-menu-resume]') as HTMLButtonElement).click();
      expect(onResume).toHaveBeenCalledOnce();
    });

    it('Settings button is disabled (Phase 7 placeholder)', () => {
      const m = new EscMenu({ onLogout: vi.fn(), onResume: vi.fn() });
      m.mount(parent); m.open();
      const btn = parent.querySelector('[data-testid=esc-menu-settings]') as HTMLButtonElement;
      expect(btn.disabled).toBe(true);
      expect(btn.title).toMatch(/Phase 7/);
    });

    it('uses UI-SPEC §Color tokens (Resume = accent, Logout = destructive)', () => {
      const m = new EscMenu({ onLogout: vi.fn(), onResume: vi.fn() });
      m.mount(parent); m.open();
      const resume = parent.querySelector('[data-testid=esc-menu-resume]') as HTMLButtonElement;
      const logout = parent.querySelector('[data-testid=esc-menu-logout]') as HTMLButtonElement;
      expect(resume.style.background).toMatch(/#22D3EE|rgb\(34,\s*211,\s*238\)/i);
      expect(logout.style.background).toMatch(/#EF4444|rgb\(239,\s*68,\s*68\)/i);
    });

    it('unmounts cleanly', () => {
      const m = new EscMenu({ onLogout: vi.fn(), onResume: vi.fn() });
      m.mount(parent); m.unmount();
      expect(parent.querySelectorAll('#esc-menu').length).toBe(0);
      expect(m.isOpen()).toBe(false);
    });
  });
  ```
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test -- src/__test__/esc-menu.test.ts</automated>
    </verify>
    <done>
      apps/client/src/ui/EscMenu.ts exports a mount/open/close/unmount lifecycle matching the ChatHUD pattern. 8 vitest tests in esc-menu.test.ts pass. UI-SPEC §Color tokens applied verbatim (Resume = #22D3EE accent, Logout = #EF4444 destructive, Settings disabled at 0.5 opacity). DOM-overlay HTML slot tagged.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: GameScene wiring (Esc + canvas click → menu) + signOut helper + Playwright logout e2e</name>
    <files>
      apps/client/src/scenes/GameScene.ts,
      apps/client/src/auth/client.ts,
      apps/client/test/e2e/logout.e2e.test.ts
    </files>
    <read_first>
      apps/client/src/scenes/GameScene.ts (find: existing Esc handler in chat-mode; existing this.input.on('pointerdown') if any; InputDispatcher freeze API),
      apps/client/src/auth/client.ts (find: existing createAuthClient + signIn.email + getSession; check if signOut is exported),
      apps/client/src/net/colyseus-client.ts (room.leave() pattern — used during reconnect/teardown),
      apps/client/test/e2e/cli-08.e2e.test.ts (Playwright shape — copy describe/test imports + loginAs fixture usage)
    </read_first>
    <behavior>
      - Esc keydown inside GameScene: if chat-mode is open → defer to chat handler; else → escMenu.open() + inputDispatcher.setFrozen(true)
      - Phaser pointerdown on canvas: if escMenu not open AND chat not in chat-mode AND no other modal active → escMenu.open() + inputDispatcher.setFrozen(true)
      - Resume → escMenu.close() + inputDispatcher.setFrozen(false)
      - Logout → call signOut() → room.leave() (best-effort; catch errors) → scene.start('LoginScene', { fastPath: false }) (no username, no sessionToken — fresh login required)
      - signOut() in auth/client.ts: calls Better-Auth client.signOut(); clears any sessionStorage 'colyseus-reconnect' token cache; idempotent (safe to call multiple times)
      - Playwright e2e: loginAs(accountA) → wait for game-ready → press Escape → assert [data-testid=esc-menu] visible → click [data-testid=esc-menu-logout] → assert form#login-form heading visible AND document.cookie does NOT contain better-auth session cookie name
    </behavior>
    <action>
  **1. apps/client/src/auth/client.ts** — Add or expose signOut. Tag `// [impl->REQ-CLI-02] [impl->REQ-CLI-03]` near the new export.

  If the file already creates an authClient via `createAuthClient({...})` per Better-Auth docs, ensure `authClient.signOut` is reachable. Add a small wrapper:

  ```ts
  // [impl->REQ-CLI-02] [impl->REQ-CLI-03]
  /** Signs out the Better-Auth session. Idempotent — safe to call multiple times. */
  export async function signOut(): Promise<void> {
    try {
      await authClient.signOut();
    } catch (err) {
      // Better-Auth signOut on an already-cleared session may 401; treat as success.
      console.warn('[auth] signOut: ignoring non-fatal error', err);
    }
    // Clear Colyseus reconnection-token cache so a stale token can't auto-resume
    try {
      sessionStorage.removeItem('colyseus-reconnect');
    } catch {/* sessionStorage may be unavailable in some test envs */}
  }
  ```

  (Adjust import name if the existing module exports `client` instead of `authClient`. Match existing file conventions.)

  **2. apps/client/src/scenes/GameScene.ts** — Tag `// [impl->REQ-CLI-02] [impl->REQ-CLI-03]` near the new EscMenu wiring section.

  Add to GameScene:
  ```ts
  // imports
  import { EscMenu } from '../ui/EscMenu';
  import { signOut } from '../auth/client';

  // in class fields
  private escMenu!: EscMenu;

  // in create() — after chatHud.mount + reconnectBanner.mount
  this.escMenu = new EscMenu({
    onLogout: async () => {
      // [impl->REQ-CLI-02] [impl->REQ-CLI-03]
      this.escMenu.close();
      try { await signOut(); } catch {/* noop — signOut is idempotent */}
      try { this.room?.leave(); } catch {/* room may already be closed */}
      this.scene.start('LoginScene', { fastPath: false });
    },
    onResume: () => {
      this.escMenu.close();
      this.inputDispatcher?.setFrozen(false);
    },
  });
  const overlay = document.getElementById('dom-overlay');
  if (overlay) this.escMenu.mount(overlay);

  // Esc keydown wiring — adapt to existing handler structure
  // IMPORTANT: precedence — chat-mode Esc cancels chat first; only fires menu when chat is closed.
  this.input.keyboard?.on('keydown-ESC', () => {
    if (this.chatHud?.isChatMode?.()) return; // existing chat-mode handler takes Esc
    if (this.escMenu.isOpen()) {
      this.escMenu.close();
      this.inputDispatcher?.setFrozen(false);
    } else {
      this.escMenu.open();
      this.inputDispatcher?.setFrozen(true);
    }
  });

  // Canvas pointerdown — D-24 secondary trigger
  this.input.on('pointerdown', () => {
    if (this.escMenu.isOpen()) return;
    if (this.chatHud?.isChatMode?.()) return;
    // any other modal (force-reset overlay)? skip if active
    this.escMenu.open();
    this.inputDispatcher?.setFrozen(true);
  });

  // in shutdown()/destroy() — cleanup
  this.escMenu?.unmount();
  ```

  **Edge cases to handle in the executor's discretion (not load-bearing for this plan):**
  - If `inputDispatcher.setFrozen` does not exist as an API yet, add it: `setFrozen(b: boolean) { this._frozen = b; }` + check `_frozen` in dispatch path.
  - If `chatHud.isChatMode` does not exist as a method, add a getter.
  - If GameScene has shutdown() lifecycle missing, add `shutdown() { this.escMenu?.unmount(); }`.

  **3. apps/client/test/e2e/logout.e2e.test.ts** — Tag `// [int->REQ-CLI-02] [int->REQ-CLI-03]`.

  ```ts
  // [int->REQ-CLI-02] [int->REQ-CLI-03]
  // Regression guard for UAT Finding #5 (D-24): no logout affordance.
  // Tests both Esc trigger AND canvas click trigger; verifies cookie cleared.

  import { test, expect } from '@playwright/test';
  import { loginAs, waitForGameReady, accountA } from './fixtures';

  test.describe('Esc menu + logout (D-24)', () => {
    test('Esc opens menu; Logout returns to LoginScene with cleared session', async ({ page }) => {
      await loginAs(page, accountA);
      await waitForGameReady(page);
      await page.keyboard.press('Escape');
      await expect(page.locator('[data-testid=esc-menu]')).toBeVisible();
      await page.click('[data-testid=esc-menu-logout]');
      // Back at LoginScene
      await expect(page.locator('form#login-form h1')).toBeVisible({ timeout: 5000 });
      // Better-Auth session cookie cleared
      const cookies = await page.context().cookies();
      const sessionCookies = cookies.filter((c) => /better-auth|session/i.test(c.name));
      expect(sessionCookies.every((c) => !c.value || c.value === '')).toBe(true);
    });

    test('canvas click opens menu (D-24 secondary trigger)', async ({ page }) => {
      await loginAs(page, accountA);
      await waitForGameReady(page);
      // Click roughly the center of the canvas
      const canvas = page.locator('canvas[data-game-ready=true]');
      await canvas.click({ position: { x: 100, y: 100 } });
      await expect(page.locator('[data-testid=esc-menu]')).toBeVisible();
    });

    test('Resume closes menu and re-engages movement', async ({ page }) => {
      await loginAs(page, accountA);
      await waitForGameReady(page);
      await page.keyboard.press('Escape');
      await expect(page.locator('[data-testid=esc-menu]')).toBeVisible();
      await page.click('[data-testid=esc-menu-resume]');
      await expect(page.locator('[data-testid=esc-menu]')).toBeHidden();
    });
  });
  ```
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test:e2e -- --grep "Esc menu"</automated>
    </verify>
    <done>
      GameScene wires both Esc keydown AND canvas pointerdown to escMenu.open(); chat-mode-Esc precedence preserved. signOut() helper exported from auth/client.ts and idempotent. Logout flow: signOut → room.leave → scene.start('LoginScene'). Playwright e2e (3 tests) GREEN. Operator can now exit GameScene; UAT Test 4 (browser password manager) is unblocked for re-test at 06-17.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Client → Better-Auth signOut endpoint | Logout intent crosses into auth perimeter; cookie clear is server-side authoritative |
  | Operator click → game-canvas → modal escalation | Canvas click is "untrusted" in that it's incidentally fired during gameplay; menu must NOT open spuriously while in chat-mode or during a force-reset overlay |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-11-01 | Elevation | Logout doesn't actually clear the cookie → next session reuses old auth | mitigate | Playwright e2e asserts `document.cookie` does not contain better-auth session after Logout; signOut() wraps Better-Auth SDK + clears sessionStorage reconnection-token cache |
  | T-06-11-02 | DoS | Operator spams canvas click while typing in chat → menu pops over chat input | mitigate | pointerdown handler checks chatHud.isChatMode() and exits early; chat-mode Esc precedence preserved |
  | T-06-11-03 | Tampering | Adversary replaces Resume callback to trigger Logout silently | accept | Code is bundled; no runtime extension surface in Phase 6; if a Phase 7 plugin model is added, revisit |
  | T-06-11-04 | Repudiation | Operator claims Logout didn't fire | mitigate | Logout button has data-testid; e2e captures the click; Playwright reporter logs the failure if button missing |
  </threat_model>

  <verification>
  - `pnpm --filter @rebno/client test -- src/__test__/esc-menu.test.ts` exits 0 (8 vitest tests)
  - `pnpm --filter @rebno/client test:e2e -- --grep "Esc menu"` exits 0 (3 Playwright tests)
  - `pnpm --filter @rebno/client typecheck` exits 0
  - Manual smoke: log in to local dev, press Esc → menu opens; click Logout → back at LoginScene; cookie cleared in DevTools Application tab
  - `pnpm verify:phase-6` (after this plan + Wave 11 land) passes the new e2e file as part of step 9
  - `pnpm trace:check` reports `[impl->REQ-CLI-02]` + `[impl->REQ-CLI-03]` tags satisfied for the new logout surface
  </verification>

  <success_criteria>
  - UAT Finding #5 (D-24) closed: GameScene has a logout affordance reachable via both Esc and canvas click
  - BNO-faithful command-screen UX (per D-24 rationale) — non-modal overlay that freezes movement keys while open
  - UAT Test 4 (browser password manager) unblocked — operator can sign out, sign in again, exercise autofill
  - Settings option visible as a Phase 7 placeholder (does NOT smuggle in deferred functionality)
  - Tags `[impl->REQ-CLI-02] [impl->REQ-CLI-03]` (per gap-closure addendum line 360)
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-11-SUMMARY.md` recording:
  (a) Final EscMenu component public API (mount/open/close/isOpen/unmount + callback shape)
  (b) GameScene precedence rules locked (chat-mode Esc takes precedence; canvas click suppressed during chat-mode + during any modal)
  (c) signOut() idempotency confirmed by manual double-click smoke test
  (d) Confirmation that document.cookie has the better-auth session cleared after Logout (paste a Playwright reporter snippet for traceability)
  (e) Any deviations from D-24 (should be zero — flag if Settings turned out to need a real handler beyond placeholder)
  </output>
  </content>
  </invoke>```

  ### Plan 06-12
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 12
  type: execute
  wave: 9
  depends_on: []
  files_modified:
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md
  autonomous: true
  requirements: [CLI-09, CLI-04]
  must_haves:
    truths:
      - "06-12-DEBUG-FINDINGS.md exists in the phase directory and is the canonical input contract for plan 06-15 — 06-15's executor reads this file (not the generic .planning/debug/reconnect-blank-render.md) to produce the fix"
      - "Document carries the root cause verbatim: the Better-Auth session_token is dropped between BootScene → LoginScene fast-path → GameScene; cookie auto-login forwards only `username`; GameScene early-returns at empty token (line 191-192 of GameScene.ts)"
      - "Document carries the recommended fix direction (Option A — three edits in BootScene/LoginScene/GameScene threading sessionToken through init data; Option B — defence in depth via getSession() self-heal in GameScene.create)"
      - "Document folds in the secondary defect (GameScene.ts:238-252 stale inputDispatcher.room reference after silent_reauth) with a regression test sketch — flagged as in-scope for 06-15 per CONTEXT addendum line 361"
      - "Document specifies the four required regression tests (1 GameScene unit, 1 LoginScene unit, 2 Playwright e2e in cli-08.e2e.test.ts family) so 06-15's executor has a checklist"
      - "No source file modifications occur in this plan — pure-doc consolidation"
    artifacts:
      - path: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md
        provides: "Phase-local consolidation of the diagnose-only output from .planning/debug/reconnect-blank-render.md — input contract for plan 06-15"
        contains: "session_token"
    key_links:
      - from: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md
        to: .planning/debug/reconnect-blank-render.md
        via: "explicit citation in §0 — points to the global debug document for full diagnostic trail; phase-local doc is the curated input for 06-15"
        pattern: "reconnect-blank-render"
      - from: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md
        to: 06-15-PLAN.md (downstream)
        via: "06-15's <context> block references this doc; 06-15's tasks consume the §Required test coverage list verbatim"
        pattern: "06-12-DEBUG-FINDINGS"
  ---

  <objective>
  Consolidate the diagnose-only output from `.planning/debug/reconnect-blank-render.md` (root-causing UAT Finding #2) into a phase-local `06-12-DEBUG-FINDINGS.md` document that plan 06-15 will consume verbatim as its input contract. This plan does NOT modify any source files; it produces ONE Markdown artifact whose job is to give 06-15's executor a self-contained, in-phase reference for the cookie-auto-login blank-render bug, the recommended fix shape, the secondary stale-inputDispatcher-room defect (folded in per CONTEXT addendum line 361), and the four required regression tests.

  Purpose: per CONTEXT D-25 — `/gsd-debug` was run BEFORE planning to isolate the root cause; the global debug doc lives at `.planning/debug/reconnect-blank-render.md` (already root-caused, status `root_caused`). 06-15 needs a phase-local copy curated to "fix shape + test checklist" so the executor doesn't have to re-derive the diagnosis. Also folds in the silently-out-of-scope §6 secondary defect (stale `InputDispatcher.room` after silent_reauth) which the gap-closure addendum scopes INTO 06-15.

  Output: one Markdown artifact (`06-12-DEBUG-FINDINGS.md`) in the phase directory. Per gap-closure addendum line 361 — `[doc->REQ-CDOC-*]` (the doc itself) + `[impl/int->REQ-CLI-09]` is delivered downstream by 06-15.

  Important: this is a doc-only plan with one task. Per planner anti-pattern guidance, normally we'd combine; here the doc IS the single concern and ~10-15% context cost is appropriate as a wave-9 dependency lever (06-15 cannot start without it).
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/debug/reconnect-blank-render.md
  @apps/client/src/scenes/BootScene.ts
  @apps/client/src/scenes/LoginScene.ts
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/auth/client.ts
  @apps/client/src/net/colyseus-client.ts

  <interfaces>
  <!-- The global debug doc IS the input. The phase-local doc is the curated -->
  <!-- output. The interface contract is the section list below — 06-15's      -->
  <!-- executor will read these sections by header name.                       -->

  Required sections in 06-12-DEBUG-FINDINGS.md (in this order):
    §0  Source + status — 1-line citation of .planning/debug/reconnect-blank-render.md, status: root_caused, no source files modified
    §1  Symptom — 4-line summary (cookie auto-login + WS-kill→reload paths both render blank canvas)
    §2  Root cause — sessionToken dropped through Boot→Login fast-path→Game; GameScene early-return at !sessionToken; verbatim line numbers
    §3  Affected files table — 6 rows (BootScene 78-95, LoginScene 80-116 + 106-109, GameScene 191-192 + 238-252, auth/client.ts 84-108, colyseus-client.ts 82-99, RebnoRoom.ts 267-269 read-only)
    §4  Primary fix shape — Option A (3 edits, preferred) + Option B (defence-in-depth) — 06-15's executor picks one; both should ship
    §5  Secondary defect (in scope for 06-15 per CONTEXT addendum) — stale inputDispatcher.room after silent_reauth; fix sketch + unit-test sketch
    §6  Required regression tests — 4-item checklist:
          a) Unit (GameScene): scene.init({ username }) without sessionToken → getSession returns token → joinRebnoRoom IS called
          b) Unit (LoginScene): fast-path forwards sessionToken into scene.start('GameScene', ...)
          c) e2e (cookie auto-login): login → reload → [data-game-ready=true] re-asserts within 5s + chat line renderable
          d) e2e (WS kill within grace): login → __rebno.room.connection.close() → SDK auto-reconnects → [data-game-ready=true] still truthy + chat round-trip
    §7  Out of scope (documented for clarity) — server bearer-vs-cookie posture is correct; pure client-side data flow bug
    §8  References — list of file paths and the global debug doc

  Tagging contract:
    Top of file: [doc->REQ-CDOC-04] (or whichever CDOC ID covers reconnect-flow doc — fall back to a CLI-09 doc tag if no CDOC ID matches; the markdown can carry both safely)
    AND [doc->REQ-CLI-09] [doc->REQ-CLI-04] for downstream traceability
  </interfaces>
  </context>

  <tasks>

  <task type="auto">
    <name>Task 1: Write 06-12-DEBUG-FINDINGS.md per the section contract</name>
    <files>
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md
    </files>
    <read_first>
      .planning/debug/reconnect-blank-render.md (full file — this is the source-of-truth being consolidated; copy line-numbered evidence verbatim where it improves precision),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md (Gap-Closure Addendum lines 307+, especially D-25 + plan-tagging guidance line 361 noting the secondary defect is in scope for 06-15),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md (Test 1 + Test 5 + Findings #2 — operator's exact reproduction steps)
    </read_first>
    <action>
  Create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md` with the following structure. Tag at top:

  ```md
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 12
  type: doc-only
  status: complete
  input_for: 06-15-PLAN.md
  source: .planning/debug/reconnect-blank-render.md
  ---

  # Reconnect / Cookie-Auto-Login Blank GameScene — Consolidated Findings (input for 06-15)

  [doc->REQ-CLI-09] [doc->REQ-CLI-04] [doc->REQ-CDOC-04]

  > **Diagnose-only consolidation.** No source files were edited by this plan. This document is the input contract for plan **06-15** (the fix plan); 06-15 owns code edits, regression tests, and the secondary `InputDispatcher` stale-room defect (folded in per CONTEXT D-25 + addendum line 361).

  ## §0 Source + status

  - Original diagnostic: `.planning/debug/reconnect-blank-render.md` (`status: root_caused`)
  - Operator UAT trigger: `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md` Test 1 + Test 5 + Findings table row #2
  - This plan modifies zero source files.

  ## §1 Symptom

  On either of these reconnect paths the canvas goes blank — only the `#0A0E1A` background, no HUD message history, no remotes, no self avatar:

  1. **Reload the tab** after a fresh login (cookie auto-login fast-path).
  2. **Kill the WS** in DevTools (`__rebno.room.connection.close()`) and let the SDK either auto-resume within grace or fall through to silent reauth. Past-grace branch: the disconnect banner click reloads the tab → same cookie auto-login path as #1.

  Specifically: `data-game-ready` never set; `window.rebno.remotePlayers === []`; no WebSocket in DevTools Network panel; chat hint mounts but fades after 5 s.

  ## §2 Root cause

  **The cookie auto-login path drops the Better-Auth `session_token` between BootScene → LoginScene (fast-path) → GameScene.** The server's WS `onAuth` handler requires `session_token` as a `Bearer` header, so `GameScene.connect()` short-circuits and no Colyseus room is ever joined.

  Verbatim evidence (file paths + line numbers — do not re-derive):

  1. `apps/client/src/scenes/GameScene.ts:191-192` — `if (!this.sessionToken) return;` (silent early-return)
  2. `apps/server/src/RebnoRoom.ts:267-269` — server requires `Authorization: Bearer ${session_token}`; cookie WS auth is NOT wired
  3. `apps/client/src/scenes/BootScene.ts:78-95` — fetches `getSession()` (which returns `{ user, token }`) but only forwards `username` to LoginScene fast-path init data
  4. `apps/client/src/scenes/LoginScene.ts:106-109` — fast-path timer: `this.scene.start('GameScene', { username })` — no sessionToken
  5. `apps/client/src/scenes/LoginScene.ts:173-177` — form-submit path DOES pass `result.session_token` (the working path; reference for the fix shape)
  6. `apps/client/src/auth/client.ts:84-108` — `getSession()` already returns `SessionShape.token`; the data is reachable, just not forwarded
  7. `apps/client/src/net/colyseus-client.ts:82-99` — sessionStorage reconnection-token cache is unreachable because connect() returns early before joinRebnoRoom()

  Why the secondary symptoms vanish too (per debug doc):
  - No remotes: `bindHandlers` never fires → `players.onAdd` never registers → no `playerRenderer.addRemote(...)` calls
  - No self avatar: same chain — `onLocalJoin` (the `players.onAdd` callback for local sessionId) never runs
  - No room background: `onRoomLayout` (the `s2c.room_layout` handler) is bound inside `bindHandlers` and never reached
  - HUD chat hint disappears: ChatHUD mounts before connect() early-return so the hint appears, then fades to opacity 0 after 5 s (ChatHUD.ts:127-129)

  ## §3 Affected files

  | File | Lines | Role in the bug | Edited by 06-15? |
  |------|-------|-----------------|------------------|
  | `apps/client/src/scenes/BootScene.ts` | 78-95 | Drops `session.token`; only forwards `username` | YES |
  | `apps/client/src/scenes/LoginScene.ts` | 80-116, esp 106-109 | Fast-path passes `{ username }` only to GameScene | YES |
  | `apps/client/src/scenes/GameScene.ts` | 191-192 | Early-return on missing `sessionToken` | YES (Option A) or KEEP + Option B self-heal |
  | `apps/client/src/scenes/GameScene.ts` | 238-252 | `if (!this.inputDispatcher)` keeps stale `room` after silent_reauth (§5 secondary defect) | YES (per CONTEXT addendum line 361) |
  | `apps/client/src/auth/client.ts` | 84-108 | `getSession()` already returns `token` — fix consumes directly | NO (read-only) |
  | `apps/client/src/net/colyseus-client.ts` | 82-99 | Cached-reconnect path is reachable without bearer for within-grace branch (informational; fix is upstream) | NO |
  | `apps/server/src/RebnoRoom.ts` | 267-269 | Server contract: WS `onAuth` is bearer-only, no cookie fallback | NO — DO NOT change |

  ## §4 Primary fix shape

  ### Option A (preferred — minimal surface, three edits)

  1. `BootScene.create` (line 88-95): forward `session.token` into `LoginSceneInitData` alongside `username`.
  2. `LoginScene.init` / `LoginSceneInitData` shape (line 26-51): accept `sessionToken?: string`; store on the instance.
  3. `LoginScene.renderFastPath` (line 106-109): pass `{ sessionToken, username }` to `scene.start('GameScene', …)`.

  Keep the existing `GameScene.connect` early-return as a defensive check (still covers the genuinely unauthenticated case) but ensure the cookie path satisfies it.

  ### Option B (defence in depth — combine with A, not instead of)

  In `GameScene.create`, if `this.sessionToken` is undefined, call `getSession()` and adopt `session.token`. Self-heal against future scene-init shape changes. **06-15 should ship BOTH** — they're complementary:
  - A fixes the immediate path
  - B prevents recurrence if anyone refactors scene-init wiring later

  ## §5 Secondary defect (in scope for 06-15 per CONTEXT addendum line 361)

  In the past-grace silent-reauth branch (`GameScene.connect` runs a second time inside the same Phaser scene), the existing `InputDispatcher` keeps a stale `room` reference:

  `apps/client/src/scenes/GameScene.ts:238-252`
  ```ts
  if (!this.inputDispatcher) {
    ...
    this.inputDispatcher = new InputDispatcher(this.room, stubPrediction);
    ...
  }
  ```

  The `if (!this.inputDispatcher)` guard means the dispatcher is created once and never re-pointed at the new `this.room` after a silent reauth. Any movement intent dispatched after reauth will be `room.send`-ed through the closed/garbage-collected old room object → silent movement freeze.

  **Fix sketch (06-15 owns):**

  ```ts
  if (!this.inputDispatcher) {
    this.inputDispatcher = new InputDispatcher(this.room, stubPrediction);
  } else {
    // Refresh room ref — silent_reauth produced a new room object; old one is closed.
    this.inputDispatcher.setRoom(this.room);
  }
  ```

  (Or expose a `room` setter on InputDispatcher if not already there.)

  **Unit test sketch (06-15 owns):**
  ```ts
  // after silent_reauth cycle
  expect(dispatcher.room).toBe(gameScene.room);
  ```

  ## §6 Required regression tests for 06-15

  1. **Unit** (`apps/client/src/__test__/game-scene.test.ts`): add a case where `scene.init({ username: 'me' })` (no `sessionToken`) is followed by `getSession()` returning a token; assert `joinRebnoRoom` IS called with the token. Covers Option B.
  2. **Unit** (`apps/client/src/__test__/login-scene.test.ts`): assert that `LoginScene` fast-path forwards `sessionToken` into `scene.start('GameScene', ...)`. Covers Option A end-to-end at the LoginScene→GameScene boundary.
  3. **e2e** (`apps/client/test/e2e/cli-08.e2e.test.ts` family — add to existing file or create `cookie-reload.e2e.test.ts`):
     - log in with the form;
     - assert `[data-game-ready="true"]` on the canvas;
     - reload the page;
     - re-assert `[data-game-ready="true"]` within 5 s of reload;
     - assert at least one `[data-chat-line]` is renderable (send a message after reload).
  4. **e2e** (kill-WS path — same file or sibling):
     - log in;
     - call `__rebno.room.connection.close()` from `page.evaluate`;
     - wait for the SDK to auto-reconnect (within grace);
     - assert `[data-game-ready="true"]` is STILL truthy and a follow-up chat send round-trips.
  5. **Unit** (covers §5 secondary): after silent_reauth cycle, assert `dispatcher.room === gameScene.room`.

  (Tests #1-4 close Finding #2; test #5 closes the secondary defect bundled into 06-15.)

  ## §7 Out of scope (documented for clarity)

  - Server-side bearer-vs-cookie posture: **correct as-is**. The bug is purely client-side data flow.
  - Future cookie-WS-auth refactor: deferred to a Phase 7 retro if the bearer-only contract proves brittle. Phase 6 ships with bearer-only.
  - Reconnect-banner UX timing: graded by UAT Test 5 separately; not in this finding's scope.

  ## §8 References

  - `apps/client/src/scenes/BootScene.ts`
  - `apps/client/src/scenes/LoginScene.ts`
  - `apps/client/src/scenes/GameScene.ts`
  - `apps/client/src/auth/client.ts`
  - `apps/client/src/net/colyseus-client.ts`
  - `apps/client/src/net/reconnect.ts`
  - `apps/client/src/ui/ChatHUD.ts`
  - `apps/client/src/ui/reconnect-banner.ts`
  - `apps/client/index.html`
  - `apps/server/src/RebnoRoom.ts` (read-only; do not edit)
  - `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md` (Finding #2 source)
  - `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md` (D-25 sequencing + addendum line 361 secondary-defect inclusion)
  - `.planning/debug/reconnect-blank-render.md` (full diagnostic trail; this doc is its phase-local consolidation)
  ```

  Confirm the file is complete and exits with §8 References. No source-file edits.
    </action>
    <verify>
      <automated>node -e "const fs=require('fs'); const p='.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md'; const t=fs.readFileSync(p,'utf-8'); const required=['§0 Source','§1 Symptom','§2 Root cause','§3 Affected files','§4 Primary fix shape','§5 Secondary defect','§6 Required regression tests','§7 Out of scope','§8 References','session_token','InputDispatcher','Option A','Option B','06-15']; for (const s of required) { if (!t.includes(s)) { console.error('MISSING:', s); process.exit(1); } } console.log('all sections present');"</automated>
    </verify>
    <done>
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md exists with §0-§8 sections, root cause + Option A/B fix shape + secondary defect + 5-item test checklist + references. Tagged [doc->REQ-CLI-09] [doc->REQ-CLI-04] [doc->REQ-CDOC-04]. Zero source files modified. Plan 06-15 has its input contract.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Phase-local doc → 06-15's executor | If this doc misrepresents the root cause, 06-15 ships a wrong fix; mitigated by direct citation of line numbers from the global debug doc |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-12-01 | Repudiation | Doc gets edited later, drifts from the truth captured at /gsd-debug time | mitigate | §0 cites the global debug doc + UAT — both immutable per planning-state model; if drift, 06-15 reconciles |
  | T-06-12-02 | Tampering | Doc instructs 06-15 to edit server bearer logic | mitigate | §3 + §4 + §7 explicitly mark `apps/server/src/RebnoRoom.ts` as read-only; instructions are bounded |
  </threat_model>

  <verification>
  - File exists at the canonical phase-local path
  - All required §0-§8 sections present (verified by inline node script)
  - No source-file modifications in apps/, packages/, tools/ (this plan is doc-only — `git status` shows only the new .md file)
  - `pnpm trace:check` reports the new doc tags satisfied for REQ-CLI-09 and REQ-CLI-04 doc-stage coverage
  </verification>

  <success_criteria>
  - 06-15-PLAN.md (next wave) can name `06-12-DEBUG-FINDINGS.md` in its `<context>` block and the executor has self-contained instructions
  - Secondary defect (stale `InputDispatcher.room` after silent_reauth) is documented + bundled into 06-15's scope per CONTEXT addendum line 361
  - Four canonical regression tests are pre-specified — 06-15's executor doesn't have to invent them
  - Tags: `[doc->REQ-CLI-09] [doc->REQ-CLI-04] [doc->REQ-CDOC-04]`
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-SUMMARY.md` recording:
  (a) Confirmation the file shipped at the canonical path
  (b) Any clarifications added beyond the global debug doc (e.g. fold-in language for the secondary defect)
  (c) Pointer that 06-15 is the consumer; this plan completes when the doc exists
  (d) Trace tag verification snippet (paste `pnpm trace:check` output for REQ-CLI-09 + REQ-CLI-04)
  </output>
  </content>
  </invoke>```

  ### Plan 06-13
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 13
  type: execute
  wave: 9
  depends_on: []
  files_modified:
    - packages/game-logic/src/constants.ts
    - packages/game-logic/src/step.ts
    - packages/game-logic/test/movement-constants.test.ts
    - packages/game-logic/test/step-bno-fidelity.test.ts
    - apps/client/src/prediction/predictor.ts
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md
  autonomous: true
  requirements: [CLI-04]
  must_haves:
    truths:
      - "Movement numeric constants in packages/game-logic/src/constants.ts are derived from extracted Client 5-8 GML — NOT invented arbitrarily — and every constant carries a comment citing its source GML file + line"
      - "06-13-MOVEMENT-DERIVATION.md records the derivation trail: which GML files were read, what each numeric value was originally, how it maps onto packages/game-logic units (px/tick, sub-pixel accumulator, tick rate)"
      - "Unit tests pin every derived constant — any future tweak that changes walk speed, friction, key-buffering, sub-pixel accumulator behavior, or tick-rate assumption must update the constant AND the test in the same commit (regression guard against silent drift)"
      - "step() in packages/game-logic/src/step.ts consumes the constants from constants.ts (not inline magic numbers); deterministic output verified by snapshot test against a known-good input/output trace from BNO-faithful expected values"
      - "PredictionEngine in apps/client/src/prediction/predictor.ts uses the same constants (no client-side override); the lint-prevented import of arbitrary numbers is preserved"
      - "CLAUDE.md hard rule #4/#5/#6 honored: extract → document → rewrite. Numbers come from extracted GML (already done in Phase 1), document the derivation (this plan's MOVEMENT-DERIVATION.md), then code consumes (constants.ts + step.ts)"
      - "Player object naming gotcha honored: the canonical mover is `extracted/client-5-8/objects/0000-server/` events (NOT 0042-player or any navi* script — addendum line 387)"
    artifacts:
      - path: packages/game-logic/src/constants.ts
        provides: "Movement constants derived from extracted GML — walk_speed_px_per_tick, key_buffer_window_ms, friction_decel_px_per_tick_sq, accel_px_per_tick_sq, sub_pixel_accumulator_mode, tick_rate_hz; each carries a comment citing source GML file + line"
        contains: "WALK_SPEED"
      - path: packages/game-logic/src/step.ts
        provides: "Pure step(state, inputs, dt) consuming constants.ts; preserves Phase 4 D-20 purity (no Date.now, no Math.random, no I/O)"
        contains: "WALK_SPEED"
      - path: packages/game-logic/test/movement-constants.test.ts
        provides: "Pinning tests — each constant has a test asserting its exact derived value with a comment citing the GML source line; any drift fails the test"
        contains: "WALK_SPEED"
      - path: packages/game-logic/test/step-bno-fidelity.test.ts
        provides: "Snapshot tests — known input vectors (e.g. 'press right for 60 ticks at 30hz') produce known position outputs matching BNO behavior; deterministic"
        contains: "fidelity"
      - path: apps/client/src/prediction/predictor.ts
        provides: "PredictionEngine consumes packages/game-logic step() with no override — no inline magic numbers"
        contains: "step"
      - path: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md
        provides: "Doc-only: derivation trail mapping every GML line to the constant in constants.ts; auditable evidence the numbers are not invented"
        contains: "0000-server"
    key_links:
      - from: packages/game-logic/src/constants.ts
        to: extracted/client-5-8/objects/0000-server/events/Step.gml + Keyboard-37/38/39/40.gml
        via: "every export carries a // SOURCE: comment citing the extracted GML file + line; lint can later assert (out of scope here)"
        pattern: "SOURCE:.*extracted/client-5-8"
      - from: packages/game-logic/src/step.ts
        to: packages/game-logic/src/constants.ts
        via: "import { WALK_SPEED, ACCEL, FRICTION, KEY_BUFFER_WINDOW_MS, TICK_RATE_HZ } from './constants'"
        pattern: "from './constants'"
      - from: apps/client/src/prediction/predictor.ts
        to: packages/game-logic/src/step.ts
        via: "PredictionEngine wraps step(state, inputs, dt) per Phase 6 D-11 extrapolation pattern; no override constants"
        pattern: "step|PredictionEngine"
      - from: packages/game-logic/test/movement-constants.test.ts
        to: packages/game-logic/src/constants.ts
        via: "import + assert each export against the value in MOVEMENT-DERIVATION.md"
        pattern: "expect.*WALK_SPEED|expect.*ACCEL"
  ---

  <objective>
  Close UAT Finding #3a (D-26 Plan A): movement does not match BNO because the prediction loop uses arbitrary client-invented constants instead of values derived from the original Client 5-8 GML. Read the canonical mover events on the original player-character object (`extracted/client-5-8/objects/0000-server/` — NOT 0042-player or navi* scripts; see CONTEXT addendum line 387), reverse the numeric constants (walk speed, key-buffering window, friction/accel if any, sub-pixel accumulator behavior, tick rate), pin them into `packages/game-logic/src/constants.ts`, update `step()` and `PredictionEngine` to consume the constants, write pinning unit tests + a BNO-fidelity snapshot test, and document the derivation trail in `06-13-MOVEMENT-DERIVATION.md`.

  Purpose: per CONTEXT D-26 Plan A — this plan is the numeric foundation. Plan B (06-16) will build the 8-direction sprite-state machine on top of these constants and on top of the asset atlases from 06-14. No asset dependency in this plan; runs in parallel with the other Wave-9 plan (06-12). CLAUDE.md hard rule #4/#5/#6 (extract → document → rewrite) is the governing principle.

  Output: a single canonical constants module + matching tests + derivation doc + minimal step() and PredictionEngine wiring updates. Per gap-closure addendum line 362 — `[impl/unit->REQ-CLI-04]` (movement prediction; numerics).
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-RESEARCH.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-PATTERNS.md
  @packages/game-logic/src/step.ts
  @apps/client/src/prediction/predictor.ts
  @apps/server/src/RebnoRoom.ts
  @decomp/wiki/15-extraction-pipeline.md
  @extracted/client-5-8/objects/0000-server/events/Step.gml
  @extracted/client-5-8/objects/0000-server/events/Step-1.gml
  @extracted/client-5-8/objects/0000-server/events/Step-2.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-37.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-38.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-39.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-40.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-37.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-38.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-39.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-40.gml
  @extracted/client-5-8/objects/0000-server/events/Create.gml
  @extracted/client-5-8/scripts/0285-player_update.gml
  @extracted/client-5-8/scripts/0347-iv_keyactions.gml
  @extracted/client-5-8/scripts/0355-pcode_mover.gml

  <interfaces>
  <!-- Existing interfaces — read each before editing. -->

  packages/game-logic/src/step.ts (Phase 4 D-20 — pure):
    export function step(state, inputs, dt) → state
    - Lint-enforced: no Date.now, no Math.random, no I/O
    - Currently consumes inline magic numbers (THIS plan extracts them to constants.ts)

  apps/client/src/prediction/predictor.ts (Phase 6 plan 06-XX):
    Wraps step() for client-side prediction; reconciler.ts handles divergence
    Currently re-derives some constants client-side (THIS plan removes that override)

  apps/server/src/RebnoRoom.ts (Phase 4 + Phase 6 D-09):
    tickLoop calls step() server-side — automatically picks up new constants
    TICK_RATE_HZ may be referenced here too (Phase 4 D-22 set it; verify the value matches what BNO uses, if not, this plan adjusts)

  GML reading guide (HARD RULES — read first):
    - 0000-server is the player character object in BNO (PROJECT.md gotcha — NOT 0042-player)
    - Step.gml / Step-1.gml / Step-2.gml — per-tick movement application
    - Keyboard-37/38/39/40 — held-key handlers (37=left, 38=up, 39=right, 40=down — Win VK codes)
    - KeyRelease-* — key-up handlers; reveal whether motion stops instantly (no friction) or decelerates
    - Create.gml — initial state; reveals starting speed/accel/friction values
    - Other-*.gml — special cases (room enter, etc.) — usually skip unless they touch motion vars
    - 0285-player_update.gml + 0347-iv_keyactions.gml + 0355-pcode_mover.gml — script-level callbacks invoked from the object events; read after the object events

  Numeric constants to derive (planner has named these; executor confirms exact values from GML):
    WALK_SPEED_PX_PER_TICK            — speed magnitude when a direction key is held (most likely "spd" or "sp" var in GML)
    ACCEL_PX_PER_TICK_SQ              — only if Step.gml shows acceleration (e.g. spd += accel until cap); else 0 (BNO is likely instant-stop, no accel)
    FRICTION_PX_PER_TICK_SQ           — only if KeyRelease shows decay; else 0 (instant stop on release)
    KEY_BUFFER_WINDOW_MS              — only if Keyboard-* shows key-pressed-recently grace; else 0
    SUB_PIXEL_ACCUMULATOR_MODE        — 'none' | 'snap-floor' | 'snap-round' | 'fractional' (look for `x = floor(x + ...)` or `x += ...` patterns; GM5 may store integer vs real)
    TICK_RATE_HZ                      — `room_speed` global in Create.gml or first room init (commonly 30 or 60 in GM5; BNO is probably 30)
    STARTING_DIRECTION                — direction at spawn (likely DOWN for BNO)

  If a GML construct doesn't translate cleanly (e.g. GM5 uses `motion_set(direction, speed)` builtins), document the equivalent in the derivation doc and pick the closest TS-numeric model.

  Lint guard (deferred to a future plan, NOT in scope here): a future lint could enforce that every export in constants.ts carries a SOURCE: comment. Plan now by writing the comments; the lint can pin later.

  Tagging contract:
    packages/game-logic/src/constants.ts → // [impl->REQ-CLI-04]
    packages/game-logic/src/step.ts (header)→ // [impl->REQ-CLI-04]
    packages/game-logic/test/*.test.ts → // [unit->REQ-CLI-04]
    apps/client/src/prediction/predictor.ts (header) → // [impl->REQ-CLI-04]
    06-13-MOVEMENT-DERIVATION.md → [doc->REQ-CLI-04]
  </interfaces>
  </context>

  <tasks>

  <task type="auto">
    <name>Task 1: Reverse-engineer movement constants from extracted GML; document derivation</name>
    <files>
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md,
      packages/game-logic/src/constants.ts
    </files>
    <read_first>
      extracted/client-5-8/objects/0000-server/events/Create.gml (look for: spd, sp, hsp, vsp, friction, accel, room_speed; whatever variables are initialized HERE govern the rest),
      extracted/client-5-8/objects/0000-server/events/Step.gml + Step-1.gml + Step-2.gml (per-tick movement application — confirm what hsp/vsp do per tick),
      extracted/client-5-8/objects/0000-server/events/Keyboard-37.gml + 38.gml + 39.gml + 40.gml (held-key handlers — what speed do they set? do they set vector or speed+direction?),
      extracted/client-5-8/objects/0000-server/events/KeyRelease-37.gml + 38.gml + 39.gml + 40.gml (key-up — instant stop or friction-decay?),
      extracted/client-5-8/scripts/0285-player_update.gml + 0347-iv_keyactions.gml + 0355-pcode_mover.gml (cross-reference — does the player object call these scripts? what do they do?),
      extracted/client-5-8/objects/0000-server/meta.json (object metadata — sprite refs, depth, persistence; informs derivation context),
      extracted/client-5-8/rooms/0058-BNCentral/meta.json (look for room_speed if not in object Create; rooms can override)
    </read_first>
    <action>
  **1. Read all the GML files above.** Note observations as you go. Common GM5 patterns:

  | GML pattern | Meaning |
  |-------------|---------|
  | `room_speed = 30;` (in room init or game setup) | TICK_RATE_HZ = 30 |
  | `spd = 4;` or `sp = 4;` | WALK_SPEED_PX_PER_TICK = 4 (verify the unit; some games store px/sec) |
  | `motion_set(0, 4);` | direction-aware motion: 0 = right, 90 = up, 180 = left, 270 = down; speed 4 |
  | `hsp = 4; vsp = 0;` | direct h/v vector |
  | `x += hsp; y += vsp;` (in Step) | applied per tick — px/tick |
  | `if (keyboard_check(vk_right)) hsp = 4;` | instant-set; no accel |
  | `if (keyboard_check(vk_right)) hsp += 0.5;` | accelerating; FRICTION + ACCEL apply |
  | `hsp *= 0.9;` (in KeyRelease or Step when no key) | friction = 0.1 per tick (decay factor 0.9) |
  | `hsp = 0;` (in KeyRelease) | instant stop, no friction |
  | `x = round(x + hsp);` or `x += hsp; x = floor(x);` | sub-pixel: snap-round / snap-floor |
  | `x += hsp;` (no rounding, x is real) | sub-pixel: fractional |

  **2. Write `06-13-MOVEMENT-DERIVATION.md`** documenting the derivation:

  ```md
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 13
  type: doc-only-derivation
  status: complete
  ---

  # Movement Constants — Derivation from Extracted Client 5-8 GML

  [doc->REQ-CLI-04]

  > Per CLAUDE.md hard rule #4/#5/#6: extract → document → rewrite. Phase 1 produced `extracted/client-5-8/`; this document maps each numeric constant in `packages/game-logic/src/constants.ts` to its source GML line, satisfying the "document" stage.

  ## Source GML (read by this plan)

  | File | Role in derivation |
  |------|--------------------|
  | `extracted/client-5-8/objects/0000-server/events/Create.gml` | Initial values: spd / sp / hsp / vsp / friction / accel / room_speed if set here |
  | `extracted/client-5-8/objects/0000-server/events/Step.gml` (+ Step-1 + Step-2) | Per-tick application: how x/y advance, sub-pixel handling |
  | `extracted/client-5-8/objects/0000-server/events/Keyboard-37/38/39/40.gml` | Held-key direction handlers (Win VK 37-40 = L/U/R/D) |
  | `extracted/client-5-8/objects/0000-server/events/KeyRelease-37/38/39/40.gml` | Key-up: instant stop vs friction decay |
  | `extracted/client-5-8/scripts/0285-player_update.gml` | Script invoked from object events (cross-reference) |
  | `extracted/client-5-8/scripts/0347-iv_keyactions.gml` | Input-vector mapping (cross-reference) |
  | `extracted/client-5-8/scripts/0355-pcode_mover.gml` | Mover pcode (cross-reference) |
  | `extracted/client-5-8/rooms/0058-BNCentral/meta.json` | room_speed override if present |

  ## Derivation table

  | Constant | Value | Source | Notes |
  |----------|-------|--------|-------|
  | `TICK_RATE_HZ` | _<derived>_ | _<file>:<line>_ | _<verbatim GML quote>_ |
  | `WALK_SPEED_PX_PER_TICK` | _<derived>_ | _<file>:<line>_ | _<verbatim>_ |
  | `ACCEL_PX_PER_TICK_SQ` | _<derived or 0>_ | _<file>:<line>_ or "absent — instant-set per Keyboard-* events" | |
  | `FRICTION_PX_PER_TICK_SQ` | _<derived or 0>_ | _<file>:<line>_ or "absent — instant-stop per KeyRelease-* events" | |
  | `KEY_BUFFER_WINDOW_MS` | _<derived or 0>_ | _<file>:<line>_ or "absent — direct keyboard_check, no buffering" | |
  | `SUB_PIXEL_ACCUMULATOR_MODE` | _'fractional' | 'snap-floor' | 'snap-round' | 'none'_ | _<file>:<line>_ | _<verbatim quote of x/y assignment line>_ |
  | `STARTING_DIRECTION` | _<derived; likely 270 = DOWN>_ | _<Create.gml line>_ | |
  | `STARTING_X`, `STARTING_Y` | _<derived if present in Create>_ | _<Create.gml line>_ | (informational; server-side D-07 home-portal spawn overrides) |

  ## GML constructs that don't translate directly

  _(For each: brief note on the GM5 builtin used and the chosen TS equivalent.)_

  - `motion_set(dir, speed)` → equivalent: hsp = cos(dir°) * speed; vsp = -sin(dir°) * speed (TS step() applies)
  - _<other constructs encountered>_

  ## Decisions made by this plan

  _(Document any judgement calls — e.g. "GML uses `keyboard_check_pressed` for buffering at line N; we map this to KEY_BUFFER_WINDOW_MS = X")_

  ## Open questions for 06-16 (sprite-state plan)

  The 8-direction facing-on-input semantics will read additional events:
  - Draw.gml — sprite selection per facing direction
  - Step events again — facing transitions

  Out of scope for this numeric-only plan but flagged so 06-16 doesn't have to rediscover the source files.
  ```

  **Fill in the table from your GML reading. Use exact verbatim quotes.** If a constant is "absent" (e.g. no friction in BNO), say so explicitly with the negative-evidence quote.

  **3. Write `packages/game-logic/src/constants.ts`** — Tag at top:

  ```ts
  // [impl->REQ-CLI-04]
  // Movement constants derived from extracted Client 5-8 GML.
  // See .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md
  // Every export here carries a SOURCE: comment citing the extracted GML file + line.
  // Pure data module — no Date.now, no Math.random, no I/O (Phase 4 D-20 lint applies).

  /** Tick rate. SOURCE: extracted/client-5-8/objects/0000-server/events/Create.gml:<line> ("room_speed = N;") */
  export const TICK_RATE_HZ = <DERIVED>;

  /** Walk speed in pixels per tick when a direction key is held.
   *  SOURCE: extracted/client-5-8/objects/0000-server/events/Keyboard-39.gml:<line> ("hsp = N;" or "spd = N;") */
  export const WALK_SPEED_PX_PER_TICK = <DERIVED>;

  /** Acceleration in px/tick² (0 if BNO uses instant-set, no acceleration).
   *  SOURCE: <file>:<line> (or "absent — instant-set per Keyboard-* handlers") */
  export const ACCEL_PX_PER_TICK_SQ = <DERIVED>;

  /** Friction (deceleration) in px/tick² (0 if KeyRelease sets hsp = 0 directly).
   *  SOURCE: <file>:<line> (or "absent — instant-stop per KeyRelease-* handlers") */
  export const FRICTION_PX_PER_TICK_SQ = <DERIVED>;

  /** Key-buffering window in ms (0 if BNO uses keyboard_check directly without buffering).
   *  SOURCE: <file>:<line> (or "absent") */
  export const KEY_BUFFER_WINDOW_MS = <DERIVED>;

  /** Sub-pixel accumulator mode.
   *  SOURCE: extracted/client-5-8/objects/0000-server/events/Step.gml:<line> ("x += hsp;" → 'fractional', or "x = floor(x + hsp);" → 'snap-floor') */
  export const SUB_PIXEL_ACCUMULATOR_MODE = <'fractional' | 'snap-floor' | 'snap-round' | 'none'> as const;

  /** Starting facing direction. 270 = DOWN per BNO sprite convention.
   *  SOURCE: extracted/client-5-8/objects/0000-server/events/Create.gml:<line> */
  export const STARTING_DIRECTION_DEGREES = <DERIVED>;
  ```

  (Fill `<DERIVED>` from your GML reading. If a value is uncertain, prefer the most conservative interpretation — instant-set/instant-stop defaults — and document the uncertainty in MOVEMENT-DERIVATION.md.)
    </action>
    <verify>
      <automated>node -e "const fs=require('fs'); const c=fs.readFileSync('packages/game-logic/src/constants.ts','utf-8'); if (c.includes('<DERIVED>')) { console.error('UNRESOLVED: <DERIVED> placeholder left in constants.ts — fill all values from extracted GML before commit'); process.exit(1); } const required=['TICK_RATE_HZ','WALK_SPEED_PX_PER_TICK','ACCEL_PX_PER_TICK_SQ','FRICTION_PX_PER_TICK_SQ','KEY_BUFFER_WINDOW_MS','SUB_PIXEL_ACCUMULATOR_MODE','SOURCE:','extracted/client-5-8']; for (const s of required) { if (!c.includes(s)) { console.error('MISSING in constants.ts:', s); process.exit(1); } } const d=fs.readFileSync('.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md','utf-8'); if (d.includes('<DERIVED>')) { console.error('UNRESOLVED: <DERIVED> placeholder left in derivation doc'); process.exit(1); } for (const s of ['Derivation table','TICK_RATE_HZ','WALK_SPEED_PX_PER_TICK','SUB_PIXEL_ACCUMULATOR_MODE','0000-server']) { if (!d.includes(s)) { console.error('MISSING in derivation doc:', s); process.exit(1); } } console.log('constants + derivation present, no placeholders remain');"</automated>
    </verify>
    <done>
      constants.ts exports 6+ named constants, each with a SOURCE: comment citing extracted GML. 06-13-MOVEMENT-DERIVATION.md records the derivation trail with file+line citations and verbatim GML quotes. Any 'absent' constant is explicitly flagged with negative evidence. No source files outside packages/game-logic/src/constants.ts touched in this task.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Update step() + PredictionEngine to consume constants; pin with unit + fidelity tests</name>
    <files>
      packages/game-logic/src/step.ts,
      packages/game-logic/test/movement-constants.test.ts,
      packages/game-logic/test/step-bno-fidelity.test.ts,
      apps/client/src/prediction/predictor.ts
    </files>
    <read_first>
      packages/game-logic/src/step.ts (current implementation — find the inline magic numbers being replaced),
      packages/game-logic/test/*.test.ts (existing test layout — copy harness shape; vitest or node:test depending on what's already there),
      apps/client/src/prediction/predictor.ts (find any inline constants the client-side overrode; remove and use the canonical ones),
      apps/server/src/RebnoRoom.ts (cross-check: server tickLoop should use step() unmodified — verify TICK_RATE_HZ here matches packages/game-logic constants.ts; if drift, this plan unifies),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md (your own derivation doc — the test expected values come from here)
    </read_first>
    <behavior>
      - movement-constants.test.ts: one test per exported constant in constants.ts; each test asserts the exact derived value with a comment citing the GML source line; test fails if any export's value drifts
      - step-bno-fidelity.test.ts: a small set of golden input → output snapshots — e.g. "from (0, 0), press right for 60 ticks at TICK_RATE_HZ → end position is (60 * WALK_SPEED_PX_PER_TICK, 0)"; "press right for 1 tick then release → end position depends on FRICTION; if FRICTION=0, snap to (WALK_SPEED, 0); if FRICTION>0, decay to 0 over N ticks"; each scenario calls step() repeatedly and asserts the trajectory
      - PredictionEngine in apps/client/src/prediction/predictor.ts no longer overrides any movement constant — fully consumes step() output
      - All tests are deterministic (no Date.now, no Math.random)
    </behavior>
    <action>
  **1. packages/game-logic/src/step.ts** — Tag `// [impl->REQ-CLI-04]` near the export. Replace any inline magic numbers (e.g. `4` for speed, `30` for tick rate, `0.9` for friction factor) with imports from `./constants`:

  ```ts
  // [impl->REQ-CLI-04]
  import {
    WALK_SPEED_PX_PER_TICK,
    ACCEL_PX_PER_TICK_SQ,
    FRICTION_PX_PER_TICK_SQ,
    TICK_RATE_HZ,
    SUB_PIXEL_ACCUMULATOR_MODE,
  } from './constants';

  // ... existing step() signature unchanged ...
  // Replace inline numbers with the imported constants. Sub-pixel handling
  // follows SUB_PIXEL_ACCUMULATOR_MODE per the derivation doc.
  ```

  (The exact replacement depends on the existing step.ts shape. The executor reads it, identifies every magic number used in motion math, and substitutes the named constant. If step() currently does NOT exist or is a stub, implement the minimal correct shape per the derivation: instant-set on key-down, instant-stop on key-release, sub-pixel mode applied per accumulator MODE.)

  **2. packages/game-logic/test/movement-constants.test.ts** — Tag `// [unit->REQ-CLI-04]`.

  ```ts
  // [unit->REQ-CLI-04]
  // Pinning tests for movement constants. Drift = test failure.
  // Source values: see ../src/constants.ts SOURCE: comments + 06-13-MOVEMENT-DERIVATION.md.

  import { describe, it, expect } from 'vitest'; // or node:test if that's the existing harness
  import {
    TICK_RATE_HZ,
    WALK_SPEED_PX_PER_TICK,
    ACCEL_PX_PER_TICK_SQ,
    FRICTION_PX_PER_TICK_SQ,
    KEY_BUFFER_WINDOW_MS,
    SUB_PIXEL_ACCUMULATOR_MODE,
    STARTING_DIRECTION_DEGREES,
  } from '../src/constants';

  describe('movement constants — derived from extracted Client 5-8 GML', () => {
    it('TICK_RATE_HZ pins to BNO room_speed', () => {
      expect(TICK_RATE_HZ).toBe(<DERIVED>); // SOURCE: <file>:<line>
    });
    it('WALK_SPEED_PX_PER_TICK pins to BNO Keyboard-* hsp/spd value', () => {
      expect(WALK_SPEED_PX_PER_TICK).toBe(<DERIVED>);
    });
    it('ACCEL_PX_PER_TICK_SQ pins (0 if instant-set in BNO)', () => {
      expect(ACCEL_PX_PER_TICK_SQ).toBe(<DERIVED>);
    });
    it('FRICTION_PX_PER_TICK_SQ pins (0 if instant-stop in BNO)', () => {
      expect(FRICTION_PX_PER_TICK_SQ).toBe(<DERIVED>);
    });
    it('KEY_BUFFER_WINDOW_MS pins (0 if direct keyboard_check)', () => {
      expect(KEY_BUFFER_WINDOW_MS).toBe(<DERIVED>);
    });
    it('SUB_PIXEL_ACCUMULATOR_MODE pins to Step.gml x/y assignment style', () => {
      expect(SUB_PIXEL_ACCUMULATOR_MODE).toBe(<'fractional' | 'snap-floor' | 'snap-round' | 'none'>);
    });
    it('STARTING_DIRECTION_DEGREES pins (270 = DOWN per BNO convention)', () => {
      expect(STARTING_DIRECTION_DEGREES).toBe(<DERIVED>);
    });
  });
  ```

  **3. packages/game-logic/test/step-bno-fidelity.test.ts** — Tag `// [unit->REQ-CLI-04]`.

  ```ts
  // [unit->REQ-CLI-04]
  // BNO movement fidelity — golden input/output snapshots from derived constants.
  // Each scenario calls step() N times and asserts the deterministic trajectory.

  import { describe, it, expect } from 'vitest';
  import { step } from '../src/step';
  import { WALK_SPEED_PX_PER_TICK, FRICTION_PX_PER_TICK_SQ } from '../src/constants';

  const initState = () => ({ /* match the existing state shape — players: { local: { x: 0, y: 0, ... } } */ });
  const inputsHeldRight = { axes: { x: 1, y: 0 }, /* ... */ };
  const inputsRelease = { axes: { x: 0, y: 0 }, /* ... */ };

  describe('step() fidelity vs BNO constants', () => {
    it('press right for 60 ticks → x advances by 60 * WALK_SPEED_PX_PER_TICK (instant-set, no accel)', () => {
      let s = initState();
      for (let i = 0; i < 60; i++) {
        s = step(s, inputsHeldRight, 1 / TICK_RATE_HZ);
      }
      expect(s.players.local.x).toBe(60 * WALK_SPEED_PX_PER_TICK);
      expect(s.players.local.y).toBe(0);
    });

    it('press right for 1 tick then release → instant-stop OR friction-decay per FRICTION', () => {
      let s = initState();
      s = step(s, inputsHeldRight, 1 / TICK_RATE_HZ);
      const xAfterPress = s.players.local.x;
      s = step(s, inputsRelease, 1 / TICK_RATE_HZ);
      if (FRICTION_PX_PER_TICK_SQ === 0) {
        // Instant stop: x stays at xAfterPress (no further motion)
        expect(s.players.local.x).toBe(xAfterPress);
      } else {
        // Friction-decay: x advances less than another full WALK_SPEED on the next tick
        expect(s.players.local.x).toBeLessThan(xAfterPress + WALK_SPEED_PX_PER_TICK);
        expect(s.players.local.x).toBeGreaterThanOrEqual(xAfterPress);
      }
    });

    it('determinism: same input → same output across 1000 runs', () => {
      let final1 = initState();
      let final2 = initState();
      for (let i = 0; i < 100; i++) {
        final1 = step(final1, inputsHeldRight, 1 / TICK_RATE_HZ);
        final2 = step(final2, inputsHeldRight, 1 / TICK_RATE_HZ);
      }
      expect(final1).toEqual(final2);
    });
  });
  ```

  (Adjust the state shape + step() signature to match the existing module. The TICK_RATE_HZ import is needed if the dt parameter is in seconds.)

  **4. apps/client/src/prediction/predictor.ts** — Tag `// [impl->REQ-CLI-04]` at top. Find any local `const SPEED = 4` or similar magic numbers; replace with imports from `@rebno/game-logic/constants` (or whatever the workspace export path is). If predictor.ts already correctly delegates to step() without overrides, just add the tag and a comment noting "no client-side override; consumes packages/game-logic step() unmodified per Phase 6 D-11."

  **5. Verify server-side too**: read `apps/server/src/RebnoRoom.ts` for any hardcoded `30` (tick rate) or duplicated movement constants. If found, the executor MAY (within this plan's scope) update RebnoRoom.ts to import TICK_RATE_HZ from `@rebno/game-logic/constants` to keep the server in sync. If the server uses a different tick-loop module, document the cross-reference but do NOT block — Phase 4 D-22 already established the server-side rate; this plan's responsibility is the game-logic + client side.
    </action>
    <verify>
      <automated>pnpm --filter @rebno/game-logic test &amp;&amp; pnpm --filter @rebno/client typecheck</automated>
    </verify>
    <done>
      step.ts imports all motion constants from constants.ts (no inline magic numbers in motion math). predictor.ts has no client-side override of WALK_SPEED / FRICTION / ACCEL / TICK_RATE. movement-constants.test.ts pins 7 constants (one test each). step-bno-fidelity.test.ts has 3+ golden trajectory tests including a determinism check. All tests GREEN. RebnoRoom.ts cross-reference verified (either uses constants from @rebno/game-logic or documented why not in the SUMMARY).
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Extracted GML → derived TS constants | If derivation misreads a GML expression (e.g. units px/tick vs px/sec), client + server simulate at wrong speed → players see desync; mitigated by Plan A's pinning tests + 06-17 UAT video evidence |
  | packages/game-logic constants → server step() | Server is authoritative; if server is faster than client, prediction overshoots → reconciliation snaps; if slower, prediction undershoots → reconciliation catches up. D-10 threshold absorbs small drift; large drift means a constants bug |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-13-01 | Tampering | Future PR silently changes WALK_SPEED to "feel better" — diverges from BNO fidelity | mitigate | movement-constants.test.ts pins exact value; SOURCE: comment forces PR author to update derivation doc + GML citation |
  | T-06-13-02 | Information leakage | None — constants are not secrets | accept | n/a |
  | T-06-13-03 | Repudiation | "We don't know where this number came from" | mitigate | SOURCE: comment + MOVEMENT-DERIVATION.md = full trail from GML line → constant → test |
  | T-06-13-04 | DoS | A constant set wrong (e.g. WALK_SPEED = 1000) creates server-side spd that exceeds rate-limit / collision step → bug, not security; caught by step-bno-fidelity tests |
  </threat_model>

  <verification>
  - `pnpm --filter @rebno/game-logic test` exits 0 (movement-constants + step-bno-fidelity all GREEN)
  - `pnpm --filter @rebno/client typecheck` exits 0
  - `pnpm --filter @rebno/client test` exits 0 (no PredictionEngine regressions)
  - `pnpm trace:check` reports `[impl->REQ-CLI-04]` and `[unit->REQ-CLI-04]` tags satisfied for the new files
  - 06-13-MOVEMENT-DERIVATION.md exists, has the derivation table fully populated (no `<DERIVED>` placeholders left)
  - Manual smoke (deferred to 06-17 UAT): two-client move feels closer to BNO. Final lock at 06-17 UAT video.
  </verification>

  <success_criteria>
  - Numeric movement constants derived from extracted Client 5-8 GML (per CLAUDE.md hard rule #4/#5/#6) and pinned in code + tests
  - Derivation trail auditable in 06-13-MOVEMENT-DERIVATION.md
  - step() and PredictionEngine consume constants from a single source (constants.ts) — no client/server drift
  - Plan B (06-16 sprite-state machine) has a stable numeric foundation to build on
  - Tags `[impl/unit->REQ-CLI-04]` (per gap-closure addendum line 362)
  - Player object naming gotcha (0000-server, NOT 0042-player or navi*) honored throughout
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-SUMMARY.md` recording:
  (a) Final values of all 7+ derived constants (paste from constants.ts)
  (b) Any GML-construct-doesn't-translate notes (e.g. motion_set → cos/sin decomposition)
  (c) Any uncertainty flagged in derivation (acknowledge if a value was best-guess from ambiguous GML)
  (d) Cross-reference outcome: did RebnoRoom.ts get updated to import TICK_RATE_HZ from constants.ts, or is the server's existing value verified to match?
  (e) Confirmation 06-16 (sprite-state plan) has a stable numeric foundation
  (f) Trace tag verification snippet for REQ-CLI-04 impl + unit stages
  </output>
  </content>
  </invoke>```

  ### Plan 06-14
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 14
  type: execute
  wave: 10
  depends_on: []
  files_modified:
    - tools/asset-pipeline/src/bootstrap.ts
    - tools/asset-pipeline/cli.ts
    - tools/asset-pipeline/test/bootstrap.test.ts
    - tools/asset-pipeline/src/build.ts
    - tools/room-converter/src/bn-central.ts
    - tools/room-converter/test/bn-central.test.ts
    - apps/server/rooms/bn-central/000.json
    - apps/server/rooms/bn-central/000.sig
    - apps/server/src/RebnoRoom.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/assets/room-renderer.ts
    - apps/client/src/assets/atlas-loader.ts
    - apps/client/src/render/PlayerRenderer.ts
    - apps/client/test/e2e/cli-08.e2e.test.ts
    - assets/source/sprites/.gitkeep
    - package.json
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md
  autonomous: true
  requirements: [AST-01, CLI-07]
  must_haves:
    truths:
      - "tools/asset-pipeline ships a `bootstrap` mode that reads extracted/client-5-8/sprites/<id>/frames/img_*.bmp + meta.json and emits assets/source/sprites/<id>.{png,json} (Aseprite-compatible PNG strip + JSON sidecar)"
      - "All 15 sprite IDs from CONTEXT addendum line 336 (0000, 0027, 0029-0033, 0036-0043) are bootstrapped → committed Aseprite-compatible source files; tile/door sprites referenced by 0058-BNCentral/tiles.json are also bootstrapped"
      - "tools/asset-pipeline `build` packs the bootstrapped sprites into atlas-mvp.{png,json} via existing 06-08 build mode (extending, not rebuilding)"
      - "tools/room-converter has a bn-central converter that reads extracted/client-5-8/rooms/0058-BNCentral/{instances.json,tiles.json,creation-code.gml,backgrounds.json,meta.json} and emits apps/server/rooms/bn-central/000.json conforming to Phase 4 SRV-13 room-layout schema + Ed25519 signed (000.sig) per Phase 4 D-12"
      - "Server room registration in apps/server/src/RebnoRoom.ts replaces 'mvp-lobby' with 'bn-central' (or registers both during transition; final state = bn-central is the canonical room for CLI-08 UAT)"
      - "Client GameScene + RoomRenderer + PlayerRenderer load atlas-mvp.{png,json} and render real BNO sprites (replacing the blue rectangle placeholder); PlayerRenderer.addRemote/ensureLocal use NaviStandD as default frame"
      - "Playwright e2e (06-08's cli-08.e2e.test.ts) updated to assert data-room-id='bn-central' and that a non-blue sprite is rendered (e.g. canvas pixel sample at player position is NOT solid #0000FF / blue rectangle marker)"
      - "Replaces synthetic mvp-lobby — does NOT keep both as production rooms; mvp-lobby may stay as a dev-only / test-only fallback at Claude's discretion but bn-central is the canonical CLI-08 room"
      - "First-pass scope EXCLUDES audio (Phase 7 AST-02), fonts (Phase 7 AST-03), other 9 extracted rooms (Phase 7 PAR-03) — explicitly per addendum line 340"
    artifacts:
      - path: tools/asset-pipeline/src/bootstrap.ts
        provides: "Bootstrap mode entry point — reads extracted sprite frames + meta, emits Aseprite-compatible source files via sharp BMP→PNG strip"
        contains: "bootstrap"
      - path: tools/asset-pipeline/cli.ts
        provides: "CLI dispatcher — adds `bootstrap` subcommand alongside the existing `build`"
        contains: "bootstrap"
      - path: tools/room-converter/src/bn-central.ts
        provides: "BN Central room converter — bridges extracted/client-5-8/rooms/0058-BNCentral/* → apps/server/rooms/bn-central/000.json (SRV-13 schema)"
        contains: "0058-BNCentral"
      - path: apps/server/rooms/bn-central/000.json
        provides: "Signed room-layout JSON (SRV-13 contract) for BN Central — instances + tiles + backgrounds, ready for s2c.room_layout broadcast"
        contains: "bn-central"
      - path: apps/server/rooms/bn-central/000.sig
        provides: "Ed25519 signature over 000.json (Phase 4 D-12 / Phase 5 D-19 ritual)"
        contains: ""
      - path: assets/source/sprites/.gitkeep
        provides: "Marks the directory as committed; bootstrap output (per-sprite .png + .json) goes here per CONTEXT D-14"
        contains: ""
      - path: apps/client/src/render/PlayerRenderer.ts
        provides: "Loads atlas-mvp; renders real NaviStandD sprite for self + remotes (no more blue rectangle); 8-direction sprite SELECTION wiring lives in 06-16, not here — this plan ships StandD as the default-frame baseline"
        contains: "NaviStandD"
      - path: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md
        provides: "Doc-only — lists every sprite/tile bootstrapped + the 0058-BNCentral conversion summary; auditable AST-01 first-pass scope"
        contains: "0027"
    key_links:
      - from: tools/asset-pipeline/src/bootstrap.ts
        to: extracted/client-5-8/sprites/<id>/frames/img_*.bmp + meta.json
        via: "sharp.raw().resize().png() pipeline; reads meta.json for origin + frame_count; emits assets/source/sprites/<id>.{png,json}"
        pattern: "extracted/client-5-8/sprites"
      - from: tools/room-converter/src/bn-central.ts
        to: extracted/client-5-8/rooms/0058-BNCentral/*.json + creation-code.gml
        via: "JSON parse + GML lex (light — extract instance.create_code if needed); maps to SRV-13 layout shape"
        pattern: "0058-BNCentral"
      - from: apps/server/src/RebnoRoom.ts
        to: apps/server/rooms/bn-central/000.json
        via: "broadcastRoomLayout reads bn-central instead of mvp-lobby; room registration matchmaker.defineRoomType('bn-central', ...)"
        pattern: "bn-central"
      - from: apps/client/src/scenes/GameScene.ts
        to: apps/client/src/render/PlayerRenderer.ts + apps/client/src/assets/atlas-loader.ts
        via: "preload atlas-mvp via this.load.atlas('mvp', '/atlas-mvp.png', '/atlas-mvp.json'); PlayerRenderer.ensureLocal/addRemote use atlas frame keys"
        pattern: "atlas-mvp"
      - from: apps/client/test/e2e/cli-08.e2e.test.ts
        to: data-room-id="bn-central" + non-blue-pixel assertion
        via: "expect(canvas.evaluate(canvas.getAttribute('data-room-id'))).toBe('bn-central')"
        pattern: "bn-central"
  ---

  <objective>
  Close UAT Finding #4 (D-27): there are no real BNO assets in the project — GameScene shows blue rectangles + cyan nameplates over an empty black background, and the synthetic `mvp-lobby` room used in 06-07 has no relationship to the original game. Bridge the existing `tools/asset-pipeline/` (06-08 lint + manifest schema; never piped real input) with a `bootstrap` mode that converts extracted Client 5-8 BMP sprite frames + meta into Aseprite-compatible PNG-strip source files; pack via the existing `build` mode into `atlas-mvp.{png,json}`; convert the canonical first-pass room (`extracted/client-5-8/rooms/0058-BNCentral/` — the actual latest online lobby in BNO, NOT the older `0004-Online_Lobby`) into a Phase 4 SRV-13 room-layout JSON signed per Phase 4 D-12; wire server room registration to use `bn-central` (replacing `mvp-lobby`); update GameScene + PlayerRenderer + RoomRenderer to load the atlas and render real sprites + the BN Central room background and tiles; update the 06-08 Playwright e2e to assert `data-room-id="bn-central"` and that the player render is no longer a solid-blue rectangle.

  Purpose: per CONTEXT D-27 — first-pass real assets are the unblock for UAT Test 1 (HiDPI nearest-neighbor — needs real sprites to evaluate pixel correctness), Test 2 (CLI-08 milestone artifact — needs real game presentation), Test 3 (Aseprite round-trip — needs source files committed). Player object naming gotcha (0000-server, NOT 0042-player) and room naming gotcha (0058-BNCentral, NOT 0004-Online_Lobby) are honored. Nametag (D-27a) ships in 06-16 (where the per-player render path lives), NOT here.

  Output: extended asset-pipeline bootstrap mode + bn-central room converter + signed room JSON + server registration update + client atlas loader + PlayerRenderer real-sprite rendering + e2e assertion update + asset manifest doc. Per gap-closure addendum line 363 — `[impl->REQ-AST-01] [impl->REQ-CLI-07]`.

  This is the largest plan in the gap-closure set (3 tasks, multiple subsystems). Splitting further would fragment a single coherent feature ("real assets land end-to-end"); the natural seam is bootstrap+atlas (Task 1) → room conversion + server (Task 2) → client render + e2e (Task 3).
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-RESEARCH.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-PATTERNS.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-08-SUMMARY.md
  @tools/asset-pipeline/cli.ts
  @tools/asset-pipeline/src/build.ts
  @tools/asset-pipeline/src/manifest.ts
  @tools/asset-catalog/cli.ts
  @tools/extract-gmd/cli.ts
  @tools/room-converter/src/index.ts
  @tools/scripts/lint-room-layout.mjs
  @tools/scripts/lint-asset-pipeline.mjs
  @apps/server/src/RebnoRoom.ts
  @apps/server/rooms/mvp-lobby/000.json
  @apps/server/rooms/mvp-lobby/000.sig
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/assets/room-renderer.ts
  @apps/client/src/assets/atlas-loader.ts
  @apps/client/src/render/PlayerRenderer.ts
  @apps/client/test/e2e/cli-08.e2e.test.ts
  @apps/client/test/e2e/fixtures.ts
  @extracted/client-5-8/sprites/0000-NaviStandD/meta.json
  @extracted/client-5-8/sprites/0027-NaviRunD/meta.json
  @extracted/client-5-8/rooms/0058-BNCentral/meta.json
  @extracted/client-5-8/rooms/0058-BNCentral/instances.json
  @extracted/client-5-8/rooms/0058-BNCentral/tiles.json
  @extracted/client-5-8/rooms/0058-BNCentral/backgrounds.json
  @extracted/client-5-8/rooms/0058-BNCentral/creation-code.gml

  <interfaces>
  <!-- Existing tools/asset-pipeline shape (06-08 SUMMARY confirms shipped) -->

  tools/asset-pipeline/cli.ts:
    Subcommands today: `build`, `postprocess` (per 06-09 verify-phase-6.mjs steps 4 + 6)
    Add: `bootstrap`

  tools/asset-pipeline/src/build.ts:
    Reads assets/source/sprites/<id>.{png,json}; packs into atlas-mvp.{png,json}
    Uses sharp + a bin-pack algorithm
    Already shipped (06-08); THIS plan extends inputs (more sprites) but DOES NOT change the build algorithm

  tools/asset-pipeline/src/manifest.ts:
    Emits tools/asset-pipeline/output/pipeline-manifest.json with sha256 + paths
    Already shipped; THIS plan adds entries for the new sprites (manifest is regenerated)

  tools/scripts/lint-asset-pipeline.mjs:
    Validates pipeline-manifest.json schema_version === 1; cross-checks atlas sha256
    Already shipped; THIS plan's bootstrap output flows through it unchanged

  <!-- New: bootstrap mode contract -->

  bootstrap input shape:
    extracted/client-5-8/sprites/<id>/meta.json:
      { "name": "NaviStandD", "frame_w": <int>, "frame_h": <int>, "frame_count": <int>, "origin_x": <int>, "origin_y": <int>, ... }
    extracted/client-5-8/sprites/<id>/frames/img_NNN.bmp (one BMP per frame)

  bootstrap output shape (Aseprite-compatible — D-15):
    assets/source/sprites/<id>.png   (horizontal strip; frame_count frames laid out left-to-right at frame_w × frame_h)
    assets/source/sprites/<id>.json:
      {
        "frames": { "<id>_0": { "frame": { "x": 0, "y": 0, "w": frame_w, "h": frame_h } }, ... },
        "meta": {
          "image": "<id>.png",
          "format": "RGBA8888",
          "size": { "w": frame_count * frame_w, "h": frame_h },
          "scale": "1",
          "frame_w": frame_w, "frame_h": frame_h,
          "frame_count": frame_count,
          "origin": { "x": origin_x, "y": origin_y },
          "sprite_id": "<id>",
          "source": "extracted/client-5-8/sprites/<id>/"
        }
      }

    This shape matches Aseprite's `--format json-array` export (D-15) so devs can edit
    the .png + .json in Aseprite, re-export with the same shape, and re-run `build`
    without re-running `bootstrap`.

  <!-- Sprite IDs to bootstrap (CONTEXT addendum line 336): -->
    Player sprites (16 total):
      0000-NaviStandD, 0027-NaviRunD, 0028-NaviRunR,
      0029-NaviStandR, 0030-NaviStandU, 0031-NaviRunU, 0032-NaviRunL, 0033-NaviStandL,
      0036-NaviStandDR, 0037-NaviRunDR,
      0038-NaviStandUR, 0039-NaviRunUR,
      0040-NaviStandUL, 0041-NaviRunUL,
      0042-NaviStandDL, 0043-NaviRunDL
    Tile sprites:
      Whatever sprite IDs are referenced by 0058-BNCentral/tiles.json
      (read tiles.json — it lists tileset_id → sprite_id; bootstrap each)
      Plus any door/object sprites referenced by 0058-BNCentral/instances.json
      (read instances.json — `object_id` references; cross-look-up in extracted/client-5-8/objects/<id>/meta.json
      for the `sprite_id` field, then bootstrap that sprite)

  <!-- Room layout SRV-13 schema (Phase 4 D-12) -->
  apps/server/rooms/<room>/000.json:
    {
      "room_id": "bn-central",
      "version": 1,
      "width_tiles": <int>, "height_tiles": <int>,
      "tile_w": 44, "tile_h": 40,                           // Phase 4 / Phase 6 standard
      "background": { "sprite_id": "<bg_id>", "tile_x": 0, "tile_y": 0 },
      "tiles": [{ "tileset_sprite_id": "<id>", "x": <px>, "y": <px>, "frame": <int> }, ...],
      "instances": [{ "object_id": "<original_obj_id>", "x": <px>, "y": <px> }, ...],
      "spawn_points": [{ "name": "home_portal", "x": <px>, "y": <px> }, ...]
    }
    (Reference apps/server/rooms/mvp-lobby/000.json for the EXACT shape; mirror it.)

  apps/server/rooms/<room>/000.sig:
    Ed25519 signature over the canonical-JSON-bytes of 000.json
    Use the existing signing helper (Phase 4 D-12 / Phase 5 D-19 ritual)
    Private key extraction is operator-side via `fly ssh`; for local dev there is a dev keypair already
    per 06-08 fixtures and the Phase 5 ROOM_SIGNING_PRIVATE_KEY ritual.

  <!-- Server room registration (apps/server/src/RebnoRoom.ts) -->

  Today: matchmaker.defineRoomType('mvp-lobby', RebnoRoom, { layoutPath: 'apps/server/rooms/mvp-lobby/000.json' })
  After: matchmaker.defineRoomType('bn-central', RebnoRoom, { layoutPath: 'apps/server/rooms/bn-central/000.json' })

  home-portal spawn (Phase 6 D-07): on first-session join, server reads spawn_points.home_portal from the loaded layout
  and uses {x, y}; verify the bn-central room HAS a home_portal spawn point — if 0058-BNCentral/instances.json has a
  specific portal/door object marking the entry, that's it; if not, the planner picks a sensible center-of-room default
  (documented in 06-14-ASSET-MANIFEST.md).

  <!-- Client wiring -->

  apps/client/src/scenes/GameScene.ts (preload):
    this.load.atlas('mvp', '/atlas-mvp.png', '/atlas-mvp.json');
    // Vite dev: paths served from public/; staging/prod: served from apps/server/public/ via D-18 mount

  apps/client/src/render/PlayerRenderer.ts (replace blue rect):
    ensureLocal() / addRemote() build a Phaser.GameObjects.Sprite using atlas frame:
      const sprite = scene.add.sprite(x, y, 'mvp', '0000-NaviStandD_0');
      sprite.setOrigin(origin_x / frame_w, origin_y / frame_h);  // honor extracted origin
    THIS plan ships only the StandD default frame; 06-16 wires 8-direction selection.

  apps/client/src/assets/room-renderer.ts (extend):
    Read the SRV-13 layout JSON; for each tile entry, render via Phaser tilemap or sprite-blit;
    background rendered as a single Phaser.GameObjects.Image at world origin.
    Mirror the existing mvp-lobby render path; replace mvp-lobby paths with bn-central (or
    generalize to read room_id from layout JSON).

  apps/client/src/assets/atlas-loader.ts:
    Already wires Phaser.Textures.FilterMode.NEAREST per Phase 6 D-12; just ensure it sets the
    filter on the new 'mvp' atlas after this.load.atlas resolves.

  <!-- e2e update -->

  apps/client/test/e2e/cli-08.e2e.test.ts:
    Add/update assertions:
      - canvas[data-room-id="bn-central"] (or wherever the attribute lives — set on Phaser canvas root after room layout received)
      - Player render is NOT a solid color rectangle: page.evaluate sample of canvas pixel
        at known player position; assert R/G/B is not (0, 0, 255) (blue marker) and the
        pixel is opaque (alpha = 255). Coarse but sufficient for a non-blue-rect check.
      - Existing chat round-trip + nameplate assertions remain unchanged
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: tools/asset-pipeline bootstrap mode + bootstrap all 15 player sprites + bn-central tile/object sprites; rebuild atlas-mvp</name>
    <files>
      tools/asset-pipeline/src/bootstrap.ts,
      tools/asset-pipeline/cli.ts,
      tools/asset-pipeline/test/bootstrap.test.ts,
      tools/asset-pipeline/src/build.ts,
      assets/source/sprites/.gitkeep,
      package.json
    </files>
    <read_first>
      tools/asset-pipeline/cli.ts (current dispatcher — find existing build / postprocess subcommands; mirror the registration shape for `bootstrap`),
      tools/asset-pipeline/src/build.ts (verify it handles the bootstrap output shape — assets/source/sprites/<id>.{png,json} — without changes),
      tools/asset-pipeline/src/manifest.ts (verify it picks up new sprite entries automatically; if it has a hard-coded list, update it),
      tools/asset-catalog/cli.ts (BMP→PNG sharp pattern reference — exact-mirror analog from PATTERNS lines 80-83),
      tools/extract-gmd/cli.ts (sharp pipeline reference),
      extracted/client-5-8/sprites/0000-NaviStandD/meta.json (confirm exact field names: frame_w / frame_h / origin / frame_count — the bootstrap reader must match these names),
      extracted/client-5-8/sprites/0027-NaviRunD/meta.json (multi-frame example; verify frame_count = 6 from the directory listing),
      extracted/client-5-8/rooms/0058-BNCentral/tiles.json (extract the list of tileset sprite_ids referenced),
      extracted/client-5-8/rooms/0058-BNCentral/instances.json (extract the list of object_ids; cross-reference each in extracted/client-5-8/objects/<id>/meta.json to find sprite_id),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-08-SUMMARY.md (lint:asset-pipeline contract — bootstrap output flows through unchanged)
    </read_first>
    <behavior>
      - tools/asset-pipeline/cli.ts: registers `bootstrap` subcommand alongside `build` + `postprocess`
      - tools/asset-pipeline/src/bootstrap.ts: exports bootstrap({ sourceRoot, outDir, spriteIds }) → reads each sprite from extracted/, decodes BMP frames via sharp, composites a horizontal strip PNG, writes <id>.png + <id>.json (Aseprite shape) to outDir; deterministic (sorted keys, pinned PNG encoder settings) per Phase 1 D-15
      - bootstrap is idempotent — re-running produces byte-identical output for the same input
      - bootstrap.test.ts: 5+ vitest tests covering: (a) single sprite bootstrap produces correct strip dimensions, (b) frame_count = 6 produces 6 horizontal cells, (c) origin from meta.json is preserved in the output JSON, (d) JSON shape matches Aseprite json-array, (e) determinism (sha256 over output stable across two runs)
      - bootstrap CLI invocation: `pnpm asset-pipeline bootstrap` reads a hard-coded sprite-id list (or accepts a CSV arg) covering the 15 player sprites + bn-central tile/object sprites
      - After bootstrap runs, `pnpm asset-pipeline:build` (existing) packs all of assets/source/sprites/* into atlas-mvp.{png,json} — atlas grows from 06-08's empty/test state to include all real sprites
      - assets/source/sprites/ directory is committed (with .gitkeep + the bootstrapped output files); aseprite/ subdirectory is committed-or-gitignored per planner discretion (CONTEXT D-15) — this plan picks GITIGNORED (the .aseprite source files come later when devs edit; first-pass workflow is bootstrap → ship; aseprite source storage is a v2 ergonomics question, not first-pass)
    </behavior>
    <action>
  **1. tools/asset-pipeline/src/bootstrap.ts** — Tag `// [impl->REQ-AST-01]` at top.

  ```ts
  // [impl->REQ-AST-01]
  // Bootstrap mode (CONTEXT D-14): reads extracted/client-5-8/sprites/<id>/frames/img_*.bmp
  // + meta.json, emits assets/source/sprites/<id>.{png,json} (Aseprite-compatible).
  //
  // Idempotent: re-running with the same input produces byte-identical output
  // (Phase 1 D-15 reproducibility).

  import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
  import { join, resolve } from 'node:path';
  import sharp from 'sharp';

  export interface SpriteMeta {
    name?: string;
    frame_w: number;
    frame_h: number;
    frame_count: number;
    origin_x?: number;
    origin_y?: number;
    // ... other extracted fields preserved verbatim in the output JSON
    [k: string]: unknown;
  }

  export interface BootstrapOptions {
    sourceRoot: string;   // e.g. 'extracted/client-5-8/sprites'
    outDir: string;       // e.g. 'assets/source/sprites'
    spriteIds: string[];  // e.g. ['0000-NaviStandD', '0027-NaviRunD', ...]
  }

  export interface BootstrapResult {
    sprites: Array<{
      id: string;
      framesIn: number;
      pngOut: string;
      jsonOut: string;
      sha256_png: string;
    }>;
  }

  export async function bootstrap(opts: BootstrapOptions): Promise<BootstrapResult> {
    if (!existsSync(opts.outDir)) mkdirSync(opts.outDir, { recursive: true });
    const result: BootstrapResult = { sprites: [] };

    for (const id of [...opts.spriteIds].sort()) {
      const spriteDir = join(opts.sourceRoot, id);
      if (!existsSync(spriteDir)) {
        throw new Error(`bootstrap: sprite directory not found: ${spriteDir}`);
      }
      const metaPath = join(spriteDir, 'meta.json');
      const meta = JSON.parse(readFileSync(metaPath, 'utf-8')) as SpriteMeta;
      const framesDir = join(spriteDir, 'frames');
      const frameFiles = readdirSync(framesDir).filter((f) => f.endsWith('.bmp')).sort();
      if (frameFiles.length !== meta.frame_count) {
        throw new Error(`bootstrap: ${id} meta.frame_count=${meta.frame_count} but found ${frameFiles.length} BMPs`);
      }

      // Composite horizontal strip
      const stripWidth = meta.frame_w * meta.frame_count;
      const stripHeight = meta.frame_h;
      const composite: sharp.OverlayOptions[] = [];
      for (let i = 0; i < frameFiles.length; i++) {
        const bmpBuf = readFileSync(join(framesDir, frameFiles[i]));
        const pngBuf = await sharp(bmpBuf).png({ compressionLevel: 9, palette: false }).toBuffer();
        composite.push({ input: pngBuf, left: i * meta.frame_w, top: 0 });
      }

      const stripBuf = await sharp({
        create: { width: stripWidth, height: stripHeight, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
      }).composite(composite).png({ compressionLevel: 9, palette: false }).toBuffer();

      const pngOut = join(opts.outDir, `${id}.png`);
      writeFileSync(pngOut, stripBuf);

      // Aseprite-compatible JSON sidecar (D-15)
      const frames: Record<string, unknown> = {};
      for (let i = 0; i < meta.frame_count; i++) {
        frames[`${id}_${i}`] = {
          frame: { x: i * meta.frame_w, y: 0, w: meta.frame_w, h: meta.frame_h },
          rotated: false, trimmed: false,
          spriteSourceSize: { x: 0, y: 0, w: meta.frame_w, h: meta.frame_h },
          sourceSize: { w: meta.frame_w, h: meta.frame_h },
        };
      }
      const sidecar = {
        frames,
        meta: {
          app: 'rebno-asset-pipeline-bootstrap',
          version: '1',
          image: `${id}.png`,
          format: 'RGBA8888',
          size: { w: stripWidth, h: stripHeight },
          scale: '1',
          frame_w: meta.frame_w,
          frame_h: meta.frame_h,
          frame_count: meta.frame_count,
          origin: { x: meta.origin_x ?? 0, y: meta.origin_y ?? 0 },
          sprite_id: id,
          source: `extracted/client-5-8/sprites/${id}/`,
        },
      };
      const jsonOut = join(opts.outDir, `${id}.json`);
      // Deterministic deep-sorted JSON (Phase 1 D-15) — use canonical helper.
      // `canonicalStringify` lives in tools/asset-pipeline/src/manifest.ts (06-08 scaffold).
      // If absent, write the 6-line recursive helper inline (see manifest.ts pattern).
      writeFileSync(jsonOut, canonicalStringify(sidecar) + '\n');

      const sha256_png = await sha256(stripBuf);
      result.sprites.push({ id, framesIn: frameFiles.length, pngOut, jsonOut, sha256_png });
    }
    return result;
  }

  async function sha256(buf: Buffer): Promise<string> {
    const { createHash } = await import('node:crypto');
    return createHash('sha256').update(buf).digest('hex');
  }
  ```

  (Determinism note: `JSON.stringify`'s second arg is a replacer function or property-name filter, NOT a sort. Use `canonicalStringify` from `tools/asset-pipeline/src/manifest.ts`. If that helper does not exist yet in 06-08's scaffold, add it as a 6-line recursive sort: walk each object, sort keys, recurse on values, pass through arrays. Intent: same input → byte-identical JSON output.)

  **2. tools/asset-pipeline/cli.ts** — Tag `// [impl->REQ-AST-01]` near the new subcommand.

  ```ts
  // [impl->REQ-AST-01]
  // Add `bootstrap` subcommand alongside existing `build` + `postprocess`.

  import { bootstrap } from './src/bootstrap';

  const PLAYER_SPRITE_IDS = [
    '0000-NaviStandD',
    '0027-NaviRunD',
    '0028-NaviRunR',
    '0029-NaviStandR',
    '0030-NaviStandU',
    '0031-NaviRunU',
    '0032-NaviRunL',
    '0033-NaviStandL',
    '0036-NaviStandDR',
    '0037-NaviRunDR',
    '0038-NaviStandUR',
    '0039-NaviRunUR',
    '0040-NaviStandUL',
    '0041-NaviRunUL',
    '0042-NaviStandDL',
    '0043-NaviRunDL',
  ];

  // Tile + object sprites: derived at bootstrap time from
  // extracted/client-5-8/rooms/0058-BNCentral/tiles.json + instances.json
  // (cross-reference each instance.object_id → extracted/client-5-8/objects/<id>/meta.json
  //  → meta.sprite_id). The executor builds this list dynamically from the
  // extracted JSONs and concatenates with PLAYER_SPRITE_IDS.

  // Add to the existing dispatcher (mirror the build/postprocess registration shape):
  case 'bootstrap': {
    const tileObjectIds = await deriveBnCentralSpriteIds('extracted/client-5-8');
    const allIds = Array.from(new Set([...PLAYER_SPRITE_IDS, ...tileObjectIds])).sort();
    const r = await bootstrap({
      sourceRoot: 'extracted/client-5-8/sprites',
      outDir: 'assets/source/sprites',
      spriteIds: allIds,
    });
    console.log(`bootstrap: ${r.sprites.length} sprites → assets/source/sprites/`);
    break;
  }
  ```

  (Implement `deriveBnCentralSpriteIds` as a small helper inside cli.ts or a sibling module. It reads the room's tiles.json + instances.json and resolves sprite IDs.)

  **3. tools/asset-pipeline/test/bootstrap.test.ts** — Tag `// [unit->REQ-AST-01]`. 5+ vitest tests:

  ```ts
  // [unit->REQ-AST-01]
  import { describe, it, expect, beforeEach } from 'vitest';
  import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
  import { tmpdir } from 'node:os';
  import { join } from 'node:path';
  import { bootstrap } from '../src/bootstrap';

  function makeFixture(root: string, id: string, frame_w: number, frame_h: number, frame_count: number) {
    // Write a minimal extracted/<id>/ tree with meta.json + N solid-color BMPs.
    // Use sharp to generate the BMPs synchronously inside the test (acceptable
    // for unit-test cost; ~milliseconds).
    // ... (executor implements the small fixture builder)
  }

  describe('asset-pipeline bootstrap', () => {
    let tmp: string;
    beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'asset-pipe-')); });

    it('single-frame sprite produces strip with correct dimensions', async () => { /* assert PNG.size === frame_w * frame_h */ });
    it('multi-frame sprite (frame_count=6) produces 6-cell horizontal strip', async () => { /* assert size = 6*frame_w x frame_h */ });
    it('JSON sidecar carries origin from meta.json', async () => { /* assert sidecar.meta.origin.x/y */ });
    it('JSON shape matches Aseprite json-array (frames + meta keys)', async () => { /* assert shape */ });
    it('idempotent — two runs produce byte-identical output (sha256 stable)', async () => { /* run twice, compare sha256 of PNG + JSON */ });
    it('throws clearly when meta.frame_count mismatches BMP count', async () => { /* assert thrown error message */ });
  });
  ```

  **4. assets/source/sprites/.gitkeep** — Empty file. Confirms the directory is committed.

  **5. package.json** — Add npm scripts (alongside the existing asset-pipeline:build / :postprocess from 06-08):

  ```jsonc
  "asset-pipeline:bootstrap": "node tools/asset-pipeline/dist/cli.js bootstrap",
  // (or whatever the existing invocation pattern is — match build / postprocess shape exactly)
  ```

  Then run end-to-end:
  1. `pnpm --filter @rebno/asset-pipeline build` (compiles tools/asset-pipeline TS)
  2. `pnpm asset-pipeline:bootstrap` (produces assets/source/sprites/<id>.{png,json} for all 15 player sprites + bn-central tile/object sprites)
  3. `pnpm asset-pipeline:build` (existing — packs all of assets/source/sprites/* into atlas-mvp.{png,json})
  4. `pnpm asset-pipeline:postprocess` (existing — rewrites manifest paths)
  5. `pnpm lint:asset-pipeline` (existing — sha256 cross-check fires; should pass)

  **Commit assets/source/sprites/*.{png,json}** along with the code changes — these are the bootstrapped output, committed per CONTEXT D-14 ("output committed to git so devs can edit `.aseprite` source files locally").
    </action>
    <verify>
      <automated>pnpm --filter @rebno/asset-pipeline test &amp;&amp; pnpm asset-pipeline:bootstrap &amp;&amp; pnpm asset-pipeline:build &amp;&amp; pnpm asset-pipeline:postprocess &amp;&amp; pnpm lint:asset-pipeline &amp;&amp; node -e "const fs=require('fs'); const ids=['0000-NaviStandD','0027-NaviRunD','0028-NaviRunR','0029-NaviStandR','0030-NaviStandU','0031-NaviRunU','0032-NaviRunL','0033-NaviStandL','0036-NaviStandDR','0037-NaviRunDR','0038-NaviStandUR','0039-NaviRunUR','0040-NaviStandUL','0041-NaviRunUL','0042-NaviStandDL','0043-NaviRunDL']; for (const id of ids) { if (!fs.existsSync('assets/source/sprites/'+id+'.png')) { console.error('MISSING:', id+'.png'); process.exit(1); } if (!fs.existsSync('assets/source/sprites/'+id+'.json')) { console.error('MISSING:', id+'.json'); process.exit(1); } } console.log('all 16 player sprites bootstrapped + atlas built + lint passed');"</automated>
    </verify>
    <done>
      bootstrap subcommand wired; 6+ unit tests pass; all 15 player sprites + bn-central tile/object sprites have .png + .json under assets/source/sprites/; pnpm asset-pipeline:build produces atlas-mvp.{png,json} including the new sprites; pnpm lint:asset-pipeline passes (manifest schema + sha256 integrity); idempotent re-runs are byte-stable.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Convert 0058-BNCentral → apps/server/rooms/bn-central/000.json + signed; register on server (replaces mvp-lobby)</name>
    <files>
      tools/room-converter/src/bn-central.ts,
      tools/room-converter/test/bn-central.test.ts,
      apps/server/rooms/bn-central/000.json,
      apps/server/rooms/bn-central/000.sig,
      apps/server/src/RebnoRoom.ts,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md
    </files>
    <read_first>
      extracted/client-5-8/rooms/0058-BNCentral/meta.json (room dimensions, room_speed if present),
      extracted/client-5-8/rooms/0058-BNCentral/instances.json (object instance list with x/y/object_id),
      extracted/client-5-8/rooms/0058-BNCentral/tiles.json (tile list with x/y/tileset_id/frame),
      extracted/client-5-8/rooms/0058-BNCentral/backgrounds.json (background image refs),
      extracted/client-5-8/rooms/0058-BNCentral/creation-code.gml (room init code; usually irrelevant for layout but read to confirm),
      apps/server/rooms/mvp-lobby/000.json (canonical SRV-13 schema reference — mirror exactly),
      apps/server/rooms/mvp-lobby/000.sig (signature ritual reference — find the helper that produced it),
      apps/server/src/RebnoRoom.ts (find: matchmaker.defineRoomType('mvp-lobby', ...); replace with bn-central; verify home-portal spawn read path D-07),
      tools/room-converter/src/index.ts (existing entry; mirror its conventions — file structure + cli wiring),
      tools/scripts/lint-room-layout.mjs (validates 000.json + 000.sig — verify the new bn-central files pass)
    </read_first>
    <behavior>
      - tools/room-converter/src/bn-central.ts: exports convertBnCentral({ srcRoot, outRoot }) → reads the 5 source files, builds the SRV-13 layout JSON, writes apps/server/rooms/bn-central/000.json + signs to 000.sig
      - All sprite/object IDs preserved verbatim (object_id stays as the original BNO id like '0042-player' even though the player object is actually 0000-server — this is the room's instance list, not the player avatar; the room may legitimately reference 0042-player for some other instance type, document if so)
      - home_portal spawn point: identified from instances.json (a specific portal/door object) OR fallback to room center; documented in 06-14-ASSET-MANIFEST.md
      - 000.sig produced by the existing Phase 4 D-12 signing helper (use the dev key for local; staging/prod keys ship per Phase 5 D-19 ritual)
      - bn-central.test.ts: 4+ tests — fixture-based conversion produces expected layout shape, sprite IDs cross-reference correctly, signature verifies, idempotent
      - Server: RebnoRoom.ts replaces 'mvp-lobby' with 'bn-central' in matchmaker.defineRoomType (or runs both during transition; final committed state is bn-central is the canonical CLI-08 room — leave mvp-lobby registration if needed for backward compat with any other test, but the e2e + UAT target bn-central)
      - 06-14-ASSET-MANIFEST.md documents: list of sprites bootstrapped, source for each (extracted path), home-portal spawn coordinates + reasoning, any tile/object IDs that couldn't be resolved (with reason)
    </behavior>
    <action>
  **1. tools/room-converter/src/bn-central.ts** — Tag `// [impl->REQ-AST-01] [impl->REQ-CLI-07]`.

  ```ts
  // [impl->REQ-AST-01] [impl->REQ-CLI-07]
  // BN Central room converter — bridges extracted/client-5-8/rooms/0058-BNCentral/*
  // → apps/server/rooms/bn-central/000.json (SRV-13 schema) + signed 000.sig.

  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
  import { join } from 'node:path';
  // import { signRoomLayout } from '@rebno/server/dev-keys';  // or wherever Phase 4 D-12 helper lives

  export interface ConvertOptions {
    srcRoot: string;          // 'extracted/client-5-8/rooms/0058-BNCentral'
    outRoot: string;          // 'apps/server/rooms/bn-central'
    signingKeyPath?: string;  // dev key for local; production via Phase 5 D-19 ritual
  }

  export async function convertBnCentral(opts: ConvertOptions): Promise<{ jsonPath: string; sigPath: string }> {
    const meta = JSON.parse(readFileSync(join(opts.srcRoot, 'meta.json'), 'utf-8'));
    const instances = JSON.parse(readFileSync(join(opts.srcRoot, 'instances.json'), 'utf-8'));
    const tiles = JSON.parse(readFileSync(join(opts.srcRoot, 'tiles.json'), 'utf-8'));
    const backgrounds = JSON.parse(readFileSync(join(opts.srcRoot, 'backgrounds.json'), 'utf-8'));

    // Phase 4 / Phase 6 standard tile sizes (verify against meta.json — BNO original may differ)
    const tile_w = 44;
    const tile_h = 40;

    const layout = {
      room_id: 'bn-central',
      version: 1,
      width_tiles: Math.ceil(meta.width / tile_w),   // adapt to meta.json shape; verify field name
      height_tiles: Math.ceil(meta.height / tile_h),
      tile_w, tile_h,
      background: extractBackground(backgrounds), // map to { sprite_id, tile_x, tile_y } or {color} fallback
      tiles: tiles.map((t: any) => ({
        tileset_sprite_id: resolveSpriteId(t.tileset_id ?? t.background_id),
        x: t.x, y: t.y,
        frame: t.frame ?? 0,
      })),
      instances: instances.map((i: any) => ({
        object_id: i.object_id ?? i.name,    // verify field name from extracted JSON
        x: i.x, y: i.y,
      })),
      spawn_points: deriveSpawnPoints(instances, meta),  // home_portal from a specific object OR room center fallback
    };

    if (!existsSync(opts.outRoot)) mkdirSync(opts.outRoot, { recursive: true });
    const canonical = canonicalJson(layout);  // sorted-keys, deterministic
    const jsonPath = join(opts.outRoot, '000.json');
    writeFileSync(jsonPath, canonical + '\n');

    // Sign — call the existing Phase 4 D-12 helper. If it lives in apps/server/src/room-signing.ts,
    // import it; if it's a CLI script, invoke it via spawnSync. Verify the existing mvp-lobby
    // pipeline and mirror exactly.
    const sigPath = join(opts.outRoot, '000.sig');
    await signLayout(jsonPath, sigPath, opts.signingKeyPath);

    return { jsonPath, sigPath };
  }

  // Helpers — extractBackground / resolveSpriteId / deriveSpawnPoints / canonicalJson / signLayout
  // ... (executor implements per the existing mvp-lobby reference)
  ```

  **Key derivation decisions (executor records in 06-14-ASSET-MANIFEST.md):**

  a) **Tile size**: 44×40 is the Phase 6 standard (CONTEXT D-10 reference). If extracted/0058-BNCentral/meta.json shows a different value (e.g. 16×16), USE THE EXTRACTED VALUE — Phase 6 must adapt to BNO truth, not impose a different tile size. Document the choice.

  b) **Background**: backgrounds.json may reference a sprite ID from extracted/client-5-8/backgrounds/ (different namespace from sprites/). Verify; bootstrap that background asset too if needed (Task 1 was player+tile+object sprites; if backgrounds need their own bootstrap-like step, add it now or scope to a tiny helper).

  c) **home_portal spawn**: Look in instances.json for an obvious portal/door/spawn-marker object. If none, use room-center: `(width/2, height/2)`. Document choice + coordinates.

  d) **Object IDs that don't have a sprite**: some original-BNO objects are invisible (logic-only). Skip in the rendered instances list OR include with a sentinel sprite_id; document.

  **2. tools/room-converter/test/bn-central.test.ts** — Tag `// [unit->REQ-AST-01]`.

  4+ vitest tests using a fixture extracted-room directory:
  - (a) Layout JSON matches SRV-13 schema (validate via lint-room-layout.mjs's schema constants)
  - (b) Tile + instance counts match source JSON inputs
  - (c) home_portal spawn point is present and within room bounds
  - (d) Idempotent — two runs produce byte-identical 000.json
  - (e) Signature verifies against the dev pubkey

  **3. Run the converter** to produce real outputs:
  ```bash
  pnpm --filter @rebno/room-converter build  # if needed
  node tools/room-converter/dist/cli.js bn-central
  # OR a one-off script call to convertBnCentral(...) — verify tools/room-converter's existing CLI shape
  ```

  This produces `apps/server/rooms/bn-central/000.json` and `apps/server/rooms/bn-central/000.sig`. Commit both.

  **4. apps/server/src/RebnoRoom.ts** — Tag `// [impl->REQ-CLI-07]`. Find the existing `matchmaker.defineRoomType('mvp-lobby', ...)` call (or wherever room registration happens). Add a parallel registration for 'bn-central' OR replace mvp-lobby with bn-central. Decision: REPLACE (mvp-lobby was synthetic; bn-central is canonical now). Keep mvp-lobby as a fallback ONLY if any other test depends on it; otherwise remove from production server.

  ```ts
  // [impl->REQ-CLI-07]
  // Replaces synthetic mvp-lobby with first-pass real BN Central room (D-27).
  matchmaker.defineRoomType('bn-central', RebnoRoom, { layoutPath: 'apps/server/rooms/bn-central/000.json' });
  ```

  Verify the home-portal spawn read path (Phase 6 D-07): `onJoin` for a new session reads `spawn_points.home_portal.{x,y}` from the loaded layout. If the existing code reads `mvp-lobby` spawn coordinates, generalize to read from the layout's spawn_points by name.

  **5. .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md** — Doc-only.

  ```md
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 14
  type: doc-only-manifest
  status: complete
  ---

  # First-Pass Real Asset Manifest (D-27)

  [doc->REQ-AST-01] [doc->REQ-CLI-07]

  ## Player sprites bootstrapped (15)

  | Sprite ID | Source | Frames | Origin (x,y) | Notes |
  |-----------|--------|--------|--------------|-------|
  | 0000-NaviStandD | extracted/client-5-8/sprites/0000-NaviStandD/ | 1 | (x,y) | Default-frame baseline for PlayerRenderer |
  | 0027-NaviRunD | ... | 6 | ... | Walk-cycle south |
  | 0028-NaviRunR | ... | ? | ... | Walk-cycle east (R-facing); restored after CONTEXT-line-336 omission |
  | 0029-NaviStandR | ... | 1 | ... | |
  | 0030-NaviStandU | ... | 1 | ... | |
  | 0031-NaviRunU | ... | ? | ... | Walk-cycle north |
  | 0032-NaviRunL | ... | ? | ... | Walk-cycle west |
  | 0033-NaviStandL | ... | 1 | ... | |
  | 0036-NaviStandDR | ... | 1 | ... | Diagonal stand SE |
  | 0037-NaviRunDR | ... | ? | ... | Diagonal walk SE |
  | 0038-NaviStandUR | ... | 1 | ... | Diagonal stand NE |
  | 0039-NaviRunUR | ... | ? | ... | Diagonal walk NE |
  | 0040-NaviStandUL | ... | 1 | ... | Diagonal stand NW |
  | 0041-NaviRunUL | ... | ? | ... | Diagonal walk NW |
  | 0042-NaviStandDL | ... | 1 | ... | Diagonal stand SW |
  | 0043-NaviRunDL | ... | ? | ... | Diagonal walk SW |

  ## Tile sprites bootstrapped (derived from 0058-BNCentral/tiles.json)

  | Sprite ID | Source | Frames | Used for | Notes |
  |-----------|--------|--------|----------|-------|
  | _<derived>_ | _<extracted path>_ | _<n>_ | Floor tile / wall / etc. | |

  ## Object sprites bootstrapped (derived from 0058-BNCentral/instances.json + objects/<id>/meta.json)

  | Sprite ID | From object_id | Source | Notes |
  |-----------|----------------|--------|-------|
  | _<derived>_ | _<obj id>_ | _<extracted path>_ | |

  ## Background

  | Background ID | Source | Notes |
  |---------------|--------|-------|
  | _<derived>_ | extracted/client-5-8/backgrounds/<id>/ | Bootstrapped via _<bootstrap helper>_ |

  ## Room conversion: 0058-BNCentral → bn-central

  - Source room dimensions: _<from meta.json>_ (e.g. 640×480 px)
  - Tile size adopted: 44×40 (Phase 6 D-10 standard) OR _<extracted value if different>_ — RATIONALE: _<...>_
  - Tile count: _<n>_ (from tiles.json)
  - Instance count: _<n>_ (from instances.json)
  - home_portal spawn: (x, y) = _<derived>_; SOURCE: _<which instance/object marked it OR "room center fallback">_
  - Signature: 000.sig produced by _<helper name>_ using _<dev key path>_; verifies against _<pubkey path>_

  ## Decisions made

  _(Document any judgment calls — invisible objects skipped, tile size mismatch, sprite_id couldn't be resolved, etc.)_

  ## Out of scope (Phase 7)

  - Audio (AST-02 — extracted/client-5-8/ has no audio per ADR 0001)
  - Fonts (AST-03)
  - Other 9 extracted rooms (PAR-03)
  - Aseprite source files (devs may add later; bootstrap output is the first-pass committed source)
  ```

  Fill in the `<derived>` placeholders from your actual reading of the extracted JSONs.
    </action>
    <verify>
      <automated>node -e "const fs=require('fs'); if (!fs.existsSync('apps/server/rooms/bn-central/000.json')) { console.error('MISSING 000.json'); process.exit(1); } if (!fs.existsSync('apps/server/rooms/bn-central/000.sig')) { console.error('MISSING 000.sig'); process.exit(1); } const layout=JSON.parse(fs.readFileSync('apps/server/rooms/bn-central/000.json','utf-8')); if (layout.room_id !== 'bn-central') { console.error('room_id wrong:', layout.room_id); process.exit(1); } if (!Array.isArray(layout.tiles) || layout.tiles.length === 0) { console.error('no tiles'); process.exit(1); } if (!layout.spawn_points || !layout.spawn_points.find(s => s.name === 'home_portal')) { console.error('no home_portal spawn'); process.exit(1); } console.log('bn-central layout OK:', layout.tiles.length, 'tiles,', layout.instances.length, 'instances'); const r=fs.readFileSync('apps/server/src/RebnoRoom.ts','utf-8'); if (!r.includes('bn-central')) { console.error('RebnoRoom does not register bn-central'); process.exit(1); } console.log('server registers bn-central'); &amp;&amp; pnpm --filter @rebno/room-converter test &amp;&amp; pnpm trace:check"</automated>
    </verify>
    <done>
      apps/server/rooms/bn-central/000.json + 000.sig committed; SRV-13 schema valid (lint-room-layout.mjs passes); RebnoRoom.ts registers 'bn-central' as the canonical room (mvp-lobby removed or kept as deprecated fallback per discretion); tools/room-converter/src/bn-central.ts shipped with 4+ tests; 06-14-ASSET-MANIFEST.md fully populated. Server boots without error against the new room.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: Client atlas load + PlayerRenderer real-sprite + RoomRenderer bn-central + e2e assertion update</name>
    <files>
      apps/client/src/scenes/GameScene.ts,
      apps/client/src/assets/atlas-loader.ts,
      apps/client/src/assets/room-renderer.ts,
      apps/client/src/render/PlayerRenderer.ts,
      apps/client/test/e2e/cli-08.e2e.test.ts
    </files>
    <read_first>
      apps/client/src/scenes/GameScene.ts (find: preload(); current room rendering call site; current PlayerRenderer instantiation),
      apps/client/src/assets/atlas-loader.ts (current implementation; understand how it sets Phaser.Textures.FilterMode.NEAREST),
      apps/client/src/assets/room-renderer.ts (find: render(layout) method; current mvp-lobby-specific code paths),
      apps/client/src/render/PlayerRenderer.ts (find: ensureLocal / addRemote — replace blue rectangle with atlas sprite),
      apps/client/test/e2e/cli-08.e2e.test.ts (find: existing room-id assertion if any; existing player-render assertion if any; this plan adds/updates),
      apps/client/vite.config.ts (verify the public/ static dir or asset import path — atlas-mvp.png needs to be served at /atlas-mvp.png in dev),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md (cross-reference sprite IDs)
    </read_first>
    <behavior>
      - GameScene.preload(): this.load.atlas('mvp', '/atlas-mvp.png', '/atlas-mvp.json'); set NEAREST filter on the loaded atlas
      - GameScene.create(): set canvas attribute data-room-id='bn-central' (or read from received layout) so e2e can assert
      - PlayerRenderer.ensureLocal(): replaces blue rectangle with `scene.add.sprite(x, y, 'mvp', '0000-NaviStandD_0')`; honors origin from atlas json sidecar
      - PlayerRenderer.addRemote(): same shape; uses atlas-loaded sprite
      - 8-direction sprite SELECTION wiring is OUT OF SCOPE here (06-16 owns it); this plan ships StandD as the default frame for both self + remotes
      - RoomRenderer.render(layout): handles bn-central layout (tiles + instances + background) using atlas frames where applicable
      - e2e cli-08.e2e.test.ts: adds two assertions — (a) data-room-id === 'bn-central'; (b) canvas pixel sample at player position is NOT solid blue (RGB ≠ (0,0,255)); existing chat + nameplate assertions preserved
    </behavior>
    <action>
  **1. apps/client/src/scenes/GameScene.ts** — Tag `// [impl->REQ-AST-01] [impl->REQ-CLI-07]` near the preload + room-id-set lines.

  ```ts
  // [impl->REQ-AST-01] [impl->REQ-CLI-07]

  preload(): void {
    // Atlas (D-13/D-14/D-17)
    this.load.atlas('mvp', '/atlas-mvp.png', '/atlas-mvp.json');
    // existing preload calls...
  }

  create(): void {
    // existing create()...
    // After atlas loaded, set NEAREST filter (Phase 6 D-12)
    const tex = this.textures.get('mvp');
    if (tex) tex.setFilter(Phaser.Textures.FilterMode.NEAREST);
    // Mark room id for e2e selectors — set on first s2c.room_layout receive in onRoomLayout()
    // (or set immediately if room id is known from join init data)
  }

  private onRoomLayout(layout: RoomLayout): void {
    // existing render call...
    this.roomRenderer.render(layout);
    // Mark on canvas for e2e
    const canvas = this.game.canvas as HTMLCanvasElement | null;
    if (canvas) canvas.setAttribute('data-room-id', layout.room_id);
  }
  ```

  **2. apps/client/src/assets/atlas-loader.ts** — Tag `// [impl->REQ-AST-01]`. Verify it sets NEAREST filter on the 'mvp' atlas after load. If absent, add the wiring.

  **3. apps/client/src/render/PlayerRenderer.ts** — Tag `// [impl->REQ-AST-01]` near the ensureLocal/addRemote methods.

  ```ts
  // [impl->REQ-AST-01]
  // Replace blue rectangle with real atlas sprite. 8-direction sprite SELECTION
  // wiring lives in 06-16 (sprite-state machine). This plan ships StandD as the
  // default frame for self + remotes.

  ensureLocal(scene: Phaser.Scene, x: number, y: number, sessionId: string): Phaser.GameObjects.Sprite {
    if (this.local) return this.local;
    const sprite = scene.add.sprite(x, y, 'mvp', '0000-NaviStandD_0');
    // Honor origin from atlas json sidecar (executor reads atlas-mvp.json frame meta + applies setOrigin)
    this.local = sprite;
    return sprite;
  }

  addRemote(scene: Phaser.Scene, x: number, y: number, sessionId: string, username: string): void {
    const sprite = scene.add.sprite(x, y, 'mvp', '0000-NaviStandD_0');
    // Nameplate wiring stays as-is (D-27a cyan nametag lands in 06-16)
    this.remotes.set(sessionId, sprite);
  }
  ```

  (If PlayerRenderer's existing API differs, adapt — the principle is "atlas frame replaces rectangle.")

  **4. apps/client/src/assets/room-renderer.ts** — Tag `// [impl->REQ-CLI-07]`.

  ```ts
  // [impl->REQ-CLI-07]
  render(layout: RoomLayout): void {
    // Background
    if (layout.background?.sprite_id) {
      this.scene.add.image(0, 0, 'mvp', `${layout.background.sprite_id}_0`).setOrigin(0, 0);
    }
    // Tiles — render via atlas sprites at world coords
    for (const t of layout.tiles) {
      const frameKey = `${t.tileset_sprite_id}_${t.frame ?? 0}`;
      this.scene.add.sprite(t.x, t.y, 'mvp', frameKey).setOrigin(0, 0);
    }
    // Instances — only render those with a corresponding sprite (some are invisible logic objects)
    for (const inst of layout.instances) {
      const spriteId = this.resolveInstanceSprite(inst.object_id);
      if (spriteId) this.scene.add.sprite(inst.x, inst.y, 'mvp', `${spriteId}_0`);
    }
  }
  ```

  (executor implements `resolveInstanceSprite` — a small lookup of object_id → sprite_id from the manifest or a hard-coded table from 06-14-ASSET-MANIFEST.md; if Phaser TilemapLayer is preferred per RESEARCH §room-rendering recommendation, swap to that — both work for SRV-13's tile array shape.)

  **5. apps/client/test/e2e/cli-08.e2e.test.ts** — Tag `// [int->REQ-AST-01] [int->REQ-CLI-07]`.

  Update existing assertions (do NOT rewrite the file; add to it):

  ```ts
  // [int->REQ-AST-01] [int->REQ-CLI-07]
  test('renders bn-central room (replaces synthetic mvp-lobby)', async ({ page }) => {
    await loginAs(page, accountA);
    await waitForGameReady(page);
    // Room-id assertion
    const roomId = await page.locator('canvas[data-game-ready=true]').getAttribute('data-room-id');
    expect(roomId).toBe('bn-central');
  });

  test('player render is not a solid blue rectangle (real atlas sprite loaded)', async ({ page }) => {
    await loginAs(page, accountA);
    await waitForGameReady(page);
    // Sample canvas pixel at the local-player position. Coarse but sufficient.
    const isBlueRect = await page.evaluate(async () => {
      const canvas = document.querySelector('canvas[data-game-ready=true]') as HTMLCanvasElement;
      if (!canvas) return null;
      // Sample at canvas center (server spawns at home_portal which the camera follows;
      // the local player should be near canvas center after camera follow)
      const ctx = canvas.getContext('2d') ?? canvas.getContext('webgl2');
      if (!ctx || !('getImageData' in ctx)) {
        // WebGL canvas — use a different sampling approach via toDataURL or skip
        return 'webgl-skip';
      }
      const px = (ctx as CanvasRenderingContext2D).getImageData(canvas.width / 2, canvas.height / 2, 1, 1).data;
      const [r, g, b, a] = [px[0], px[1], px[2], px[3]];
      // Solid blue marker would be ~(0, 0, 255); real sprite pixels are NOT this
      return r === 0 && g === 0 && b === 255 && a === 255;
    });
    if (isBlueRect === 'webgl-skip') {
      // Phaser uses WebGL by default; if pixel sample isn't reachable, fall back to checking
      // that the 'mvp' texture is loaded by querying window.__rebno or similar dev hook
      const atlasLoaded = await page.evaluate(() => {
        const game = (window as any).__rebno?.game as Phaser.Game | undefined;
        return !!game?.textures?.exists?.('mvp');
      });
      expect(atlasLoaded).toBe(true);
    } else {
      expect(isBlueRect).toBe(false);
    }
  });
  ```

  (executor adapts to actual GameScene Phaser renderer — WebGL pixel sample is awkward; the texture-loaded fallback is the practical assertion. Either is acceptable as a "not blue rectangle" guard.)

  Existing chat + nameplate + reconnect tests in cli-08.e2e.test.ts are preserved unchanged. The room-id selector update may require updating any other test that currently asserts `data-room-id="mvp-lobby"` — search and replace those references.
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client typecheck &amp;&amp; pnpm --filter @rebno/client test:e2e -- --grep "bn-central|atlas|player render"</automated>
    </verify>
    <done>
      GameScene preloads atlas-mvp; sets NEAREST filter; sets data-room-id on canvas. PlayerRenderer renders 0000-NaviStandD_0 atlas frame instead of blue rectangle. RoomRenderer renders BN Central tiles + background + instances from atlas frames. cli-08.e2e.test.ts asserts data-room-id=='bn-central' AND non-blue player render (or atlas-loaded fallback). All updated tests GREEN. Manual smoke (deferred to 06-17): operator opens local dev → sees real BNO sprites in BN Central room.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Extracted assets → committed Aseprite-compatible source | If bootstrap mis-decodes BMP, every downstream artifact is corrupted; mitigated by sha256 cross-check (lint:asset-pipeline) |
  | Bootstrapped source → atlas pack | Phase 1 D-15 reproducibility requires deterministic output; canonical-JSON + sorted spriteIds enforce |
  | Server-emitted bn-central layout → client | Phase 4 D-12 Ed25519 signature gates; client verifies before tilemap render |
  | Client atlas asset path → public/ static mount | D-18 same-Fly-app hosting; atlas-mvp.png served from apps/server/public/; CSP must not block |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-14-01 | Tampering | Adversary swaps atlas-mvp.png with bogus content | mitigate | lint:asset-pipeline cross-checks sha256_png against pipeline-manifest.json; CI runs the lint per verify-phase-6 step 7 |
  | T-06-14-02 | Tampering | Room layout 000.json edited without re-signing | mitigate | Phase 4 D-12 Ed25519 signature; client verify-on-load fails closed; server-side test ensures 000.sig matches 000.json before bootstrap |
  | T-06-14-03 | Disclosure | Extracted-asset filenames committed publicly leak BNO content via path leak | accept | Repo private through Phase 7 (CLAUDE.md hard rule #8); Phase 7 PAR-08 legal-prep checklist gates public flip |
  | T-06-14-04 | DoS | Atlas grows beyond 1024×1024 fallback → Phaser texture limit on lower-spec GPUs | mitigate | Atlas budget per CONTEXT D-17 (target 512×512, fallback 1024×1024); 06-14-ASSET-MANIFEST.md records final dimensions; if exceeded, split atlas |
  | T-06-14-05 | Repudiation | Sprite ID resolution unclear (which extracted file → which atlas frame) | mitigate | 06-14-ASSET-MANIFEST.md tables record every sprite_id → extracted/path → atlas frame; auditable |
  </threat_model>

  <verification>
  - `pnpm --filter @rebno/asset-pipeline test` exits 0 (bootstrap unit tests + existing tests)
  - `pnpm asset-pipeline:bootstrap && pnpm asset-pipeline:build && pnpm asset-pipeline:postprocess && pnpm lint:asset-pipeline` exits 0 end-to-end
  - `pnpm --filter @rebno/room-converter test` exits 0 (bn-central converter tests)
  - `node tools/room-converter/dist/cli.js bn-central` produces 000.json + 000.sig at apps/server/rooms/bn-central/
  - `pnpm --filter @rebno/server test` exits 0 (server boots with bn-central registered)
  - `pnpm --filter @rebno/client typecheck` exits 0
  - `pnpm --filter @rebno/client test:e2e` exits 0 (existing + new bn-central + non-blue-render assertions)
  - 06-14-ASSET-MANIFEST.md exists and has all `<derived>` placeholders filled
  - assets/source/sprites/<id>.{png,json} committed for all 15 player sprites + bn-central tile/object sprites
  - `pnpm trace:check` reports `[impl->REQ-AST-01] [impl->REQ-CLI-07]` tags satisfied
  </verification>

  <success_criteria>
  - UAT Finding #4 (D-27) closed: real BNO assets land end-to-end (sprites + room + atlas + server registration + client render)
  - mvp-lobby (synthetic) replaced with bn-central (canonical first-pass real room — the actual latest online lobby in BNO per addendum line 388)
  - Player object naming gotcha (0000-server) and room naming gotcha (0058-BNCentral, NOT 0004-Online_Lobby) honored
  - 8-direction sprite-state machine + cyan nametag (D-27a) explicitly DEFERRED to 06-16 — this plan ships StandD as default frame baseline
  - First-pass scope EXCLUDES audio + fonts + other 9 rooms (Phase 7) per addendum line 340
  - Tags `[impl->REQ-AST-01] [impl->REQ-CLI-07]` (per gap-closure addendum line 363)
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-SUMMARY.md` recording:
  (a) Final atlas-mvp dimensions (px × px) + total sprite count packed
  (b) Final bn-central layout dimensions (width_tiles × height_tiles, tile_w × tile_h) + tile/instance counts
  (c) home_portal spawn coordinates + which extracted source identified them
  (d) Any sprite_id that couldn't be resolved (with reason — invisible logic object, missing extracted asset, etc.)
  (e) Confirmation `pnpm verify:phase-6` passes end-to-end after Wave 11+12 plans land
  (f) Pointer to 06-16 for D-27a nametag + 8-direction sprite-state wiring
  (g) Trace tag verification snippet for REQ-AST-01 + REQ-CLI-07 impl + doc + int stages
  </output>
  </content>
  </invoke>```

  ### Plan 06-15
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 15
  type: execute
  wave: 11
  depends_on: [06-12]
  files_modified:
    - apps/client/src/scenes/BootScene.ts
    - apps/client/src/scenes/LoginScene.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/net/InputDispatcher.ts
    - apps/client/src/__test__/game-scene.test.ts
    - apps/client/src/__test__/login-scene.test.ts
    - apps/client/src/__test__/input-dispatcher.test.ts
    - apps/client/test/e2e/cookie-reload.e2e.test.ts
    - apps/client/test/e2e/ws-kill-reconnect.e2e.test.ts
  autonomous: true
  requirements: [CLI-09, CLI-04]
  must_haves:
    truths:
      - "Cookie auto-login path threads Better-Auth session.token through BootScene → LoginSceneInitData → GameSceneInitData (Option A — three edits per 06-12-DEBUG-FINDINGS.md §4)"
      - "GameScene self-heals on missing token by calling getSession() in create() and adopting session.token if init-data sessionToken is undefined (Option B — defence in depth, complementary to A)"
      - "GameScene.connect() no longer silently early-returns on the cookie path: with token present, joinRebnoRoom is called → bindHandlers fires → players.onAdd / onRoomLayout / chat all wire up → canvas renders content"
      - "Stale InputDispatcher.room reference fixed: after silent_reauth, dispatcher.room === GameScene.room (not the closed/garbage-collected old room object) — addresses §5 secondary defect from 06-12-DEBUG-FINDINGS"
      - "Two new Playwright e2e files (cookie-reload + ws-kill-reconnect) cover both reconnect paths from 06-HUMAN-UAT.md Test 1 reload-after-login + Test 5 WS-kill-then-reconnect — both assert canvas[data-game-ready=true] re-renders within 5s + a chat round-trip works post-reconnect"
      - "Three new vitest unit files lock the regression: GameScene.test (cookie path token threading + Option B self-heal), LoginScene.test (fast-path forwards sessionToken), InputDispatcher.test (room ref refreshed after silent_reauth)"
      - "No server-side changes — server bearer-vs-cookie posture is correct per 06-12-DEBUG-FINDINGS §7"
    artifacts:
      - path: apps/client/src/scenes/BootScene.ts
        provides: "Forwards session.token into LoginSceneInitData alongside username (Option A edit 1 of 3)"
        contains: "sessionToken"
      - path: apps/client/src/scenes/LoginScene.ts
        provides: "LoginSceneInitData accepts sessionToken; renderFastPath forwards { sessionToken, username } to scene.start('GameScene', ...) (Option A edits 2 + 3 of 3)"
        contains: "sessionToken"
      - path: apps/client/src/scenes/GameScene.ts
        provides: "create() self-heals on missing sessionToken via getSession() (Option B); connect() now reaches joinRebnoRoom on cookie path; InputDispatcher room ref refreshed on every reconnect"
        contains: "sessionToken"
      - path: apps/client/src/net/InputDispatcher.ts
        provides: "Adds setRoom(room) method (or reassigns the room field via the existing API surface) so GameScene can refresh the dispatcher's room ref after silent_reauth"
        contains: "setRoom"
      - path: apps/client/src/__test__/game-scene.test.ts
        provides: "Adds cookie-path test: scene.init({ username }) with no sessionToken → mocked getSession returns token → assert joinRebnoRoom IS called with the token"
        contains: "cookie path"
      - path: apps/client/src/__test__/login-scene.test.ts
        provides: "Adds fast-path test: assert scene.start('GameScene', ...) is called with { sessionToken, username } when LoginScene receives fastPath: true + sessionToken"
        contains: "fast-path"
      - path: apps/client/src/__test__/input-dispatcher.test.ts
        provides: "Adds silent-reauth test: dispatcher.room === gameScene.room after a simulated reconnect cycle"
        contains: "silent_reauth"
      - path: apps/client/test/e2e/cookie-reload.e2e.test.ts
        provides: "Playwright e2e — login + reload → canvas[data-game-ready=true] within 5s + chat line renderable"
        contains: "reload"
      - path: apps/client/test/e2e/ws-kill-reconnect.e2e.test.ts
        provides: "Playwright e2e — login + page.evaluate(__rebno.room.connection.close()) → SDK auto-reconnects within grace → canvas[data-game-ready=true] still truthy + chat round-trips"
        contains: "ws-kill"
    key_links:
      - from: apps/client/src/scenes/BootScene.ts
        to: apps/client/src/auth/client.ts (getSession returns SessionShape.token)
        via: "const session = await getSession(); → forward session.token alongside session.user.username"
        pattern: "session.token"
      - from: apps/client/src/scenes/LoginScene.ts
        to: apps/client/src/scenes/GameScene.ts
        via: "scene.start('GameScene', { sessionToken, username, mustForceReset }) — fast path matches form-submit shape (LoginScene.ts:173-177)"
        pattern: "sessionToken"
      - from: apps/client/src/scenes/GameScene.ts (refreshed room ref)
        to: apps/client/src/net/InputDispatcher.ts
        via: "this.inputDispatcher.setRoom(this.room) on every reconnect (refresh-not-recreate; preserves any pending input buffer state)"
        pattern: "setRoom|inputDispatcher.room"
      - from: apps/client/test/e2e/cookie-reload.e2e.test.ts + ws-kill-reconnect.e2e.test.ts
        to: apps/client/test/e2e/fixtures.ts
        via: "loginAs + waitForGameReady fixtures — reuse 06-08 patterns; the new tests extend, not replace, the cli-08.e2e.test.ts family"
        pattern: "loginAs|waitForGameReady"
  ---

  <objective>
  Close UAT Finding #2 (D-25) and the secondary defect (§5 in 06-12-DEBUG-FINDINGS.md): the cookie auto-login path drops the Better-Auth `session_token` between BootScene → LoginScene fast-path → GameScene, causing GameScene.connect() to silently early-return at the empty-token guard. Apply Option A (three edits threading the token through scene-init data) AND Option B (GameScene self-heal via getSession() in create()) — they're complementary, ship both. Also fold in the secondary defect: `InputDispatcher` keeps a stale `room` reference after silent_reauth (the `if (!this.inputDispatcher)` guard prevents re-pointing at the new room object) — refresh the dispatcher's room ref on every reconnect.

  Lock the regression with five new tests:
  1. Vitest GameScene unit (cookie path: init without sessionToken → getSession self-heal → joinRebnoRoom IS called)
  2. Vitest LoginScene unit (fast-path forwards sessionToken to GameScene)
  3. Vitest InputDispatcher unit (room ref refreshed after silent_reauth — `dispatcher.room === gameScene.room`)
  4. Playwright cookie-reload e2e (login → reload → canvas re-renders + chat works)
  5. Playwright ws-kill-reconnect e2e (login → kill WS → SDK auto-reconnects within grace → canvas still truthy + chat round-trips)

  Purpose: per 06-12-DEBUG-FINDINGS.md (the input contract for this plan, produced by 06-12). Server-side bearer-vs-cookie posture is CORRECT — do NOT touch `apps/server/src/RebnoRoom.ts` or any server bearer logic; the bug is purely client-side data flow per §7 of the debug doc.

  Output: 4 client-source edits + 1 dispatcher API tweak + 3 vitest files + 2 Playwright files. Per gap-closure addendum line 361 — `[impl/int->REQ-CLI-09] [impl->REQ-CLI-04]`.
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md
  @.planning/debug/reconnect-blank-render.md
  @apps/client/src/scenes/BootScene.ts
  @apps/client/src/scenes/LoginScene.ts
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/auth/client.ts
  @apps/client/src/net/colyseus-client.ts
  @apps/client/src/net/reconnect.ts
  @apps/client/src/net/InputDispatcher.ts
  @apps/client/test/e2e/cli-08.e2e.test.ts
  @apps/client/test/e2e/fixtures.ts

  <interfaces>
  <!-- Verbatim from 06-12-DEBUG-FINDINGS.md §3 §4 §5 — do not re-derive. -->

  Affected files (06-12 §3) — edit by 06-15:
    - apps/client/src/scenes/BootScene.ts (line 78-95) — Option A edit 1
    - apps/client/src/scenes/LoginScene.ts (line 80-116, esp. 106-109) — Option A edits 2 + 3
    - apps/client/src/scenes/GameScene.ts (line 191-192) — Option B self-heal in create()
    - apps/client/src/scenes/GameScene.ts (line 238-252) — secondary defect: refresh InputDispatcher.room
    - apps/client/src/net/InputDispatcher.ts — add setRoom method (or expose room as settable)

  Affected files (06-12 §3) — read-only / DO NOT touch:
    - apps/client/src/auth/client.ts (84-108) — getSession already returns token; consume verbatim
    - apps/client/src/net/colyseus-client.ts (82-99) — cached-reconnect path; do not modify
    - apps/server/src/RebnoRoom.ts (267-269) — server bearer-only contract is CORRECT; do not modify

  Option A (06-12 §4): three edits in priority order:
    1. BootScene.create line 88-95: forward session.token into LoginSceneInitData
    2. LoginScene.init / LoginSceneInitData line 26-51: accept sessionToken?: string; store on instance
    3. LoginScene.renderFastPath line 106-109: pass { sessionToken, username } to scene.start('GameScene', ...)

  Option B (06-12 §4): in GameScene.create, if this.sessionToken is undefined, call getSession() and adopt session.token. Combine with A — they're complementary.

  Secondary defect (06-12 §5):
    Current code in GameScene.ts:238-252 — `if (!this.inputDispatcher)` guard creates dispatcher once;
    never re-points at new this.room after silent_reauth.
    Fix: change to either always-recreate, or call this.inputDispatcher.setRoom(this.room) on the
    not-first reconnect path.

  Required regression tests (06-12 §6) — five total:
    1. Unit (game-scene.test.ts): cookie path token-threading + Option B self-heal
    2. Unit (login-scene.test.ts): fast-path forwards sessionToken
    3. Unit (input-dispatcher.test.ts): silent_reauth — dispatcher.room === gameScene.room
    4. e2e (cookie-reload.e2e.test.ts): login → reload → re-renders + chat
    5. e2e (ws-kill-reconnect.e2e.test.ts): kill WS → reconnect → still renders + chat round-trips

  LoginSceneInitData shape (existing, find at LoginScene.ts:26-51):
    interface LoginSceneInitData {
      fastPath?: boolean;
      username?: string;
      // ADD: sessionToken?: string;
    }

  GameSceneInitData shape (existing, find at GameScene.ts top):
    interface GameSceneInitData {
      sessionToken?: string;       // already present per LoginScene form-submit path
      username?: string;
      mustForceReset?: boolean;
    }

  InputDispatcher API (existing, find at apps/client/src/net/InputDispatcher.ts):
    class InputDispatcher {
      constructor(public room: Room, ...);
      // ADD: setRoom(room: Room): void { this.room = room; /* preserve any internal buffer state */ }
    }

  E2E fixtures (existing, apps/client/test/e2e/fixtures.ts):
    loginAs(page, account)              — drives form submit
    waitForGameReady(page)              — waits for canvas[data-game-ready=true]
    accountA / accountB                 — UAT seed accounts
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: Option A token-threading edits (BootScene + LoginScene) + Option B self-heal (GameScene) + LoginScene unit test</name>
    <files>
      apps/client/src/scenes/BootScene.ts,
      apps/client/src/scenes/LoginScene.ts,
      apps/client/src/scenes/GameScene.ts,
      apps/client/src/__test__/login-scene.test.ts,
      apps/client/src/__test__/game-scene.test.ts
    </files>
    <read_first>
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md (whole file — input contract),
      apps/client/src/scenes/BootScene.ts (lines 78-95 — current cookie-fetch + scene.start to LoginScene),
      apps/client/src/scenes/LoginScene.ts (lines 26-51 init data shape; lines 80-116 fast-path render; lines 173-177 form-submit-path scene.start as the canonical reference),
      apps/client/src/scenes/GameScene.ts (lines 191-192 connect early-return; line 238-252 InputDispatcher; init data shape + create() lifecycle),
      apps/client/src/auth/client.ts (lines 84-108 — getSession returns SessionShape with token),
      apps/client/src/__test__/game-scene.test.ts (existing test harness shape — copy imports + describe/it pattern; mock createAuthClient or getSession appropriately),
      apps/client/src/__test__/login-scene.test.ts (existing if any; if absent, model after game-scene.test.ts)
    </read_first>
    <behavior>
      - BootScene: when getSession() returns a non-null session, scene.start('LoginScene', { fastPath: true, username, sessionToken: session.token }) — adds the third field
      - LoginSceneInitData type adds sessionToken?: string; LoginScene stores it on the instance in init()
      - LoginScene.renderFastPath (the timer that transitions to GameScene at line 106-109): scene.start('GameScene', { sessionToken: this.sessionToken, username: this.username }) — matches form-submit-path shape
      - GameScene.create(): if this.sessionToken is undefined AND init data carried { username }, call getSession() asynchronously; on resolve, adopt session.token into this.sessionToken; then call connect(). This is Option B — defensive against future scene-init shape drift
      - GameScene.connect early-return at line 191-192 stays as a defensive guard but should now never fire on the cookie path
      - Vitest tests: LoginScene fast-path forwards sessionToken; GameScene cookie-path self-heal — getSession mock returns token + assert joinRebnoRoom IS called with that token
    </behavior>
    <action>
  **1. apps/client/src/scenes/BootScene.ts** — Tag `// [impl->REQ-CLI-09] [impl->REQ-CLI-04]` near the cookie-fetch block. Find lines 78-95:

  Current:
  ```ts
  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 });
  }
  ```

  Change to:
  ```ts
  // [impl->REQ-CLI-09] [impl->REQ-CLI-04]
  // Option A: forward session.token alongside username so LoginScene fast-path can
  // pass it to GameScene (otherwise GameScene.connect() silently early-returns at
  // the !sessionToken guard — UAT Finding #2 / D-25).
  const session = await getSession().catch(() => null);
  ...
  if (session) {
    this.scene.start('LoginScene', {
      fastPath: true,
      username: session.user.username,
      sessionToken: session.token,
    });
  } else {
    this.scene.start('LoginScene', { fastPath: false });
  }
  ```

  **2. apps/client/src/scenes/LoginScene.ts** — Tag `// [impl->REQ-CLI-09] [impl->REQ-CLI-04]` near the init data type + at the renderFastPath site.

  Find LoginSceneInitData (lines 26-51 area):
  ```ts
  interface LoginSceneInitData {
    fastPath?: boolean;
    username?: string;
    // ADD:
    sessionToken?: string;
  }
  ```

  Find init() method — store sessionToken on the instance:
  ```ts
  init(data: LoginSceneInitData = {}): void {
    this.fastPath = !!data.fastPath;
    this.username = data.username;
    this.sessionToken = data.sessionToken;  // ADD
    // ... existing init body
  }
  ```

  Find renderFastPath / fastPathTimer (lines 106-109):
  ```ts
  this.fastPathTimer = this.time.delayedCall(500, () => {
    spinner.destroy();
    // OPTION A edit 3: forward sessionToken (matches form-submit-path shape at line 173-177)
    this.scene.start('GameScene', {
      sessionToken: this.sessionToken,
      username: this.username,
    });
  });
  ```

  **3. apps/client/src/scenes/GameScene.ts** — Tag `// [impl->REQ-CLI-09] [impl->REQ-CLI-04]` near create() + the early-return guard.

  In create() — Option B self-heal:
  ```ts
  create(): void {
    // ... existing chatHud/banner/escMenu mount ...
    // [impl->REQ-CLI-09] [impl->REQ-CLI-04]
    // Option B: if init data did not carry sessionToken (e.g. future scene-init refactor
    // breaks the cookie path again), self-heal by re-fetching the session and adopting
    // its token. Defence-in-depth alongside Option A.
    if (!this.sessionToken && this.username) {
      void this.recoverSessionTokenAndConnect();
    } else {
      void this.connect(env.wssUrl);
    }
  }

  private async recoverSessionTokenAndConnect(): Promise<void> {
    try {
      const session = await getSession();
      if (session?.token) {
        this.sessionToken = session.token;
      }
    } catch (err) {
      console.warn('[GameScene] recoverSessionTokenAndConnect: getSession failed', err);
    }
    await this.connect(env.wssUrl);
  }
  ```

  Leave the existing `if (!this.sessionToken) return;` guard at line 191-192 as a defensive check — Option B feeds the token before connect() is called, so the guard now never fires on the cookie path.

  (Adapt to the actual create() control flow — if create() already invokes connect() at the bottom, gate that call behind the same async-recovery branch. The intent: connect() always sees a token by the time it runs on the cookie path.)

  **4. apps/client/src/__test__/login-scene.test.ts** — Tag `// [unit->REQ-CLI-09]`. New file or extend existing.

  ```ts
  // [unit->REQ-CLI-09]
  import { describe, it, expect, vi, beforeEach } from 'vitest';
  import { LoginScene } from '../scenes/LoginScene';

  describe('LoginScene fast-path token threading (regression for UAT Finding #2)', () => {
    it('forwards sessionToken to GameScene when fastPath + sessionToken provided', () => {
      const scene = new LoginScene();
      // Mock the Phaser scene API surface needed
      const sceneStartSpy = vi.fn();
      (scene as any).scene = { start: sceneStartSpy };
      (scene as any).time = { delayedCall: (_ms: number, cb: () => void) => cb() }; // execute immediately
      (scene as any).add = { text: () => ({ destroy: vi.fn() }) }; // minimal stub for spinner

      scene.init({ fastPath: true, username: 'me', sessionToken: 'tok-123' });
      // call the fast-path render method (or invoke create() if fast path runs from there)
      (scene as any).renderFastPath?.() ?? scene.create?.();

      expect(sceneStartSpy).toHaveBeenCalledWith('GameScene', expect.objectContaining({
        sessionToken: 'tok-123',
        username: 'me',
      }));
    });

    it('does NOT forward sessionToken when fastPath is false (form-submit path takes over)', () => {
      const scene = new LoginScene();
      const sceneStartSpy = vi.fn();
      (scene as any).scene = { start: sceneStartSpy };
      scene.init({ fastPath: false });
      // form-submit path is exercised by other tests; here just assert fast-path doesn't fire
      expect(sceneStartSpy).not.toHaveBeenCalled();
    });
  });
  ```

  (Adapt the mocked scene API to LoginScene's actual Phaser dependencies — the executor reads the file and stubs only what's needed.)

  **5. apps/client/src/__test__/game-scene.test.ts** — Add cookie-path test. Tag `// [unit->REQ-CLI-09] [unit->REQ-CLI-04]`.

  ```ts
  // [unit->REQ-CLI-09] [unit->REQ-CLI-04]
  import { describe, it, expect, vi } from 'vitest';
  import { GameScene } from '../scenes/GameScene';
  import * as authClient from '../auth/client';
  import * as colyseusClient from '../net/colyseus-client';

  describe('GameScene cookie-path self-heal (Option B regression)', () => {
    it('init({ username }) without sessionToken → getSession returns token → joinRebnoRoom called with token', async () => {
      const joinSpy = vi.spyOn(colyseusClient, 'joinRebnoRoom').mockResolvedValue({ /* mock Room */ } as any);
      vi.spyOn(authClient, 'getSession').mockResolvedValue({ user: { username: 'me' }, token: 'recovered-tok' } as any);

      const scene = new GameScene();
      // Stub Phaser surface as needed
      (scene as any).cameras = { main: { setBackgroundColor: vi.fn() } };
      (scene as any).load = { atlas: vi.fn() };
      (scene as any).add = { sprite: vi.fn() };

      scene.init({ username: 'me' }); // no sessionToken — cookie path drift simulation
      await scene.create();

      // Allow any pending microtasks (recoverSessionTokenAndConnect) to resolve
      await new Promise((r) => setTimeout(r, 50));

      expect(authClient.getSession).toHaveBeenCalled();
      expect(joinSpy).toHaveBeenCalledWith(
        expect.anything(),
        expect.objectContaining({ token: 'recovered-tok' }),
      );
    });

    it('init({ sessionToken, username }) → joinRebnoRoom called immediately without getSession', async () => {
      const joinSpy = vi.spyOn(colyseusClient, 'joinRebnoRoom').mockResolvedValue({ /* mock Room */ } as any);
      const sessionSpy = vi.spyOn(authClient, 'getSession');

      const scene = new GameScene();
      (scene as any).cameras = { main: { setBackgroundColor: vi.fn() } };
      (scene as any).load = { atlas: vi.fn() };
      (scene as any).add = { sprite: vi.fn() };

      scene.init({ sessionToken: 'tok-direct', username: 'me' });
      await scene.create();
      await new Promise((r) => setTimeout(r, 50));

      expect(sessionSpy).not.toHaveBeenCalled(); // Option A path — no recovery needed
      expect(joinSpy).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ token: 'tok-direct' }));
    });
  });
  ```

  (Adjust mocks to match the actual joinRebnoRoom + getSession signatures.)
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test -- src/__test__/login-scene.test.ts src/__test__/game-scene.test.ts &amp;&amp; pnpm --filter @rebno/client typecheck</automated>
    </verify>
    <done>
      BootScene forwards session.token; LoginScene init data accepts sessionToken + fast-path forwards it; GameScene create() self-heals via getSession when sessionToken absent. Two vitest test files lock both Option A and Option B paths. typecheck green. Cookie auto-login no longer renders blank canvas (manual smoke deferred to Task 3 e2e + 06-17 UAT).
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: InputDispatcher.setRoom + GameScene refresh on every reconnect + dispatcher unit test</name>
    <files>
      apps/client/src/net/InputDispatcher.ts,
      apps/client/src/scenes/GameScene.ts,
      apps/client/src/__test__/input-dispatcher.test.ts
    </files>
    <read_first>
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md §5 (secondary defect — verbatim),
      apps/client/src/net/InputDispatcher.ts (current shape — find the room field; check whether it's exposed as constructor-only or settable),
      apps/client/src/scenes/GameScene.ts (lines 238-252 — current `if (!this.inputDispatcher)` guard)
    </read_first>
    <behavior>
      - InputDispatcher exposes setRoom(room) — refreshes the internal room reference without recreating the dispatcher (preserves any pending input buffer / sequence counter state)
      - GameScene.connect (or wherever the post-reconnect re-bind happens, around line 238-252): replaces `if (!this.inputDispatcher) { ... new InputDispatcher(this.room, ...) }` with: if first connect → create; if subsequent reconnect → setRoom(this.room)
      - Unit test: simulate two-step lifecycle (first connect → dispatch creation; silent_reauth → setRoom) and assert dispatcher.room === gameScene.room (referential equality on the new room object)
    </behavior>
    <action>
  **1. apps/client/src/net/InputDispatcher.ts** — Tag `// [impl->REQ-CLI-09] [impl->REQ-CLI-04]` near the new method.

  ```ts
  // [impl->REQ-CLI-09] [impl->REQ-CLI-04]
  export class InputDispatcher {
    // ... existing fields including `private _room: Room` (or `public room: Room`) ...

    constructor(room: Room, /* ... */) {
      this._room = room;
      // ... existing constructor body ...
    }

    /** Refresh the room reference after a silent_reauth without recreating the
     *  dispatcher — preserves any internal sequence counter / buffer state.
     *  Closes UAT Finding #2 secondary defect (06-12-DEBUG-FINDINGS §5). */
    setRoom(room: Room): void {
      this._room = room;
    }

    /** Public read for tests */
    get room(): Room { return this._room; }

    // ... existing dispatch() etc. — all internal references go through this._room
  }
  ```

  (If InputDispatcher already exposes `room` as a public readonly field, change to a private + getter+setter without breaking existing callers. Tests rely on `.room` read access.)

  **2. apps/client/src/scenes/GameScene.ts** — Tag `// [impl->REQ-CLI-09]` near the InputDispatcher block (lines 238-252).

  ```ts
  // [impl->REQ-CLI-09]
  // Refresh dispatcher's room ref after silent_reauth. Without this, the
  // `if (!this.inputDispatcher)` guard left a closed/garbage-collected room
  // reference inside the dispatcher → movement intents went to /dev/null
  // (06-12-DEBUG-FINDINGS §5 secondary defect, in scope here per CONTEXT
  // addendum line 361).
  if (!this.inputDispatcher) {
    this.inputDispatcher = new InputDispatcher(this.room, stubPrediction);
  } else {
    this.inputDispatcher.setRoom(this.room);
  }
  ```

  **3. apps/client/src/__test__/input-dispatcher.test.ts** — New or extended. Tag `// [unit->REQ-CLI-09]`.

  ```ts
  // [unit->REQ-CLI-09]
  import { describe, it, expect } from 'vitest';
  import { InputDispatcher } from '../net/InputDispatcher';

  describe('InputDispatcher.setRoom (silent_reauth fix — 06-12-DEBUG-FINDINGS §5)', () => {
    it('exposes setRoom that refreshes the internal room reference', () => {
      const room1 = { send: () => {}, sessionId: 'r1' } as any;
      const room2 = { send: () => {}, sessionId: 'r2' } as any;
      const stubPrediction = { /* minimal stub */ } as any;
      const d = new InputDispatcher(room1, stubPrediction);
      expect(d.room).toBe(room1);
      d.setRoom(room2);
      expect(d.room).toBe(room2);
    });

    it('preserves internal state across setRoom (no buffer reset)', () => {
      // If InputDispatcher carries a sequence counter or input buffer, the
      // executor adds an assertion here that it survives the setRoom call.
      const room1 = { send: () => {}, sessionId: 'r1' } as any;
      const room2 = { send: () => {}, sessionId: 'r2' } as any;
      const d = new InputDispatcher(room1, {} as any);
      // Simulate one input dispatch (advance internal seq counter if exposed)
      if ((d as any).dispatch) (d as any).dispatch({ axes: { x: 1, y: 0 } });
      const seqBefore = (d as any).seq ?? null;
      d.setRoom(room2);
      const seqAfter = (d as any).seq ?? null;
      if (seqBefore !== null) expect(seqAfter).toBe(seqBefore);
    });
  });
  ```

  (Adapt mocks to InputDispatcher's actual constructor signature.)
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test -- src/__test__/input-dispatcher.test.ts &amp;&amp; pnpm --filter @rebno/client typecheck</automated>
    </verify>
    <done>
      InputDispatcher.setRoom shipped + 2 vitest tests GREEN. GameScene's reconnect path uses setRoom on subsequent connects (refresh) instead of leaving a stale ref. typecheck green. Movement after silent_reauth now reaches the new room object (manual smoke deferred to Task 3 e2e).
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: Two new Playwright e2e files — cookie-reload + ws-kill-reconnect</name>
    <files>
      apps/client/test/e2e/cookie-reload.e2e.test.ts,
      apps/client/test/e2e/ws-kill-reconnect.e2e.test.ts
    </files>
    <read_first>
      apps/client/test/e2e/cli-08.e2e.test.ts (canonical e2e shape — copy describe/test imports + loginAs + waitForGameReady fixture usage),
      apps/client/test/e2e/fixtures.ts (loginAs / waitForGameReady / accountA / inviteSuffix; reuse all),
      apps/client/playwright.config.ts (verify auto-discovery picks up *.e2e.test.ts; both modes — local webServer + STAGING_URL — work),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md §6 (test sketches 3 + 4 — verbatim assertion list)
    </read_first>
    <behavior>
      - cookie-reload.e2e.test.ts: login + assert canvas[data-game-ready=true] → page.reload() → re-assert canvas[data-game-ready=true] within 5s → send a chat message → assert [data-chat-line] appears
      - ws-kill-reconnect.e2e.test.ts: login + assert canvas[data-game-ready=true] → page.evaluate close the WS → wait for SDK auto-reconnect (within Phase 4 D-22 grace = 10s) → re-assert canvas[data-game-ready=true] still truthy → send a chat message + assert round-trip
      - Both tests use existing fixtures; no new account seeding; runs in both local + STAGING_URL modes per playwright.config.ts
      - Tests are timing-tolerant (use waitForSelector with timeout, not arbitrary sleep)
    </behavior>
    <action>
  **1. apps/client/test/e2e/cookie-reload.e2e.test.ts** — Tag `// [int->REQ-CLI-09] [int->REQ-CLI-04]`.

  ```ts
  // [int->REQ-CLI-09] [int->REQ-CLI-04]
  // Regression guard for UAT Finding #2 (D-25): cookie auto-login renders blank.
  // Per 06-12-DEBUG-FINDINGS §6 test #3.

  import { test, expect } from '@playwright/test';
  import { loginAs, waitForGameReady, accountA } from './fixtures';

  test.describe('Cookie auto-login fast-path (regression for UAT Finding #2)', () => {
    test('login → reload → GameScene re-renders within 5s + chat works', async ({ page }) => {
      await loginAs(page, accountA);
      await waitForGameReady(page);

      // Reload — exercises the BootScene → cookie probe → LoginScene fast-path → GameScene chain
      await page.reload();

      // Must re-render within 5s (Option A token threading + Option B self-heal)
      await expect(page.locator('canvas[data-game-ready=true]')).toBeVisible({ timeout: 5000 });

      // Chat must work post-reload (proves bindHandlers fired → onMessage('chat') wired up)
      await page.keyboard.press('Enter');
      await page.keyboard.type('hello-after-reload');
      await page.keyboard.press('Enter');
      await expect(page.locator('[data-chat-line]').filter({ hasText: 'hello-after-reload' })).toBeVisible({ timeout: 5000 });
    });
  });
  ```

  **2. apps/client/test/e2e/ws-kill-reconnect.e2e.test.ts** — Tag `// [int->REQ-CLI-09] [int->REQ-CLI-04]`.

  ```ts
  // [int->REQ-CLI-09] [int->REQ-CLI-04]
  // Regression guard for UAT Finding #2 secondary path + UAT Test 5.
  // Per 06-12-DEBUG-FINDINGS §6 test #4.

  import { test, expect } from '@playwright/test';
  import { loginAs, waitForGameReady, accountA } from './fixtures';

  test.describe('WS-kill within grace → SDK auto-reconnect (regression for UAT Finding #2 + Test 5)', () => {
    test('kill WS → SDK reconnects within grace → canvas still truthy + chat round-trips', async ({ page }) => {
      await loginAs(page, accountA);
      await waitForGameReady(page);

      // Kill the WS — Colyseus SDK should auto-reconnect within 10s grace per Phase 4 D-22
      await page.evaluate(() => {
        const r = (window as any).__rebno?.room;
        if (r?.connection?.transport?.ws?.close) r.connection.transport.ws.close();
        else if (r?.connection?.close) r.connection.close();
      });

      // Reconnect banner appears briefly; then disappears on successful reconnect
      // canvas[data-game-ready=true] must remain truthy throughout the within-grace path
      // (the SDK preserves the same Room object — state callbacks survive)
      // Wait up to 12s for any reconnect-related transient to settle
      await page.waitForTimeout(2000); // allow banner to surface
      await expect(page.locator('canvas[data-game-ready=true]')).toBeVisible({ timeout: 12000 });

      // Verify movement intents still reach the room (closes secondary defect §5)
      // by exercising chat round-trip — chat goes through room.send too
      await page.keyboard.press('Enter');
      await page.keyboard.type('hello-after-reconnect');
      await page.keyboard.press('Enter');
      await expect(page.locator('[data-chat-line]').filter({ hasText: 'hello-after-reconnect' })).toBeVisible({ timeout: 5000 });
    });
  });
  ```

  (Both tests rely on the existing `__rebno.room` dev-hook globals — verify these are exposed in the client bundle for the test environment; they may already be wired per 06-08 SUMMARY for the existing reconnect e2e.)
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test:e2e -- --grep "Cookie auto-login|WS-kill"</automated>
    </verify>
    <done>
      Both new e2e files pass GREEN locally (against the dev-mode webServer per playwright.config.ts). Cookie-reload and WS-kill paths both render the canvas + round-trip chat. Three regression scenarios from 06-12-DEBUG-FINDINGS §6 (tests 3, 4, and the implicit movement-after-reconnect proof) are now machine-checked.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Cookie auto-login → GameScene WS auth | Bearer-only contract preserved — token must transit init data correctly OR be self-recovered via getSession; never trust a missing token to mean "anonymous" |
  | Silent_reauth → InputDispatcher | Old room object is closed; sending to it = silent message loss; setRoom refresh enforces same-room invariant |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-15-01 | Spoofing | Adversary forges init data with someone else's sessionToken | accept | Server-side bearer validation in onAuth (RebnoRoom.ts:267-269) is the trust boundary; client init data is ephemeral process state, no cross-user vector |
  | T-06-15-02 | DoS | getSession() self-heal hangs → GameScene never connects → UX deadlock | mitigate | Option B uses try/catch + eventual fallback to defensive early-return; reconnect banner surfaces if connect() fails after recovery |
  | T-06-15-03 | Tampering | Future PR removes setRoom call → silent_reauth re-introduces stale room ref | mitigate | input-dispatcher.test.ts pins setRoom existence + behavior; ws-kill-reconnect.e2e.test.ts pins the end-to-end behavior; trace:check holds tags |
  | T-06-15-04 | Repudiation | "We don't know which path the bug shipped on" | mitigate | Two distinct e2e files (cookie-reload + ws-kill-reconnect) match UAT reproducer steps verbatim; report logs preserve evidence |
  </threat_model>

  <verification>
  - `pnpm --filter @rebno/client test` exits 0 (LoginScene + GameScene + InputDispatcher unit tests added; nothing else regresses)
  - `pnpm --filter @rebno/client test:e2e` exits 0 (existing cli-08 + login-contrast + logout + new cookie-reload + ws-kill-reconnect)
  - `pnpm --filter @rebno/client typecheck` exits 0
  - Manual smoke (deferred to 06-17): operator logs in, reloads, sees content; operator logs in, kills WS via DevTools, sees content remain
  - `pnpm verify:phase-6` passes end-to-end after Wave 11+12 land
  - `pnpm trace:check` reports `[impl->REQ-CLI-09] [impl->REQ-CLI-04] [unit->REQ-CLI-09] [int->REQ-CLI-09]` tags satisfied
  - ZERO modifications to apps/server/src/RebnoRoom.ts (server bearer-only contract preserved per 06-12 §7)
  </verification>

  <success_criteria>
  - UAT Finding #2 (D-25) closed: cookie auto-login + reload + WS-kill all render the canvas with content + chat works
  - §5 secondary defect (stale InputDispatcher.room after silent_reauth) closed: dispatcher always points at current room
  - Both Option A (token threading — three edits) AND Option B (getSession self-heal) shipped — defence in depth
  - Five regression tests landed (3 vitest + 2 Playwright) — pinning cookie path, fast-path forwarding, dispatcher refresh, reload-render, ws-kill-render
  - ZERO server-side changes — bearer-only WS auth preserved per 06-12-DEBUG-FINDINGS §7
  - Tags `[impl/int->REQ-CLI-09] [impl->REQ-CLI-04]` (per gap-closure addendum line 361)
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-15-SUMMARY.md` recording:
  (a) Three Option A edit locations confirmed (BootScene, LoginScene init data, LoginScene fast-path)
  (b) Option B self-heal path confirmed (GameScene.create + recoverSessionTokenAndConnect helper or equivalent)
  (c) InputDispatcher API change (setRoom signature)
  (d) Test results: 3 vitest GREEN + 2 Playwright GREEN (paste counts)
  (e) Confirmation that apps/server/src/RebnoRoom.ts is untouched
  (f) Trace tag verification snippet for REQ-CLI-09 + REQ-CLI-04 across impl, unit, int stages
  </output>
  </content>
  </invoke>```

  ### Plan 06-16
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 16
  type: execute
  wave: 11
  depends_on: [06-13, 06-14]
  files_modified:
    - apps/client/src/render/SpriteStateMachine.ts
    - apps/client/src/render/PlayerRenderer.ts
    - apps/client/src/render/Nameplate.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/__test__/sprite-state-machine.test.ts
    - apps/client/src/__test__/nameplate.test.ts
    - apps/client/test/e2e/cli-08.e2e.test.ts
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SPRITE-STATE-DERIVATION.md
  autonomous: true
  requirements: [CLI-04, CLI-07]
  must_haves:
    truths:
      - "8-direction facing is derived deterministically from the velocity vector (x, y) — pure function vec2 → ('D' | 'U' | 'L' | 'R' | 'DR' | 'DL' | 'UR' | 'UL'); zero-velocity preserves the last facing direction (no flicker on stop)"
      - "Walk-cycle phase advances by step counter at TICK_RATE_HZ (from 06-13 constants); cycle length matches BNO frame_count for the relevant Run sprite (NaviRunD = 6 frames per 06-14 manifest)"
      - "Stand vs Run sprite selection: when |velocity| > 0 → Run<DIR> with cycle phase; when |velocity| === 0 → Stand<DIR> single frame"
      - "All 16 player atlas frames from 06-14 are addressable (Stand × 8: 0000-StandD, 0029-StandR, 0030-StandU, 0033-StandL, 0036-StandDR, 0038-StandUR, 0040-StandUL, 0042-StandDL; Run × 8: 0027-RunD, 0028-RunR, 0031-RunU, 0032-RunL, 0037-RunDR, 0039-RunUR, 0041-RunUL, 0043-RunDL)"
      - "Cyan nametag (D-27a) renders above player sprite head — anchored top-center, follows movement, visible for self + remotes; cyan color from extracted 0000-server Draw.gml (verify before locking; UI-SPEC §Color #22D3EE accent is the fallback if no exact value extractable)"
      - "Frame-perfect to BNO: 0000-server Step + KeyPress events are the canonical reference for facing-on-input semantics (do they update facing on keydown OR on next Step?); the SpriteStateMachine matches this timing"
      - "Updated cli-08.e2e.test.ts asserts: when player moves in direction X, the sprite frame is the corresponding RunX_<phase> frame (not StandD); when stopped, it's the StandX (last-direction) frame; nameplate text is visible above player at all times"
    artifacts:
      - path: apps/client/src/render/SpriteStateMachine.ts
        provides: "Pure function: deriveFrame(velX, velY, lastFacing, cyclePhase) → { frameKey: string, newFacing: Direction }; consumed by PlayerRenderer per tick"
        contains: "deriveFrame"
      - path: apps/client/src/render/PlayerRenderer.ts
        provides: "Per-tick: read velocity → call SpriteStateMachine.deriveFrame → sprite.setFrame(frameKey); applies to self + remotes (remotes use input_axes from PlayerState per Phase 6 D-08)"
        contains: "deriveFrame"
      - path: apps/client/src/render/Nameplate.ts
        provides: "Phaser Text per remote (and self) above sprite head; cyan color; follows position each frame; mirror DOM `<div data-nameplate>` for Playwright per UI-SPEC surface inventory"
        contains: "Nameplate"
      - path: apps/client/src/__test__/sprite-state-machine.test.ts
        provides: "Pure-function tests: 8 direction quadrants, zero-velocity preserves last facing, cycle phase increments correctly, Stand vs Run switch on velocity threshold"
        contains: "deriveFrame"
      - path: apps/client/src/__test__/nameplate.test.ts
        provides: "Nameplate tests: cyan color (#22D3EE or extracted exact value), top-center anchor, follows position, DOM mirror node carries username"
        contains: "Nameplate"
      - path: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SPRITE-STATE-DERIVATION.md
        provides: "Doc-only: maps each of 8 facing directions to the atlas frame; documents BNO timing semantics (facing-on-keydown vs on-next-step); records cyan exact value derivation from 0000-server Draw.gml or fallback"
        contains: "0000-server"
    key_links:
      - from: apps/client/src/render/SpriteStateMachine.ts
        to: apps/client/src/render/PlayerRenderer.ts
        via: "PlayerRenderer.update(dt) calls deriveFrame(...) each tick → sprite.setFrame(frameKey)"
        pattern: "deriveFrame|setFrame"
      - from: apps/client/src/render/PlayerRenderer.ts
        to: packages/game-logic constants (TICK_RATE_HZ from 06-13)
        via: "cycle phase advance uses TICK_RATE_HZ for deterministic timing"
        pattern: "TICK_RATE_HZ"
      - from: apps/client/src/render/PlayerRenderer.ts
        to: PlayerState.input_axes (Phase 6 D-08 schema field)
        via: "remote players use input_axes (broadcast from server per D-08) as the velocity vector input to deriveFrame"
        pattern: "input_axes"
      - from: apps/client/src/render/Nameplate.ts
        to: PlayerState.username
        via: "Nameplate.setText(player.username); positioned above sprite each frame"
        pattern: "username"
      - from: apps/client/test/e2e/cli-08.e2e.test.ts
        to: data-player-frame attribute (or similar dev-hook)
        via: "Phaser sprite frame name surfaced via dev-hook OR canvas pixel-sample comparison; e2e asserts Run<DIR> frame after move"
        pattern: "data-player-frame|RunD"
  ---

  <objective>
  Close UAT Finding #3b (D-26 Plan B) and ship D-27a nametag: build the 8-direction sprite-state machine that drives PlayerRenderer's frame selection from the velocity vector + walk-cycle phase, mapped onto the player atlas frames bootstrapped in 06-14, using the deterministic numeric constants (TICK_RATE_HZ etc.) from 06-13. Add the cyan nametag rendered above each player sprite (self + remotes), anchored top-center, following movement.

  Why this combines two findings into one plan: D-27a explicitly says the nametag belongs to the per-player render path (the natural home is wherever 8-direction sprite selection lives — same module). 06-16 owns that module; bundling cuts a useless plan-boundary in half. CONTEXT addendum line 339 lock-in confirms this scoping.

  Frame-perfect to BNO: read `extracted/client-5-8/objects/0000-server/events/Step.gml` + `Keyboard-37/38/39/40.gml` + `KeyRelease-*.gml` + `Draw.gml` to confirm facing-on-input timing semantics (does facing update on keydown OR on next Step tick? does Run start on tick 0 or tick 1?) and extract the exact cyan color used by the original draw call (the UI-SPEC §Color #22D3EE accent is the fallback if 0000-server Draw.gml does not specify an exact text-color value).

  Per CONTEXT addendum line 362 — `[impl/unit->REQ-CLI-04]` (movement prediction; sprite-state); D-27a nametag also touches CLI-07 (room rendering, since nameplate sits in the room space).

  Output: 1 new pure-function module (SpriteStateMachine) + 1 modified renderer (PlayerRenderer) + 1 new UI module (Nameplate) + GameScene wiring + 2 vitest unit files + cli-08.e2e.test.ts assertion update + derivation doc.
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-PATTERNS.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md
  @apps/client/src/render/PlayerRenderer.ts
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/__test__/player-renderer.test.ts
  @packages/game-logic/src/constants.ts
  @packages/protocol/src/state.ts
  @extracted/client-5-8/objects/0000-server/events/Step.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-37.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-38.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-39.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-40.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-37.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-38.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-39.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-40.gml
  @extracted/client-5-8/objects/0000-server/events/Draw.gml

  <interfaces>
  <!-- Atlas frame keys produced by 06-14 (15 player sprite IDs × frame_count) -->

  Standing frames (1 frame each — frameKey suffix _0):
    '0000-NaviStandD_0'  — facing DOWN
    '0029-NaviStandR_0'  — facing RIGHT
    '0030-NaviStandU_0'  — facing UP
    '0033-NaviStandL_0'  — facing LEFT
    '0036-NaviStandDR_0' — facing DOWN-RIGHT (SE)
    '0038-NaviStandUR_0' — facing UP-RIGHT (NE)
    '0040-NaviStandUL_0' — facing UP-LEFT (NW)
    '0042-NaviStandDL_0' — facing DOWN-LEFT (SW)

  Running frames (frame_count varies — typically 6; verify against 06-14 manifest):
    '0027-NaviRunD_<n>'  — n in [0..frame_count-1]
    '0028-NaviRunR_<n>'  — R-facing run (sprite exists in extracted/; restored 2026-05-10)
    '0031-NaviRunU_<n>'
    '0032-NaviRunL_<n>'
    '0037-NaviRunDR_<n>'
    '0039-NaviRunUR_<n>'
    '0041-NaviRunUL_<n>'
    '0043-NaviRunDL_<n>'

  Direction enum (lock at top of SpriteStateMachine.ts):
    type Direction = 'D' | 'U' | 'L' | 'R' | 'DR' | 'DL' | 'UR' | 'UL';

  Velocity → direction mapping (8-quadrant) — pure function:
    vec2 (vx, vy) →
      if (vx, vy) ≈ (0, 0) → preserve lastFacing
      else: angle = atan2(-vy, vx);  // -vy because screen y grows down
            map to nearest of 8 octants (each 45° wide):
              R   = [-22.5°, +22.5°]
              UR  = [+22.5°, +67.5°]
              U   = [+67.5°, +112.5°]
              UL  = [+112.5°, +157.5°]
              L   = [+157.5°, +180°] ∪ [-180°, -157.5°]
              DL  = [-157.5°, -112.5°]
              D   = [-112.5°, -67.5°]
              DR  = [-67.5°, -22.5°]
    (BNO 0000-server may use a different convention — verify against KeyPress events; e.g. some games use direction = `point_direction(0,0,hsp,vsp)` and quadrant via floor(direction/45). Match BNO if the implementation differs from this textbook formula.)

  Cycle phase advance:
    isRunning = |vec2| > 0
    if isRunning: cyclePhase = (cyclePhase + 1) mod frame_count_for(direction)
    if !isRunning: cyclePhase = 0  (or preserve — BNO behavior: most likely reset to 0 on stand)

  State output:
    if isRunning:  frameKey = `${runSpriteId(facing)}_${cyclePhase}`
    if !isRunning: frameKey = `${standSpriteId(facing)}_0`

  D-27a Nametag (UI-SPEC §Nameplate verbatim):
    - Phaser canvas Text per player (PERFORMANCE: cheaper than DOM at 50+ players, fine here)
    - Hidden DOM `<div data-nameplate data-player-id="<sessionId>">` mirror for Playwright selectors per UI-SPEC surface inventory line 23
    - Color: cyan — derive from 0000-server Draw.gml `draw_set_color(...)` call if present;
             fallback: UI-SPEC §Color accent #22D3EE
    - Font: monospace, 16px, weight 400 per UI-SPEC §Typography (or whatever Phaser's atlas font supports; if no font extracted in Phase 6, use the system monospace stack)
    - Anchor: top-center, 4px above sprite head (UI-SPEC spacing exception line 63: "Nameplate-to-sprite gap = 4px in canvas pixels")
    - Updates: each frame in PlayerRenderer.update(); positions follow sprite

  Per Phase 6 D-08: PlayerState carries `input_axes` for remote players → SpriteStateMachine consumes input_axes for remotes (so their sprite-state is derived deterministically, even during snapshot interpolation per D-11).

  Tagging:
    apps/client/src/render/SpriteStateMachine.ts → // [impl->REQ-CLI-04]
    apps/client/src/render/Nameplate.ts → // [impl->REQ-CLI-07] (nametag in room rendering)
    apps/client/src/render/PlayerRenderer.ts (header) → // [impl->REQ-CLI-04] [impl->REQ-CLI-07]
    apps/client/src/__test__/sprite-state-machine.test.ts → // [unit->REQ-CLI-04]
    apps/client/src/__test__/nameplate.test.ts → // [unit->REQ-CLI-07]
    apps/client/test/e2e/cli-08.e2e.test.ts (new assertions) → // [int->REQ-CLI-04]
    06-16-SPRITE-STATE-DERIVATION.md → [doc->REQ-CLI-04]
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: SpriteStateMachine pure module + 8-direction derivation doc + comprehensive unit tests</name>
    <files>
      apps/client/src/render/SpriteStateMachine.ts,
      apps/client/src/__test__/sprite-state-machine.test.ts,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SPRITE-STATE-DERIVATION.md
    </files>
    <read_first>
      extracted/client-5-8/objects/0000-server/events/Step.gml (canonical mover — find: how does it set sprite_index based on hsp/vsp? what variable carries facing?),
      extracted/client-5-8/objects/0000-server/events/Keyboard-37.gml + 38.gml + 39.gml + 40.gml (held-direction handlers — when does facing update? on each tick? once on keydown?),
      extracted/client-5-8/objects/0000-server/events/KeyRelease-37.gml + 38.gml + 39.gml + 40.gml (key-up — does facing reset to D? preserve last? other?),
      extracted/client-5-8/objects/0000-server/events/Draw.gml (sprite drawing call — does the original use draw_sprite_ext with a specific sprite_index? does it use image_speed for animation? this informs cycle-advance semantics),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md (which sprite IDs exist + frame counts; all 16 player sprites present including 0028-NaviRunR),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md (TICK_RATE_HZ + SUB_PIXEL_ACCUMULATOR_MODE — informs cycle phase tick advance),
      packages/game-logic/src/constants.ts (TICK_RATE_HZ value to import for tests)
    </read_first>
    <behavior>
      - SpriteStateMachine.deriveFrame is pure: same input → same output; no Date.now, no Math.random, no I/O
      - Test 1: each of 8 cardinal/diagonal velocity directions produces the corresponding facing
      - Test 2: zero-velocity preserves lastFacing (no flicker)
      - Test 3: |velocity| > 0 returns Run<DIR>_<phase>; |velocity| === 0 returns Stand<DIR>_0
      - Test 4: cycle phase wraps mod frame_count after frame_count ticks
      - Test 5: BNO timing semantics — facing updates on the same tick as keydown OR on next Step (whatever 0000-server does — derivation doc records the truth)
      - Test 6: R-direction running maps to 0028-NaviRunR (sprite exists; verifies the full 8-direction Run coverage with no fallbacks)
    </behavior>
    <action>
  **1. Read the source GML.** Note observations. Common patterns to look for:

  | GML pattern | Meaning |
  |-------------|---------|
  | `sprite_index = NaviStandR;` (in Keyboard-39 = right) | Facing updates on keydown directly |
  | `direction = 0; sprite_index = sNaviRunR; image_speed = 0.5;` | Facing + animation speed set on input |
  | `if hsp != 0 || vsp != 0 { ... animate ... } else { sprite_index = sNaviStand<dir>; }` (in Step) | Stand vs Run derived from velocity in Step event |
  | `image_index += image_speed;` | GM5 auto-advances animation; image_speed is per-tick |

  **2. Write 06-16-SPRITE-STATE-DERIVATION.md** documenting:

  ```md
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 16
  type: doc-only-derivation
  status: complete
  ---

  # 8-Direction Sprite-State Machine — Derivation from Extracted Client 5-8 GML

  [doc->REQ-CLI-04]

  ## Source GML

  | File | What it informs |
  |------|-----------------|
  | `extracted/client-5-8/objects/0000-server/events/Step.gml` | Per-tick sprite_index assignment based on hsp/vsp |
  | `extracted/client-5-8/objects/0000-server/events/Keyboard-37/38/39/40.gml` | Held-direction → does sprite_index update HERE or in Step? |
  | `extracted/client-5-8/objects/0000-server/events/KeyRelease-*.gml` | On key-up: does sprite snap to Stand<lastDir>? |
  | `extracted/client-5-8/objects/0000-server/events/Draw.gml` | draw_sprite_ext call; cyan text-color reference (D-27a nametag) |

  ## Direction mapping (8 quadrants)

  | Velocity | atan2(-vy, vx) range | Facing | Stand sprite | Run sprite | Frame count |
  |----------|----------------------|--------|--------------|------------|-------------|
  | (+, 0)   | -22.5° to +22.5°     | R      | 0029-NaviStandR | 0028-NaviRunR | <n> |
  | (+, -)   | +22.5° to +67.5°     | UR     | 0038-NaviStandUR | 0039-NaviRunUR | <n> |
  | (0, -)   | +67.5° to +112.5°    | U      | 0030-NaviStandU | 0031-NaviRunU | <n> |
  | (-, -)   | +112.5° to +157.5°   | UL     | 0040-NaviStandUL | 0041-NaviRunUL | <n> |
  | (-, 0)   | ±157.5° to ±180°     | L      | 0033-NaviStandL | 0032-NaviRunL | <n> |
  | (-, +)   | -157.5° to -112.5°   | DL     | 0042-NaviStandDL | 0043-NaviRunDL | <n> |
  | (0, +)   | -112.5° to -67.5°    | D      | 0000-NaviStandD | 0027-NaviRunD | <n> |
  | (+, +)   | -67.5° to -22.5°     | DR     | 0036-NaviStandDR | 0037-NaviRunDR | <n> |

  ## Decisions

  ### Q1: ~~Missing 0028-NaviRunR~~ — RESOLVED 2026-05-10

  Sprite verified present at `extracted/client-5-8/sprites/0028-NaviRunR/` (has `meta.json` + `frames/`). Originally omitted from CONTEXT addendum line 336; plan-checker caught the gap. CONTEXT + 06-14 + this plan all updated to include 0028 across the full 8-direction Run set. No fallback strategy needed.

  ### Q2: Facing update timing — on keydown or on next Step?

  _(Read the Keyboard-* events. If they set `sprite_index` directly → facing-on-keydown. If they only set hsp/vsp and Step does sprite_index from velocity → facing-on-step.)_

  Decision: _<select + cite GML line>_

  ### Q3: Cycle phase advance rate

  _(Read Draw.gml or Step.gml for `image_speed = N`. GM5 image_speed is "frames per game tick" — image_speed=0.5 means advance one frame every 2 ticks.)_

  Cycle advance rule: cyclePhase += <derived> per tick. At TICK_RATE_HZ=<from 06-13> Hz, this means a full cycle takes <n> ms.

  ### Q4: KeyRelease facing behavior

  _(Does it preserve lastFacing? Reset to D? Other?)_

  Decision: _<derived>_

  ## Cyan nametag derivation (D-27a)

  _(Read Draw.gml — does it have a `draw_set_color(...)` call? Does it draw text? GM5 colors: c_aqua = 0xFFFF00, c_cyan = same. Hex BGR → RGB conversion required.)_

  Extracted color: _<value or "no draw_text call in 0000-server Draw.gml — using UI-SPEC §Color accent #22D3EE per fallback (CONTEXT addendum line 339)">_

  ## Out of scope (Phase 7)

  - Custom emote sprites (likely Phase 7 PAR-04)
  - Death / damage animations (Phase 7)
  - Per-character color schemes (Phase 7 PAR-06)
  ```

  **3. apps/client/src/render/SpriteStateMachine.ts** — Tag `// [impl->REQ-CLI-04]`. Pure module.

  ```ts
  // [impl->REQ-CLI-04]
  // 8-direction sprite-state machine. Pure function: vec2 + lastFacing + cyclePhase
  // → next frame key. No I/O, no Date.now, no Math.random — safe inside game-logic
  // purity rules and trivially testable.
  //
  // Derivation: see .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SPRITE-STATE-DERIVATION.md

  export type Direction = 'D' | 'U' | 'L' | 'R' | 'DR' | 'DL' | 'UR' | 'UL';

  const STAND_FRAME: Record<Direction, string> = {
    D:  '0000-NaviStandD_0',
    U:  '0030-NaviStandU_0',
    L:  '0033-NaviStandL_0',
    R:  '0029-NaviStandR_0',
    DR: '0036-NaviStandDR_0',
    DL: '0042-NaviStandDL_0',
    UR: '0038-NaviStandUR_0',
    UL: '0040-NaviStandUL_0',
  };

  const RUN_SPRITE_ID: Record<Direction, string> = {
    D:  '0027-NaviRunD',
    U:  '0031-NaviRunU',
    L:  '0032-NaviRunL',
    R:  '0028-NaviRunR',
    DR: '0037-NaviRunDR',
    DL: '0043-NaviRunDL',
    UR: '0039-NaviRunUR',
    UL: '0041-NaviRunUL',
  };

  // Frame counts from 06-14-ASSET-MANIFEST.md (verified at executor time)
  const RUN_FRAME_COUNT: Record<Direction, number> = {
    D: 6, U: <n>, L: <n>, R: <n>, DR: <n>, DL: <n>, UR: <n>, UL: <n>,
  };

  const VELOCITY_THRESHOLD = 0.001; // |v| ≤ this = stationary; tune if needed (sub-pixel accumulator may produce tiny non-zero velocities while standing)

  export function deriveFacing(vx: number, vy: number, lastFacing: Direction): Direction {
    if (Math.hypot(vx, vy) <= VELOCITY_THRESHOLD) return lastFacing;
    // Screen y grows down; angle in degrees, +x = right = 0°, +y_screen = down = -90°
    const angle = Math.atan2(-vy, vx) * (180 / Math.PI);
    // Normalize to [-180, 180]
    // Octant mapping (each 45° wide, centered on cardinal/diagonal axes)
    if (angle >= -22.5 && angle < 22.5)   return 'R';
    if (angle >=  22.5 && angle < 67.5)   return 'UR';
    if (angle >=  67.5 && angle < 112.5)  return 'U';
    if (angle >= 112.5 && angle < 157.5)  return 'UL';
    if (angle >= 157.5 || angle < -157.5) return 'L';
    if (angle >= -157.5 && angle < -112.5) return 'DL';
    if (angle >= -112.5 && angle < -67.5)  return 'D';
    return 'DR'; // angle >= -67.5 && angle < -22.5
  }

  export interface SpriteState {
    facing: Direction;
    cyclePhase: number;
    isRunning: boolean;
    frameKey: string;
  }

  export function deriveFrame(
    vx: number, vy: number,
    lastFacing: Direction,
    cyclePhase: number,
  ): SpriteState {
    const isRunning = Math.hypot(vx, vy) > VELOCITY_THRESHOLD;
    const facing = deriveFacing(vx, vy, lastFacing);
    if (!isRunning) {
      return { facing, cyclePhase: 0, isRunning: false, frameKey: STAND_FRAME[facing] };
    }
    const fc = RUN_FRAME_COUNT[facing] ?? 1;
    const newPhase = (cyclePhase + 1) % fc;
    return { facing, cyclePhase: newPhase, isRunning: true, frameKey: `${RUN_SPRITE_ID[facing]}_${newPhase}` };
  }
  ```

  **4. apps/client/src/__test__/sprite-state-machine.test.ts** — Tag `// [unit->REQ-CLI-04]`.

  ```ts
  // [unit->REQ-CLI-04]
  import { describe, it, expect } from 'vitest';
  import { deriveFacing, deriveFrame, type Direction } from '../render/SpriteStateMachine';

  describe('deriveFacing — 8 quadrants', () => {
    it.each([
      [1, 0, 'R'], [1, -1, 'UR'], [0, -1, 'U'], [-1, -1, 'UL'],
      [-1, 0, 'L'], [-1, 1, 'DL'], [0, 1, 'D'], [1, 1, 'DR'],
    ] as Array<[number, number, Direction]>)('vec(%d, %d) → %s', (vx, vy, expected) => {
      expect(deriveFacing(vx, vy, 'D')).toBe(expected);
    });

    it('zero velocity preserves lastFacing (no flicker)', () => {
      expect(deriveFacing(0, 0, 'UR')).toBe('UR');
      expect(deriveFacing(0, 0, 'L')).toBe('L');
    });

    it('sub-threshold velocity also preserves lastFacing', () => {
      expect(deriveFacing(0.0001, 0.0001, 'D')).toBe('D');
    });
  });

  describe('deriveFrame — Stand vs Run', () => {
    it('stationary → Stand<DIR>_0 frame, cycle reset to 0', () => {
      const r = deriveFrame(0, 0, 'D', 5);
      expect(r.isRunning).toBe(false);
      expect(r.frameKey).toBe('0000-NaviStandD_0');
      expect(r.cyclePhase).toBe(0);
    });

    it('moving → Run<DIR>_<phase> frame, cycle advances', () => {
      const r = deriveFrame(0, 1, 'D', 0);  // moving down
      expect(r.isRunning).toBe(true);
      expect(r.facing).toBe('D');
      expect(r.frameKey).toMatch(/^0027-NaviRunD_\d+$/);
      expect(r.cyclePhase).toBe(1);
    });

    it('cycle wraps after frame_count ticks', () => {
      let phase = 0;
      let last: Direction = 'D';
      for (let i = 0; i < 12; i++) {
        const r = deriveFrame(0, 1, last, phase);
        phase = r.cyclePhase;
        last = r.facing;
      }
      // After 12 ticks (assuming frame_count=6), we've wrapped twice
      expect(phase).toBe(0);
    });
  });
  ```

  (Update the cycle-wrap test's expected count once you confirm the frame_count from 06-14.)
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test -- src/__test__/sprite-state-machine.test.ts</automated>
    </verify>
    <done>
      SpriteStateMachine.ts shipped with pure deriveFacing + deriveFrame; 11+ vitest tests GREEN covering 8 directions + zero-velocity preservation + Stand-Run switch + cycle wrap. 06-16-SPRITE-STATE-DERIVATION.md fully populated with all 4 decision points (Q1 missing RunR, Q2 facing timing, Q3 cycle advance, Q4 KeyRelease behavior) + cyan derivation. No GameScene wiring yet (Task 2).
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: PlayerRenderer wires SpriteStateMachine + Nameplate (cyan, top-center) + GameScene wiring</name>
    <files>
      apps/client/src/render/PlayerRenderer.ts,
      apps/client/src/render/Nameplate.ts,
      apps/client/src/scenes/GameScene.ts,
      apps/client/src/__test__/nameplate.test.ts
    </files>
    <read_first>
      apps/client/src/render/PlayerRenderer.ts (current state — should now render 0000-NaviStandD per 06-14; this plan adds dynamic frame selection),
      apps/client/src/scenes/GameScene.ts (find: per-tick update loop where local + remote sprites are positioned; PlayerRenderer.update(dt) call site),
      packages/protocol/src/state.ts (PlayerState shape — confirm input_axes is on the schema per Phase 6 D-08; that's the velocity input for remotes),
      packages/game-logic/src/constants.ts (import TICK_RATE_HZ for cycle-phase advance),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md §Color (line 91 — accent #22D3EE) + §Spacing (line 63 — Nameplate-to-sprite gap = 4px) + §Typography (16px / 400 / monospace),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SPRITE-STATE-DERIVATION.md (cyan exact value derivation)
    </read_first>
    <behavior>
      - PlayerRenderer carries per-player state: { sprite, facing, cyclePhase, nameplate }
      - Each tick: read velocity (local: from PredictionEngine; remote: from PlayerState.input_axes per Phase 6 D-08); call deriveFrame(vx, vy, facing, cyclePhase); update sprite.setFrame; update local facing + cyclePhase
      - Nameplate is a Phaser.GameObjects.Text per player, color = derived cyan (06-16-SPRITE-STATE-DERIVATION.md), font = 'monospace' 16px, anchored top-center 4px above sprite head
      - Hidden DOM mirror `<div data-nameplate data-player-id="<sessionId>">username</div>` per UI-SPEC surface inventory line 23 — for Playwright selectors
      - Nameplate position updates each frame to follow sprite
      - Self also gets a nameplate (D-27a says self + remotes both)
    </behavior>
    <action>
  **1. apps/client/src/render/Nameplate.ts** — New file. Tag `// [impl->REQ-CLI-07]`.

  ```ts
  // [impl->REQ-CLI-07]
  // D-27a: cyan nametag above each player sprite (self + remotes).
  // Phaser canvas Text + hidden DOM mirror for Playwright selectors (UI-SPEC line 23).

  const NAMETAG_COLOR = '#22D3EE';  // UI-SPEC §Color accent fallback; if 06-16-SPRITE-STATE-DERIVATION.md extracted exact BNO cyan, use that instead
  const NAMETAG_FONT_SIZE = '16px';
  const NAMETAG_FONT_FAMILY = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace';
  const NAMETAG_OFFSET_Y = 4; // px above sprite head per UI-SPEC §Spacing line 63

  export class Nameplate {
    private text: Phaser.GameObjects.Text;
    private domMirror: HTMLDivElement | null = null;

    constructor(scene: Phaser.Scene, sessionId: string, username: string) {
      this.text = scene.add.text(0, 0, username, {
        fontFamily: NAMETAG_FONT_FAMILY,
        fontSize: NAMETAG_FONT_SIZE,
        color: NAMETAG_COLOR,
      }).setOrigin(0.5, 1);  // top-center anchor (origin x=0.5 center, y=1 bottom-of-text-box → text sits above the anchor point)

      // DOM mirror for Playwright selectors
      const overlay = document.getElementById('dom-overlay');
      if (overlay) {
        const mirror = document.createElement('div');
        mirror.setAttribute('data-nameplate', '');
        mirror.setAttribute('data-player-id', sessionId);
        mirror.textContent = username;
        mirror.style.display = 'none'; // selector-only; not visible UI
        overlay.appendChild(mirror);
        this.domMirror = mirror;
      }
    }

    /** Update position to follow the sprite. Call each frame. */
    follow(spriteX: number, spriteY: number, spriteHeight: number): void {
      this.text.setPosition(spriteX, spriteY - spriteHeight / 2 - NAMETAG_OFFSET_Y);
    }

    setText(username: string): void {
      this.text.setText(username);
      if (this.domMirror) this.domMirror.textContent = username;
    }

    destroy(): void {
      this.text.destroy();
      this.domMirror?.remove();
      this.domMirror = null;
    }
  }
  ```

  **2. apps/client/src/render/PlayerRenderer.ts** — Tag `// [impl->REQ-CLI-04] [impl->REQ-CLI-07]` near the new state-machine + nameplate wiring.

  Augment the per-player record:
  ```ts
  import { deriveFrame, type Direction } from './SpriteStateMachine';
  import { Nameplate } from './Nameplate';

  interface PlayerRenderState {
    sprite: Phaser.GameObjects.Sprite;
    nameplate: Nameplate;
    facing: Direction;
    cyclePhase: number;
  }

  private local: PlayerRenderState | null = null;
  private remotes = new Map<string, PlayerRenderState>();

  ensureLocal(scene: Phaser.Scene, x: number, y: number, sessionId: string, username: string): PlayerRenderState {
    if (this.local) return this.local;
    const sprite = scene.add.sprite(x, y, 'mvp', '0000-NaviStandD_0');
    const nameplate = new Nameplate(scene, sessionId, username);
    this.local = { sprite, nameplate, facing: 'D', cyclePhase: 0 };
    return this.local;
  }

  addRemote(scene: Phaser.Scene, x: number, y: number, sessionId: string, username: string): void {
    const sprite = scene.add.sprite(x, y, 'mvp', '0000-NaviStandD_0');
    const nameplate = new Nameplate(scene, sessionId, username);
    this.remotes.set(sessionId, { sprite, nameplate, facing: 'D', cyclePhase: 0 });
  }

  /** Per-tick update — call from GameScene.update(). */
  updateLocal(vx: number, vy: number, x: number, y: number): void {
    if (!this.local) return;
    const state = deriveFrame(vx, vy, this.local.facing, this.local.cyclePhase);
    this.local.sprite.setFrame(state.frameKey);
    this.local.sprite.setPosition(x, y);
    this.local.facing = state.facing;
    this.local.cyclePhase = state.cyclePhase;
    this.local.nameplate.follow(x, y, this.local.sprite.height);
  }

  updateRemote(sessionId: string, vx: number, vy: number, x: number, y: number): void {
    const r = this.remotes.get(sessionId);
    if (!r) return;
    const state = deriveFrame(vx, vy, r.facing, r.cyclePhase);
    r.sprite.setFrame(state.frameKey);
    r.sprite.setPosition(x, y);
    r.facing = state.facing;
    r.cyclePhase = state.cyclePhase;
    r.nameplate.follow(x, y, r.sprite.height);
  }
  ```

  **3. apps/client/src/scenes/GameScene.ts** — Tag `// [impl->REQ-CLI-04] [impl->REQ-CLI-07]` near the update loop.

  Wire the per-tick calls. The local-player velocity comes from PredictionEngine (Phase 6 06-XX); the remote-player velocity comes from PlayerState.input_axes per D-08:

  ```ts
  // [impl->REQ-CLI-04] [impl->REQ-CLI-07]
  update(time: number, dt: number): void {
    // ... existing prediction step ...

    // Local player render
    if (this.predictedState) {
      const { x, y, vx, vy } = this.predictedState.players.local;
      this.playerRenderer.updateLocal(vx, vy, x, y);
    }

    // Remote players render — read input_axes from server-broadcast PlayerState
    for (const [sessionId, p] of this.room?.state.players ?? []) {
      if (sessionId === this.room?.sessionId) continue; // skip local
      this.playerRenderer.updateRemote(sessionId, p.input_axes?.x ?? 0, p.input_axes?.y ?? 0, p.x, p.y);
    }
  }
  ```

  (Adapt to the actual PredictionEngine + PlayerState API shapes — the executor reads them and wires accordingly.)

  **4. apps/client/src/__test__/nameplate.test.ts** — Tag `// [unit->REQ-CLI-07]`.

  ```ts
  // [unit->REQ-CLI-07]
  import { describe, it, expect, beforeEach, vi } from 'vitest';
  import { Nameplate } from '../render/Nameplate';

  describe('Nameplate (D-27a)', () => {
    let scene: any;
    beforeEach(() => {
      document.body.innerHTML = '<div id="dom-overlay"></div>';
      scene = {
        add: {
          text: vi.fn().mockReturnValue({
            setOrigin: vi.fn().mockReturnThis(),
            setPosition: vi.fn().mockReturnThis(),
            setText: vi.fn().mockReturnThis(),
            destroy: vi.fn(),
          }),
        },
      };
    });

    it('uses cyan color (#22D3EE accent or extracted exact value)', () => {
      new Nameplate(scene, 'abc', 'me');
      const args = (scene.add.text as any).mock.calls[0][3];
      expect(args.color).toMatch(/#22D3EE|<extracted-exact-value-from-06-16-derivation>/i);
    });

    it('uses monospace font at 16px (UI-SPEC §Typography)', () => {
      new Nameplate(scene, 'abc', 'me');
      const args = (scene.add.text as any).mock.calls[0][3];
      expect(args.fontSize).toBe('16px');
      expect(args.fontFamily).toMatch(/monospace/i);
    });

    it('top-center anchor (setOrigin(0.5, 1))', () => {
      new Nameplate(scene, 'abc', 'me');
      const textObj = (scene.add.text as any).mock.results[0].value;
      expect(textObj.setOrigin).toHaveBeenCalledWith(0.5, 1);
    });

    it('creates DOM mirror with data-nameplate + data-player-id', () => {
      new Nameplate(scene, 'session-xyz', 'alice');
      const mirror = document.querySelector('[data-nameplate][data-player-id="session-xyz"]');
      expect(mirror).not.toBeNull();
      expect(mirror!.textContent).toBe('alice');
    });

    it('follow() positions nameplate above sprite', () => {
      const np = new Nameplate(scene, 'abc', 'me');
      np.follow(100, 200, 32);
      const textObj = (scene.add.text as any).mock.results[0].value;
      // Expected: x=100, y=200 - 32/2 - 4 = 180
      expect(textObj.setPosition).toHaveBeenCalledWith(100, 180);
    });

    it('destroy() cleans up DOM mirror + Phaser text', () => {
      const np = new Nameplate(scene, 'abc', 'me');
      np.destroy();
      expect(document.querySelectorAll('[data-nameplate]').length).toBe(0);
    });
  });
  ```

  **5. apps/client/test/e2e/cli-08.e2e.test.ts** — Add (don't replace) frame-selection + nameplate assertions:

  ```ts
  // [int->REQ-CLI-04] [int->REQ-CLI-07]
  test('player sprite frame switches to Run<DIR> on movement, back to Stand<DIR> on stop', async ({ page }) => {
    await loginAs(page, accountA);
    await waitForGameReady(page);
    // Press right (D key in WASD or ArrowRight) for 1s
    await page.keyboard.down('d');
    await page.waitForTimeout(500);
    // Read the current frame name via dev hook (executor exposes window.__rebno.localFrame for tests)
    const frameWhileRunning = await page.evaluate(() => (window as any).__rebno?.localFrame);
    expect(frameWhileRunning).toMatch(/^0028-NaviRunR_\d+$/); // R-facing run frame (cycle phase varies)
    await page.keyboard.up('d');
    await page.waitForTimeout(200);
    const frameAfterStop = await page.evaluate(() => (window as any).__rebno?.localFrame);
    expect(frameAfterStop).toMatch(/^(0029-NaviStandR|0000-NaviStandD)/); // Stand frame after stop
  });

  test('nameplate visible above each player sprite (DOM mirror)', async ({ page }) => {
    await loginAs(page, accountA);
    await waitForGameReady(page);
    const nameplates = await page.locator('[data-nameplate]').count();
    expect(nameplates).toBeGreaterThan(0);
  });
  ```

  (executor adds the dev-hook `window.__rebno.localFrame = state.frameKey` inside PlayerRenderer.updateLocal for testability — gated by import.meta.env.DEV or DEV_HOOKS=1 to avoid prod surface.)
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test -- src/__test__/nameplate.test.ts &amp;&amp; pnpm --filter @rebno/client typecheck &amp;&amp; pnpm --filter @rebno/client test:e2e -- --grep "Run<DIR>|nameplate"</automated>
    </verify>
    <done>
      Nameplate.ts shipped with cyan color + top-center anchor + DOM mirror; 6 vitest tests GREEN. PlayerRenderer wires SpriteStateMachine.deriveFrame for both local + remote players using PlayerState.input_axes for remotes. GameScene update() loop calls updateLocal + updateRemote each tick. cli-08.e2e.test.ts asserts frame switches Run↔Stand + nameplate DOM mirror present. typecheck green.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Server-broadcast input_axes → client deriveFrame | Phase 6 D-08 schema field is server-authoritative; client uses verbatim for remote sprite-state — no spoofing vector |
  | GML-derived sprite IDs → atlas frame keys | If 06-14 manifest disagrees with SpriteStateMachine sprite-ID table, sprite.setFrame throws → caught in vitest frame-key test |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-16-01 | Tampering | Adversary spoofs nameplate text via DOM injection | accept | DOM mirror is dev-tool-only (display:none); even if visible, server-broadcast username is the source of truth and re-render overwrites |
  | T-06-16-02 | DoS | Cycle-phase advance per frame at 60Hz creates GC pressure with many remotes | mitigate | Pure function returns small object; no allocations per tick beyond the SpriteState record (executor uses object pooling if profiler shows it matters; not premature) |
  | T-06-16-03 | Repudiation | "Sprite frame doesn't match BNO" without evidence | mitigate | 06-16-SPRITE-STATE-DERIVATION.md cites GML lines + UAT video evidence at 06-17 confirms |
  | T-06-16-04 | Information disclosure | Username DOM mirror exposes other players' usernames in DevTools | accept | Usernames are public per server broadcast already; mirror is parity, not new exposure |
  </threat_model>

  <verification>
  - `pnpm --filter @rebno/client test -- src/__test__/sprite-state-machine.test.ts src/__test__/nameplate.test.ts` exits 0 (17+ tests)
  - `pnpm --filter @rebno/client typecheck` exits 0
  - `pnpm --filter @rebno/client test:e2e` exits 0 (existing + new frame-switch + nameplate assertions)
  - 06-16-SPRITE-STATE-DERIVATION.md exists with all 4 decisions + cyan derivation populated
  - `pnpm verify:phase-6` passes after Wave 12 lands
  - `pnpm trace:check` reports `[impl/unit->REQ-CLI-04]` + `[impl->REQ-CLI-07]` tags satisfied
  - Manual smoke (deferred to 06-17 UAT video): sprites face correct direction; nameplate visible cyan above heads
  </verification>

  <success_criteria>
  - UAT Finding #3b (D-26 Plan B) closed: 8-direction sprite-state machine derived from extracted GML + atlas frames from 06-14
  - D-27a nametag (cyan, top-center, follows movement, self + remotes) shipped per CONTEXT addendum line 339
  - Pure SpriteStateMachine module — testable + reusable
  - Frame timing matches BNO (Q2 facing-on-keydown vs on-step decision recorded + implemented)
  - Tags `[impl/unit->REQ-CLI-04]` (per gap-closure addendum line 362) + `[impl->REQ-CLI-07]` for nametag in room-render
  - All 8 directions reachable via player movement; full 8-direction Run coverage including 0028-NaviRunR
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SUMMARY.md` recording:
  (a) Q1-Q4 decisions from 06-16-SPRITE-STATE-DERIVATION.md (one-line each)
  (b) Final RUN_FRAME_COUNT table per direction (paste from SpriteStateMachine.ts)
  (c) Cyan nametag color used (extracted exact value OR UI-SPEC accent fallback)
  (d) DOM mirror selector strategy verified working in Playwright
  (e) Confirmation 06-17 UAT can capture an authentic BNO-fidelity video on first take
  (f) Trace tag verification snippet for REQ-CLI-04 + REQ-CLI-07 across impl + unit + int stages
  </output>
  </content>
  </invoke>```

  ### Plan 06-17
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 17
  type: execute
  wave: 12
  depends_on: [06-10, 06-11, 06-12, 06-13, 06-14, 06-15, 06-16]
  files_modified:
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CLI-08-milestone.mp4
  autonomous: false
  requirements: [CLI-08, CLI-01, CLI-02, CLI-03, CLI-04, CLI-05, CLI-06, CLI-07, CLI-09, AST-01]
  must_haves:
    truths:
      - "06-HUMAN-UAT.md is re-run verbatim — same 5 tests, same instructions — by operator decidel against the freshly-deployed staging build that includes all gap-closure plans 06-10..06-16"
      - "Test 1 (HiDPI nearest-neighbor): operator can READ the login form (06-10 fix) AND the GameScene shows real BNO sprites (06-14) — pixel-correctness sweep is now meaningful and assessed across 4K Retina + 1080p + Windows 125%/150%"
      - "Test 2 (CLI-08 milestone): operator captures real `06-CLI-08-milestone.mp4` showing two players in BN Central, walking with frame-perfect 8-direction sprites (06-13 + 06-16), exchanging chat — the 'we shipped CLI-08' artifact"
      - "Test 3 (Aseprite round-trip): operator exercises the bootstrap → build → render dev-loop with one of the real bootstrapped sprites from 06-14 (e.g. modify 0000-NaviStandD.png in-place, run pnpm asset-pipeline:build, reload, see the change) — no need for .aseprite source if D-15 deferred those"
      - "Test 4 (Browser password manager + autofill): operator can sign out via the new Esc menu (06-11) → Chrome 'Save password?' fires → reload → autofill works"
      - "Test 5 (Reconnect grace banner UX): operator kills WS via DevTools (06-15 fixes the post-reconnect blank canvas) → banner appears + clears + content remains rendered + position preserved within grace + home portal spawn past grace"
      - "All 5 Status fields show ✅ Pass; the 06-HUMAN-UAT.md ## Findings table from 2026-05-10 is RESOLVED (each finding row updated with closure evidence + gap-closure plan number)"
      - "06-CLI-08-milestone.mp4 committed to .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/ (Git LFS or external link if file > 50 MB; either is per CLAUDE.md hard rule #8 acceptable)"
      - "Phase 6 status flips from IN_PROGRESS to APPROVED in 06-HUMAN-UAT.md front-matter; CLI-08 is officially shipped"
    artifacts:
      - path: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
        provides: "Re-executed UAT — all 5 tests Pass; ## Findings table updated with closure evidence per row; sign-off table green; status APPROVED"
        contains: "APPROVED"
      - path: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CLI-08-milestone.mp4
        provides: "The CLI-08 milestone artifact — Capcom-derived BNO has had ~25 years between original and rebuild; this video is the proof"
        contains: ""
    key_links:
      - from: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md (post-update)
        to: 06-10..06-16 SUMMARY files
        via: "## Findings table closure column references each gap-closure plan + summary doc"
        pattern: "06-1[0-6]-SUMMARY"
      - from: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
        to: 06-CLI-08-milestone.mp4
        via: "Test 2 Video field references the committed file path"
        pattern: "06-CLI-08-milestone"
      - from: 06-HUMAN-UAT.md status APPROVED
        to: /gsd-verify-work 06
        via: "Phase 6 close-out: /gsd-verify-work consumes both the automated verify-phase-6 (06-09) and the manual UAT artifact"
        pattern: "APPROVED"
  ---

  <objective>
  Close the gap-closure cycle: re-run `06-HUMAN-UAT.md` verbatim against the freshly-deployed staging build that includes all gap-closure plans 06-10 through 06-16; capture the real `06-CLI-08-milestone.mp4` (the milestone artifact for "we shipped CLI-08"); update the UAT doc's ## Findings table with closure evidence for each of the 5 findings from the 2026-05-10 run; flip Phase 6 status to APPROVED.

  Purpose: per CONTEXT addendum line 353 — Wave 12, sequential, all 7 prior gap-closure plans must ship before this runs. This is the final acceptance test for CLI-08, the rebuild's go/no-go milestone (CLAUDE.md hard rule #8 + ROADMAP). The original UAT video at `uat-test-1-2.mp4` was captured on a synthetic mvp-lobby with blue rectangles; this plan replaces it with a real-asset, real-room, real-sprite, real-reconnect-recovery video.

  Per gap-closure addendum line 364 — `[doc->REQ-CLI-08]` (final milestone artifact).

  Output: updated 06-HUMAN-UAT.md (5 tests Pass + closure table + APPROVED status) + committed 06-CLI-08-milestone.mp4. After this plan ships and the checkpoint resolves green, Phase 6 closes and Phase 7 unblocks.
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-09-PLAN.md
  @.planning/phases/05-deploy/05-HUMAN-UAT.md

  <interfaces>
  <!-- This plan deliberately consumes ZERO source-file interfaces. -->
  <!-- It re-executes a 2026-05-10 UAT script against the gap-closure deployment. -->

  The 5 UAT tests are LOCKED verbatim by 06-HUMAN-UAT.md (do not change instructions; only update Operator Notes + Status + ## Findings closure column).

  Pre-flight checks (operator + Claude before re-running UAT):
  1. All gap-closure plans 06-10..06-16 SUMMARY docs exist and report green
  2. `pnpm verify:phase-6` (06-09 composite gate) exits 0 end-to-end
  3. Staging deploy is green: `https://staging.rebno.decidel.com/?invite=$STAGING_INVITE_TOKEN` returns the new client bundle
  4. Two seeded UAT accounts (uat_a + uat_b) work — verified by 06-08's Playwright e2e in CI

  Deployment ritual (Phase 6 D-18 + Phase 5 D-11):
  - `git push` to a branch → CI runs deploy-staging.yml → builds client + server image → flyctl deploy → Playwright post-deploy gate → if green, merge to main → prod deploy on main push (gated by Phase 5 ritual)
  - Operator verifies the deployed bundle includes all gap-closure changes (check the JS bundle hash in DevTools Network panel matches the latest commit's expected hash)

  Closure evidence per finding (to be filled in 06-HUMAN-UAT.md ## Findings table):
  - Finding #1 (login text invisible) → 06-10-PLAN.md / 06-10-SUMMARY.md + 06-10-PLAN.md's contrast Playwright assertion
  - Finding #2 (reconnect blank GameScene) → 06-12-DEBUG-FINDINGS.md + 06-15-PLAN.md / 06-15-SUMMARY.md + cookie-reload + ws-kill-reconnect e2e tests
  - Finding #3 (movement doesn't match BNO) → 06-13-PLAN.md / 06-13-SUMMARY.md (numerics) + 06-16-PLAN.md / 06-16-SUMMARY.md (sprite-state)
  - Finding #4 (no real BNO assets) → 06-14-PLAN.md / 06-14-SUMMARY.md + 06-14-ASSET-MANIFEST.md
  - Finding #5 (no logout) → 06-11-PLAN.md / 06-11-SUMMARY.md

  Video file size handling (CLAUDE.md hard rule #8 — repo private through Phase 7):
  - Up to 50 MB: commit directly
  - 50-100 MB: Git LFS via `.gitattributes` track for *.mp4
  - > 100 MB: external private location + reference in UAT doc
  </interfaces>
  </context>

  <tasks>

  <task type="auto">
    <name>Task 1: Pre-UAT verification — confirm all gap-closure plans landed and verify-phase-6 is green</name>
    <files>
      (read-only verification — no file edits)
    </files>
    <read_first>
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-SUMMARY.md,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-11-SUMMARY.md,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-SUMMARY.md,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-SUMMARY.md,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-SUMMARY.md,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-15-SUMMARY.md,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SUMMARY.md
    </read_first>
    <action>
  **1. Confirm all 7 prior SUMMARY docs exist and report green** (each summary's "What was built" + "verification" sections):

  ```bash
  for n in 10 11 12 13 14 15 16; do
    if [ ! -f ".planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-${n}-SUMMARY.md" ]; then
      echo "MISSING SUMMARY: 06-${n}"; exit 1
    fi
  done
  echo "All 7 SUMMARYs present"
  ```

  **2. Run the composite verify gate end-to-end**:
  ```bash
  pnpm verify:phase-6
  ```

  Must exit 0. If any step fails, STOP and route the failure back to the responsible plan (use the failure label to identify which plan owns the broken step):
  - Step 4 (Asset-pipeline: build) → 06-14
  - Step 7 (Lint: asset-pipeline) → 06-14
  - Step 8 (Workspace: test) → unit test failure; trace to source plan via test name
  - Step 9 (Client: e2e cli-08 smoke) → 06-15 or 06-16 depending on failing assertion
  - Step 10 (Traceable-reqs: check) → tagging gap; identify missing tag

  **3. Verify staging deployment is current**:
  ```bash
  # Check the deployed bundle matches the latest commit
  curl -sI https://staging.rebno.decidel.com/ | head
  # Visual confirmation: open https://staging.rebno.decidel.com/?invite=$STAGING_INVITE_TOKEN
  # in a fresh Chrome incognito window; confirm the login form is readable (06-10 fix landed),
  # the Esc menu opens (06-11 landed), the cookie reload doesn't blank (06-15 landed),
  # and the GameScene shows BNCentral with real sprites (06-14 + 06-16 landed)
  ```

  **4. Confirm UAT seed accounts are usable**:
  ```bash
  # uat_a + uat_b should already exist (per 06-08 Playwright e2e using these accounts daily)
  # If they're stale (e.g. password rotation), seed via the legacy migrator + change-password rituals
  ```

  If anything red, STOP — file a follow-up ticket / route back to the responsible plan. If all green, proceed to Task 2 checkpoint.
    </action>
    <verify>
      <automated>node -e "const fs=require('fs'); const ns=[10,11,12,13,14,15,16]; for (const n of ns) { const p='.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-'+n+'-SUMMARY.md'; if (!fs.existsSync(p)) { console.error('MISSING:', p); process.exit(1); } } console.log('all 7 SUMMARY docs present'); &amp;&amp; pnpm verify:phase-6"</automated>
    </verify>
    <done>
      All 7 prior SUMMARY docs present + green; pnpm verify:phase-6 exits 0; staging deployment confirmed current with all gap-closure changes; UAT seed accounts usable. Operator + Claude both confident the build is ready for human UAT re-run.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <name>Task 2: Operator re-runs all 5 UAT tests against staging; captures 06-CLI-08-milestone.mp4; updates 06-HUMAN-UAT.md to APPROVED</name>
    <what-built>
  All 7 gap-closure plans (06-10..06-16) shipped. Staging deployed with the gap-closure bundle. Operator can now re-execute the 5 manual UAT tests verbatim from `06-HUMAN-UAT.md` (the same script that failed on 2026-05-10) and capture the milestone video. This task is fundamentally manual — videos, real-DPI hardware sweeps, and password-manager integration cannot be headless-driven.

  **Pre-checkpoint: Claude pre-edits 06-HUMAN-UAT.md to clear the prior FAIL state**

  (see how-to-verify for the exact pre-edit shape)
    </what-built>
    <how-to-verify>
  **Step 0 (Claude — pre-checkpoint pre-edit):**

  Update `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md` BEFORE the operator runs UAT:

  a) Front-matter: `status: pending` (was `failed`); `last_updated: <today YYYY-MM-DD>`; add `gap_closure_plans: [06-10, 06-11, 06-12, 06-13, 06-14, 06-15, 06-16]`

  b) Inside each Test (1-5): clear the prior **Operator Notes**, **Screenshots / Evidence**, **Status** to a fresh `⬜ Pending` state — preserve the original instructions verbatim. The 2026-05-10 failure context belongs in the ## Findings table (see (c)) for historical record, not in the per-test fields.

  c) Update the ## Findings table — KEEP the original 2026-05-10 finding rows for traceability but ADD a "Closed by" column:

  ```md
  | # | Finding | Severity | Closed by |
  |---|---------|----------|-----------|
  | 1 | Login screen text invisible | high | 06-10-PLAN.md (LoginScene contrast tokens + Playwright contrast assertion) — see 06-10-SUMMARY.md |
  | 2 | Reconnect / cookie auto-login renders blank | critical | 06-12-DEBUG-FINDINGS.md root-cause + 06-15-PLAN.md (Option A token threading + Option B getSession self-heal + InputDispatcher.setRoom + 2 e2e tests) — see 06-15-SUMMARY.md |
  | 3 | Movement doesn't match BNO | critical | 06-13-PLAN.md (constants from extracted GML + pinning tests) + 06-16-PLAN.md (8-direction sprite-state machine + cyan nametag D-27a) — see 06-13-SUMMARY.md + 06-16-SUMMARY.md |
  | 4 | No real BNO assets | critical | 06-14-PLAN.md (asset-pipeline bootstrap + 15 player sprites + BNCentral room layout from 0058-BNCentral, replaces synthetic mvp-lobby) — see 06-14-SUMMARY.md + 06-14-ASSET-MANIFEST.md |
  | 5 | No logout | medium | 06-11-PLAN.md (Esc menu — Esc + game-view click triggers; Logout/Resume/Settings; cookie + Colyseus room cleared) — see 06-11-SUMMARY.md |
  ```

  d) Update the Sign-Off table to fresh ⬜ for all 5 rows; clear the bottom approval line.

  e) Append a `## Re-run history` section:
  ```md
  ## Re-run history

  | Date | Operator | Outcome | Notes |
  |------|----------|---------|-------|
  | 2026-05-10 | decidel | FAIL (5 findings) | Original UAT — see Sign-Off + ## Findings |
  | <YYYY-MM-DD> | decidel | _<pending>_ | Re-run after gap-closure plans 06-10..06-16 |
  ```

  **Step 1 (operator — verbatim re-run):**

  Operator opens `06-HUMAN-UAT.md` and executes Test 1 → Test 2 → Test 3 → Test 4 → Test 5 in order against `https://staging.rebno.decidel.com/?invite=$STAGING_INVITE_TOKEN` exactly as the doc specifies. For each:
  - Fill in **Operator Notes** with what was observed
  - Capture **Screenshots / Evidence** (paths in the phase directory)
  - Mark **Status** ✅ Pass or ⬛ Fail (with reason)

  **Critical: Test 2 captures the milestone video.**

  Operator records both Chrome browser windows during the alice-bob (uat_a-uat_b) movement + chat scenario. Save as `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CLI-08-milestone.mp4`. File size handling:
  - ≤ 50 MB: commit directly via `git add .planning/.../06-CLI-08-milestone.mp4`
  - 50-100 MB: enable Git LFS first (`git lfs install && git lfs track "*.mp4"`); commit `.gitattributes` + the file
  - > 100 MB: trim to ≤ 100 MB OR upload to a private location (Drive / S3) and put the link in the Test 2 Video field

  The video should clearly show:
  - Both windows side-by-side
  - BN Central room with real BNO sprites + tiles + background (NOT blue rectangles)
  - 8-direction sprite-state animation (not just StandD throughout)
  - Cyan nametags above each player's head
  - Bidirectional chat round-trip (alice's message appears in bob's window + vice versa)
  - Total duration: 30 seconds to 2 minutes is sufficient

  **Step 2 (operator — re-run history + sign-off):**

  After all 5 tests pass:
  - Fill the `## Re-run history` row for today with `outcome: PASS (5/5 tests green)` + brief notes
  - Update the Sign-Off table: each row → ✅
  - Update the bottom: `**Operator approval:** decidel, <YYYY-MM-DD> — APPROVED — CLI-08 milestone shipped.`
  - Update front-matter: `status: approved`

  If ANY test fails:
  - Mark the failing test ⬛ with reason in Operator Notes
  - DO NOT mark Phase 6 approved
  - Route back to the responsible gap-closure plan owner; file a follow-up plan or fix in place
  - The checkpoint resume signal becomes `partial` (see resume-signal section)
    </how-to-verify>
    <resume-signal>
  Type `approved — CLI-08 shipped, UAT 5/5 green, milestone video committed at <path-or-link>` when:
  - All 5 tests in 06-HUMAN-UAT.md show ✅ Pass with operator notes + evidence
  - 06-CLI-08-milestone.mp4 is committed (or externally linked + referenced in Test 2 Video field)
  - Front-matter status: approved
  - Sign-Off table all green
  - Re-run history row populated for today

  Or type `partial — UAT N/5 green, blocked on <test number(s)> due to <reason>` if any test fails. Detail the failure mode in the resume message; this triggers a follow-up gap-closure plan (06-18+) or a re-deploy. Phase 6 status remains IN_PROGRESS.

  Or type `pending — UAT scheduled for <date>` if operator is not ready to run UAT immediately; the checkpoint stays open until the operator returns with a result.
    </resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Operator's UAT execution → committed evidence | Operator could falsely claim a green run; mitigated by required screenshot + video evidence + Re-run history audit trail |
  | Milestone video → public exposure if repo flips public | CLAUDE.md hard rule #8 — repo private through Phase 7; video carries no PII (test accounts only); legal-prep checklist (Phase 7 PAR-08) reviews ALL committed media before any public flip |
  | Phase 6 close-out → unblocks Phase 7 | If UAT is approved without genuinely meeting CLI-08 ("two players walk + chat over deployed server, bug-free"), Phase 7 starts on rotten foundation — mitigated by Sign-Off table requiring all 5 ticked + video evidence reviewable |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-17-01 | Repudiation | Operator approves UAT but evidence is missing/insufficient | mitigate | Sign-Off table requires the Test 2 video file path; ## Findings closure column requires per-finding plan reference; /gsd-verify-work 06 reviews the file at close-out |
  | T-06-17-02 | Disclosure | Milestone video accidentally leaked publicly (e.g. dropped on a pastebin) | mitigate | Operator instructed to keep video in private location only; CLAUDE.md hard rule #8 enforced by Phase 7 PAR-08 legal-prep checklist before any public flip |
  | T-06-17-03 | DoS | Operator unavailable for UAT for an extended period | accept | Phase 6 close-out is the operator's responsibility; the orchestrator will surface the pending checkpoint indefinitely; this is a planning-cycle delay, not a security DoS |
  | T-06-17-04 | Tampering | Someone edits 06-HUMAN-UAT.md to mark APPROVED without running the tests | accept | Solo-developer workflow; trust model is operator integrity; Re-run history audit trail makes drift visible |
  </threat_model>

  <verification>
  - All 7 SUMMARY docs (06-10..06-16) present and green
  - `pnpm verify:phase-6` exits 0 end-to-end
  - Staging deployment current with gap-closure bundle
  - After checkpoint: 06-HUMAN-UAT.md status = approved + Sign-Off all ✅ + Test 2 video exists at the documented path + ## Findings closure column populated for all 5 findings
  - After checkpoint: `git log --oneline -- .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md` shows the re-run commit
  - `pnpm trace:check` reports `[doc->REQ-CLI-08]` doc-stage tag satisfied; all 10 Phase 6 REQ-IDs at expected stages
  - /gsd-verify-work 06 ready to run as the formal phase close-out
  </verification>

  <success_criteria>
  - UAT failure cycle from 2026-05-10 closed: 5/5 tests Pass on the gap-closure deployment
  - 06-CLI-08-milestone.mp4 captures the rebuild's go/no-go evidence (~25 years from original to rebuild — this is the artifact)
  - Phase 6 status APPROVED — CLI-08 hard milestone shipped
  - All 5 findings from the original UAT have closure-evidence rows in the ## Findings table pointing to the gap-closure plans + summaries
  - Phase 7 unblocked: Phase 6 → Phase 7 PAR-* gate is now passable (subject to /gsd-verify-work 06 formal close-out)
  - Tags `[doc->REQ-CLI-08]` (per gap-closure addendum line 364) satisfied
  - All 10 Phase 6 REQ-IDs (CLI-01..09 + AST-01) hit their required stages per traceable-reqs
  </success_criteria>

  <output>
  After completion (checkpoint resolved green), create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-17-SUMMARY.md` recording:
  (a) UAT re-run date + operator + outcome (5/5 PASS)
  (b) Path/link to 06-CLI-08-milestone.mp4 + file size + storage method (direct commit / Git LFS / external link)
  (c) Per-test brief outcome notes (1 sentence each, or paste the operator's notes verbatim from 06-HUMAN-UAT.md)
  (d) Closure mapping confirmed: Finding 1 → 06-10, Finding 2 → 06-12 + 06-15, Finding 3 → 06-13 + 06-16, Finding 4 → 06-14, Finding 5 → 06-11
  (e) Phase 6 status APPROVED; CLI-08 milestone shipped; Phase 7 unblocked
  (f) Pointer to next: run `/gsd-verify-work 06` for formal phase close-out → then `/gsd-discuss-phase 7` to begin PAR-*

  After 06-17 ships, the gap-closure cycle for Phase 6 is complete. The repo carries:
  - The original 2026-05-10 UAT failure (preserved in ## Findings + ## Re-run history)
  - The 7 gap-closure plans (06-10..06-16) that closed each finding
  - The re-executed UAT with all 5 tests Pass
  - The milestone video — the proof
  </output>
  </content>
  </invoke>```

  ## Review Instructions

  For the **set of 8 gap-closure plans**, provide:
  1. **Summary** — one paragraph overall assessment
  2. **Strengths** — what's well-designed
  3. **Concerns** — HIGH/MEDIUM/LOW severity, per plan or cross-plan
  4. **Suggestions** — concrete improvements
  5. **Risk Assessment** — LOW/MEDIUM/HIGH for closing CLI-08, with justification

  Pay particular attention to: wave sequencing (Wave 8: 06-10||06-11 → Wave 9: 06-12||06-13 → Wave 10: 06-14 → Wave 11: 06-15||06-16 → Wave 12: 06-17), whether the planned fixes actually root-cause the UAT defects, scope discipline (gap-closure is meant to be narrow), and security/correctness regressions versus the shipped Wave 1-7 work.

  Output markdown.

first_user_message:
  # Cross-AI Plan Review Request — REBNO Phase 6 Gap-Closure

  You are reviewing 8 gap-closure plans (06-10..06-17) for Phase 6 of the REBNO project. Wave 7 (plans 06-01..06-09) shipped but HUMAN-UAT 2026-05-10 exposed 5 blocking findings (D-25, D-26, D-27, D-27a, plus secondary stale-dispatcher-room defect). The 8 plans below close those gaps and re-aim for CLI-08 (the hard milestone — two players move + chat over deployed server). CLI-08 is the go/no-go for the entire rebuild.

  Provide structured feedback on plan quality, completeness, and risks. Focus on missing edge cases, dependency ordering, scope creep, security, performance, and whether the plans actually achieve the phase goal.

  ## Project Context (PROJECT.md, first 80 lines)
  ```markdown
  # REBNO — Rebuild BN Online

  ## What This Is

  REBNO is a ground-up modernization of *BN Online*, a late-1990s/early-2000s multiplayer game shipped as a GameMaker 5.3a `.gmd` project. The work extracts the original client and server source from `BN Online Client 5-8.gmd` and `BN Online Master 5-4.gmd`, documents engine + server features, then rebuilds both halves on a modern web stack (TypeScript everywhere, Node server on Fly.io, Phaser 3 *or* PixiJS client running in Chrome) at full feature parity with a modernized UX.

  ## Core Value

  **Original BN Online players can open Chrome, log in with their migrated account, walk around, and chat with each other in real time — bug-free.** Everything else (graphics polish, full feature parity, scaling) flows from that working multiplayer baseline.

  ## Requirements

  ### Validated

  <!-- Brownfield: existing artifacts in repo. Code/wiki present, but nothing new shipped yet. -->

  - ✓ GameMaker 5.3a reverse-engineering wiki (17 docs in `decomp/wiki/`) — existing
  - ✓ Legacy archive of all `.gmd` revisions, server source, Unity remake attempt, Maya assets, audio — existing
  - ✓ Codebase map (`.planning/codebase/`, 7 docs) — existing
  - ✓ Stage 1 (Extraction) complete — Validated in Phase 1: extraction (2026-05-03)
    - `tools/extract-gmd/` TS Node CLI ports `LateralGM GmFileReader` semantics; 157/157 tests
    - `extracted/client-5-8/` (8307 files) + `extracted/server-5-4/` (1144 files) committed; reproducibility byte-identical
    - 13 wire-format bugs uncovered + documented in `decomp/wiki/03-gmd-format.md` errata
    - Sprite/background image data carried as opaque `imageBytes` (Option A pivot — BMP→PNG decode deferred to Phase 6/7 asset-pipeline; EXT-04 acceptance relaxed accordingly)

  ### Active

  #### Stage 1 — Extraction (DONE — Phase 1, 2026-05-03)

  #### Stage 2 — Client analysis (DONE — Phase 2, 2026-05-04)

  #### Stage 3 — Server analysis (DONE — Phase 3, 2026-05-05; status: human_needed — Linux CI verify-phase-3 deferred per HUMAN-UAT.md)

  #### Stage 4 — Server rebuild (NEXT)
  - [ ] Reimplement server in Node + TypeScript with binary WebSocket transport
  - [ ] Refactor as code is ported: identify and eliminate bugs from the original
  - [ ] Migrate original user accounts to a modern hash (bcrypt/argon2) — preserve usernames, force password reset on first login OR transparent re-hash on first successful auth
  - [ ] Authoritative server model — clients never trust each other for game state
  - [ ] Room/level layouts stored server-side and transmitted to client over the wire on room entry, integrity-verified; client bundle ships no static layout data (SRV-13)
  - [ ] Moving-platform live positions deterministic via `packages/game-logic` `step()` and broadcast authoritatively to all clients in the same state-diff envelope as player positions (SRV-14)

  #### Stage 5 — Deploy
  - [ ] Deploy new server to Fly.io with persistent volume + Litestream backup (or Postgres)
  - [ ] CI/CD pipeline: push to main → deploy
  - [ ] Health checks, structured logging, basic ops dashboard

  #### Stage 6 — Client rebuild (MVP)
  - [ ] Web client (Phaser 3 or PixiJS, TypeScript, Vite) running in Chrome
  - [ ] Modernized UX (HiDPI, responsive, keyboard + gamepad input)
  - [ ] **MVP gate: two players join the same room, see each other move, exchange chat messages — multiplayer over the new server**

  #### Stage 7 — Full parity
  - [ ] All client features from Stage 2 documentation reimplemented
  - [ ] All server features from Stage 3 documentation reimplemented
  - [ ] Original-game test cases pass against new client + server

  ### Out of Scope

  - **Mobile / native ports** — Chrome-first; no iOS, Android, Steam binaries in v1
  - **Engine-level emulation of GameMaker 5.3a** — we reimplement game *logic*, not the GM5 runtime
  - **Re-running the original Windows binary** — preservation belongs in `legacy/`, not the rebuild
  - **Server-side anti-cheat beyond authoritative model** — no behavioral detection in v1
  - **Modding API / user-generated content** — out of scope until parity ships
  - **3D / VR / new gameplay features** — faithful rebuild first, then iterate
  - **Public marketing / asset rights clearance** — license + distribution decision deferred until rebuild is functional (see Key Decisions)

  ## Context

  - **Repo layout** (see `.planning/codebase/STRUCTURE.md`): `decomp/wiki/` holds RE knowledge, `legacy/` holds all original artifacts (audio, maya-project, open-source-release, servers, source-archive, unity-project). Two prior remake attempts exist (`legacy/unity-project`, `legacy/open-source-release`); both can inform the new rebuild but are not the authoritative source — the `.gmd` files are.
  - **Source-of-truth files**: `legacy/open-source-release/BN Online Client 5-8.gmd` (client) and `legacy/open-source-release/BN Online Master 5-4.gmd` (server). Older revisions in `legacy/source-archive/` and `legacy/servers/` are reference-only.
  - **Engine constraints to overcome** (from wiki): GM5.3a is Delphi 5 / Win32, modern decompilers don't read it, network code is 39dll-only (TCP, no native sockets), assets are ZLIB-compressed inside a monolithic XOR-obfuscated payload appended to the runner stub. Extraction pipeline is documented in `decomp/wiki/15-extraction-pipeline.md`.
  - **Proprietary formats**: `.bno` / `.bnb` save data is bespoke — only readable by walking the extracted GML's `file_bin_*` call order, not via external tools.
  - **Bit-rot / IP risks** flagged in `.planning/codebase/CONCERNS.md` (229 lines) — read before committing scope.

  ## Constraints

  - **Tech stack — server**: Node + TypeScript. Mature ecosystem, best WS tooling (`ws`, `uWebSockets.js`), strong type story for shared client/server packet definitions.
  - **Tech stack — client**: TypeScript + Phaser 3 (preferred) or PixiJS (fallback). Decision deferred to end of Stage 2 once feature surface is known. Must run in Google Chrome, no plugins.
  - **Tech stack — transport**: WebSocket binary frames. Authoritative server, clients send intent, server emits state.
  - **Tech stack — persistence**: TBD in Stage 3 (likely SQLite + Litestream for <50 player target; Postgres if data model demands relational queries).
  ```

  ## Phase 6 — Roadmap Section
  ```markdown
  ### Phase 6: Client Rebuild — MVP Gate (CLI-08 HARD MILESTONE)
  **Goal**: A Vite-built TypeScript Phaser 3 (or PixiJS, per Phase 2 ADR) client runs in Chrome, connects to the deployed Fly.io server over WSS, authenticates, predicts movement against `packages/game-logic`, and lets two players in the same room see each other move and exchange chat — multiplayer over the deployed server, end-to-end. **CLI-08 is THE go/no-go for the rebuild.**
  **Depends on**: Phase 2 (engine ADR), Phase 5 (deployed server)
  **Requirements**: CLI-01, CLI-02, CLI-03, CLI-04, CLI-05, CLI-06, CLI-07, CLI-08, CLI-09, AST-01
  **Success Criteria** (what must be TRUE):
    1. A user opens the deployed client URL in Chrome desktop, sees a login screen, enters username + password, and is authenticated against the Fly.io server with PROTOCOL_VERSION verified during handshake.
    2. After login, the user sees their character in a single MVP room rendered with HiDPI nearest-neighbor + integer scaling (no blur on retro pixel art); movement uses client-side prediction (wrapping `packages/game-logic`) with server reconciliation when prediction diverges.
    3. The chat HUD accepts input — pressing Enter sends a message; messages from other players appear in the same HUD with sender nameplate.
    4. The asset-pipeline MVP slice (AST-01: BMP→PNG sprite atlas via sharp; one sprite atlas + one background) loads via a content-hashed manifest at client boot.
    5. **MVP GATE (CLI-08)**: Two players in the same Chrome window pair (or two separate machines) join the same room over the deployed server, see each other move smoothly, and exchange chat messages — demonstrated end-to-end. **This is THE go/no-go milestone for the entire rebuild.**
    6. Disconnect + reconnect within the grace window (closing the tab and re-opening) restores the player to the same room with the same character.
  **Plans**: 9 plans (0/9 complete)
  - [x] 06-01-PLAN.md - Wave 1 — apps/client + tools/asset-pipeline scaffold + Wave 0 RED test stubs (CLI-01, CLI-09)
  - [x] 06-02-PLAN.md - Wave 2 — tools/asset-pipeline bootstrap + build implementation; pipeline-manifest.json deterministic emit (AST-01, CLI-07)
  - [x] 06-03-PLAN.md - Wave 2 — packages/protocol PROTOCOL_VERSION 2 bump + apps/server D-07/D-08/D-09 patches (CLI-02, CLI-04, CLI-09)
  - [x] 06-04-PLAN.md - Wave 3 — BootScene + LoginScene + auth client SDK + atlas loader + GameScene placeholder (CLI-01, CLI-03, CLI-06, CLI-07)
  - [x] 06-05-PLAN.md - Wave 4 — apps/client/src/net colyseus-client + ReconnectBanner + reconnect state machine (CLI-02, CLI-09)
  - [x] 06-06-PLAN.md - Wave 4 — Prediction + Reconcile + Extrapolator + InputDispatcher; advanceAccumulator hoist (CLI-04)
  - [x] 06-07-PLAN.md - Wave 5 — Full GameScene wiring: ChatHUD + ForceResetOverlay + RoomRenderer + PlayerRenderer + Nameplate + roomLayoutVerify (CLI-04, CLI-05, CLI-06, CLI-07)
  - [x] 06-08-PLAN.md - Wave 6 — Playwright cli-08 GREEN + lint-asset-pipeline + lint-vite-env + deploy-staging.yml extension (CLI-08, CLI-07, AST-01)
  - [ ] 06-09-PLAN.md - Wave 7 — verify-phase-6.mjs composite gate + 06-HUMAN-UAT.md milestone artifact (cross-cutting CLI-01..09 + AST-01)
  **UI hint**: yes
  **Hard milestone**: CLI-08 — entire rebuild gates on this. Do not advance to Phase 7 until demonstrated.
  **Research flag**: Standard pattern. NEEDS DEEPER RESEARCH only if the Phase 2 ADR flips to PixiJS (then `@pixi/tilemap` + `howler` + scene wiring per SUMMARY.md).

  ```

  ## Requirements In Scope (Phase 6 CLI-* + AST-01)
  ```markdown
  - [x] **CLI-01**: `apps/client` is a Vite-built, TypeScript Phaser 3 (or PixiJS per CDOC-04 ADR) bundle running in Google Chrome desktop
  - [x] **CLI-02**: Client connects to deployed Fly.io server over WSS binary frames, completes handshake including PROTOCOL_VERSION check
  - [ ] **CLI-03**: Login screen accepts username + password, authenticates via server, transitions to game on success
  - [x] **CLI-04**: Movement uses client-side prediction (wrapping `packages/game-logic`) + entity interpolation (remote players) + server reconciliation when prediction diverges
  - [x] **CLI-05**: Chat HUD lets player type a message; pressing Enter sends it; messages from other players appear in the same HUD with sender nameplate
  - [x] **CLI-06**: HiDPI rendering uses nearest-neighbor + integer scale (no blur on retro pixel art)
  - [x] **CLI-07**: Asset pipeline produces at least one sprite atlas + one background for the MVP room; client loads via content-hashed manifest
  - [x] **CLI-08**: **MVP GATE**: Two players in the same Chrome window pair (or two separate machines) join the same room, see each other move smoothly, exchange chat messages — multiplayer over the deployed server, end-to-end. Demonstrable.
  - [x] **CLI-09**: Disconnect/reconnect: closing the tab and re-opening within the grace window restores the player to the same room with the same character

  ### Asset Pipeline (cross-stage, completed by Stage 7)

  - [x] **AST-01**: `tools/asset-pipeline` converts extracted BMP sprites to PNG atlases (sharp library); per-sprite metadata preserved
  ```

  ## CONTEXT.md (user decisions)
  ```markdown
  # Phase 6: Client Rebuild — MVP Gate (CLI-08 HARD MILESTONE) - Context

  **Gathered:** 2026-05-09
  **Status:** Ready for planning

  <domain>
  ## Phase Boundary

  Stand up `apps/client` — a Vite-built TypeScript Phaser 3.90 bundle running in Chrome desktop — that connects to the deployed Fly.io server (`wss://staging.rebno.decidel.com/colyseus`, then `wss://rebno.decidel.com/colyseus`) over WSS, authenticates via Better-Auth, runs `packages/game-logic` `step()` for client-side prediction with server reconciliation, renders the `mvp-lobby` room with HiDPI nearest-neighbor + integer scaling, and lets two players in the same room see each other move and exchange chat — demonstrated end-to-end. **CLI-08 is THE go/no-go for the rebuild.**

  In scope:
  - **`apps/client/`** workspace package (Vite 8 + Phaser 3.90 + TypeScript strict). Per-env builds via `pnpm --filter @rebno/client build:staging|build:prod` (carries forward Phase 5 D-19); env files (`.env.staging`, `.env.prod`) hold `VITE_WSS_URL`, `VITE_HTTP_BASE`, `VITE_ROOM_SIGNING_PUBKEY`. **Same-Fly-app hosting** — Vite build outputs to `apps/server/public/` (or equivalent path served by Express); single domain per env eliminates CORS, allows `SameSite=Strict` cookies (CLI-01).
  - **Login + session flow (CLI-02, CLI-03)** — hybrid Phaser scene + DOM-overlay form (DOM `<form>` rendered via `Phaser.GameObjects.DOMElement` over a retro Phaser backdrop). On boot, a `BootScene` calls `GET /api/auth/me`; valid session → ~500 ms spinner → transition to `GameScene`; missing/expired → focused login form. Errors surfaced inline (bad pw, `PROTOCOL_VERSION_MISMATCH`, `SERVER_DRAINING`). Force-reset users: server emits `s2c.force_password_change` after `onJoin`; client opens an in-room password-change overlay (movement frozen, no disconnect-reconnect cycle — Phase 4 D-07 carryover).
  - **Movement + prediction (CLI-04, CLI-06)** — `GameScene` wraps `packages/game-logic` `step()` for client-side prediction. Reconciliation is **threshold-gated** — small divergence (< ~half-tile, planner picks concrete number from 44 × 40 px tile) lerps over ~100 ms while replaying unacked inputs; large divergence hard-snaps. Remote-player rendering uses **snapshot interpolation with extrapolation on stale (>200 ms) snapshots**, extrapolated by running deterministic `step()` locally for remote players using their broadcast input axes (see D-08 — wire schema expands to carry remote inputs). HiDPI: Phaser `Scale.FIT` with `autoRound: true` + `Phaser.Textures.FilterMode.NEAREST` everywhere. Camera follows local player.
  - **Chat HUD (CLI-05)** — Minecraft-pattern: chat is a non-modal overlay (DOM div absolutely positioned over Phaser canvas). Default state shows last N messages fading out; **Enter or T** opens chat input + freezes movement keys; Enter sends, Esc cancels and releases focus. Remote-player nameplates rendered above sprite via Phaser text or DOM (planner picks). Phase 4 D-02 wire (`s2c.chat_msg`, `c2s.chat_send`) consumed unchanged.
  - **Reconnect UX (CLI-09)** — top-banner notification ("Reconnecting…") on WS drop. Local input continues to be predicted but **world-mutating actions** (item acquisition, message-board posts, etc. — none in MVP scope but affordance baked in) are blocked while disconnected. Auto-retry every 1 s. Within 10 s grace → Colyseus `allowReconnection` resumes same session, server-authoritative position restored. After 10 s → silent re-auth from cookie. New session (sign-out + sign-in or full session expiry) → spawn at home portal, NOT last position. Banner clears on success; final failure shows "Disconnected — click to retry."
  - **Asset pipeline (AST-01, CLI-07)** — `tools/asset-pipeline/` standalone Node CLI (Phase 1 D-17 pattern; outside the workspace). Two modes: (a) `bootstrap` — reads `extracted/client-5-8/sprites/<id>/<frame>.bmp` + `meta.json`, decodes via sharp, emits per-sprite Aseprite-export-compatible PNG strips + JSON sidecars at `assets/source/sprites/<sprite_id>.{png,json}` (one-shot; output committed so devs can edit `.aseprite` source files locally and re-export); (b) `build` — packs `assets/source/**` into a single multi-sprite atlas (`atlas-mvp.png` + Phaser-format `atlas-mvp.json`) plus a `background-mvp.png`, writes `tools/asset-pipeline/output/pipeline-manifest.json` with rich per-sprite metadata (frames, origin, hashes) referencing Vite-hashed paths from Vite's own `manifest.json` after build. Phaser loads via `this.load.atlas('mvp', …)`. MVP slice scope: NaviStandD/U/R + NaviRunD/U/R (player) + Tile1, TSide1, VBorder, HBorder (room walls/floor) + one background. Sourced from `extracted/client-5-8/sprites/0000-NaviStandD/` etc.
  - **Room rendering** — `apps/server/rooms/mvp-lobby/000.json` arrives via `s2c.room_layout`, signature verified against `import.meta.env.VITE_ROOM_SIGNING_PUBKEY`, then rendered via Phaser `TilemapLayer` (or custom blit if planner finds Phaser tilemap awkward for the canonical layout JSON — see Claude's discretion). Hot-reload from server (Phase 4 D-10): on receiving a fresh `s2c.room_layout` for the current room, client re-builds the tilemap in place; player position preserved.
  - **Wire / protocol coordination** — Phase 6 plan **expands `packages/protocol`** to broadcast remote players' last-input axes (see D-08). This bumps `PROTOCOL_VERSION` and adds an `input_axes` field to `PlayerState` schema; `apps/server` tick loop emits the held input vector per account. Coordinated re-deploy ritual per Phase 4 D-03 (server before client).
  - **Per-env Vite bundles** (Phase 5 D-19 carryover) — `pnpm --filter @rebno/client build:staging` injects staging WSS URL + staging Ed25519 pubkey; `:prod` injects prod equivalents. Bundle ships zero static layout data per Phase 4 SRV-13.
  - **CLI-08 verification harness** — Both: (a) **Playwright two-client smoke** in `pnpm verify:phase-6` + `.github/workflows/deploy-staging.yml` (extends Phase 5 D-11 soak) — spawns 2 headless Chromium contexts, logs in as 2 accounts, asserts move + chat round-trip + nameplates visible; gates merge to `main`. (b) **Manual UAT** recorded in `06-HUMAN-UAT.md` with screenshot/video evidence — milestone artifact for "we shipped CLI-08." Same UAT pattern as Phase 5.
  - **Lints** — `lint-asset-pipeline.mjs` (manifest schema validation + atlas integrity), `lint-vite-env.mjs` (`.env.staging`/`.env.prod` carry required `VITE_*` keys, no secrets), wired into `pnpm verify:phase-6`.
  - **CSP + HiDPI + Chrome-first runtime guarantees** — strict CSP headers via Express on `/`, `<canvas image-rendering: pixelated>` fallback, integer-scale snap on `window.resize`. Best-effort other browsers, blocking only Chrome desktop.

  Out of scope (belongs in Phase 7 or later):
  - AST-02 (MIDI→OGG via ffmpeg + vintage soundfont), AST-03 (BMP fonts → WOFF2), AST-04 (full content-hashed manifest spanning audio + fonts) — Phase 7. Phase 6 ships silent (no audio in `extracted/client-5-8/` per ADR 0001).
  - Full room set + pixel-diff verification — Phase 7 PAR-03. Phase 6 ships exactly `mvp-lobby` through Phase 4 SRV-13 contract.
  - Multi-room transitions, friends list + presence, whispers, account recovery, account-level settings — Phase 7 PAR-04/PAR-06.
  - `.bnu` per-user transactional character migration — Phase 7 PAR-05.
  - Modernized admin web UI — Phase 7 PAR-07.
  - Full chat surface (whispers, channels, ignore/block, profanity wordlist, history rolling buffer) — Phase 7 PAR-04. Phase 6 ships only `chat_send`/`chat_msg` broadcast.
  - Mobile / native ports — out of scope per PROJECT.md.
  - Behavioural anti-cheat beyond authoritative-server model — out of scope per PROJECT.md.
  - Pre-game lobby, character creation, settings menu, message-board reader — Phase 7.
  - World-mutating action surfaces (item acquisition, message-board posts) — Phase 7. Phase 6 only bakes the **affordance** (block these while reconnecting); none ship in MVP scope.

  </domain>

  <decisions>
  ## Implementation Decisions

  ### Login + session UX (CLI-02, CLI-03)

  - **D-01 (hybrid Phaser scene + DOM-overlay form):** `LoginScene` renders retro pixel-art backdrop (Phaser); login form is a DOM `<form>` mounted via `Phaser.GameObjects.DOMElement`. Browser password-manager + autofill + accessibility (screen readers, autofill, paste) all work. Errors render inline below the form (`PROTOCOL_VERSION_MISMATCH`, bad credentials, `SERVER_DRAINING`).
  - **D-02 (autologin → ~500 ms spinner on login scene):** `BootScene` calls `GET /api/auth/me` on app boot. Valid session → show `LoginScene` with spinner + "Reconnecting as `<username>`…" for ~500 ms (visible feedback even on fast paths) → transition to `GameScene`. Invalid/expired → focused login form. User can hit Esc during the spinner to cancel and re-enter credentials. Frictionless steer (Phase 4 D-07) preserved without making the login screen "flash."
  - **D-03 (in-room force-reset overlay):** On `s2c.force_password_change` after `onJoin`, `GameScene` opens a non-dismissible password-change overlay (DOM form): movement keys frozen, scene rendered behind, `POST /api/auth/change-password`, on success server clears `accounts.force_reset` (Phase 4 D-08), overlay closes, normal play resumes — **no disconnect-reconnect cycle**.

  ### Chat HUD (CLI-05)

  - **D-04 (Minecraft-pattern non-modal overlay):** Chat is a DOM div absolutely positioned over the Phaser canvas. Default: latest N messages render bottom-left, fading out after a few seconds. **Enter or T** opens the chat input field + freezes movement keys (input field becomes focus-target so browser stops bubbling keystrokes to Phaser). **Enter** sends + closes; **Esc** cancels + closes; either way, movement keys re-engage. Visual indicator (e.g. cursor blink, distinct background) shows "chat-mode" active.
  - **D-05 (remote-player nameplates):** Always-on text label above remote-player sprites showing their `username`. Form factor (Phaser canvas text vs DOM overlay) is planner's call — both work; canvas text is cheaper, DOM gives crisper anti-aliasing.

  ### Reconnect UX (CLI-09)

  - **D-06 (top-banner reconnect):** WS drop → top-of-viewport yellow/orange banner: "Reconnecting…" with auto-retry every 1 s. Local prediction continues so the player's character stays responsive on screen. **World-mutating actions blocked** while disconnected (Phase 6 ships none, but the affordance is baked into the input/intent layer for Phase 7). Within Phase 4's 10 s grace → `allowReconnection` reuses session token; server position is authoritative on rejoin (no client-claim trust). Past 10 s → silent re-auth from cookie. Banner clears on success. Final failure (cookie also expired or repeated server unreachable) → "Disconnected — click to retry" clickable banner.
  - **D-07 (no cross-session location persistence — home-portal spawn on fresh login):** **Refines Phase 4 D-14.** Position persists in `characters.x/y/room_id` only for grace-window reconnect; on new session (full sign-out + sign-in, or session-expiry-and-rejoin), server spawns the player at their home portal, ignoring `characters.x/y`. REBNO is exploration-driven; travel itself is the progression, so per-session reset is intentional. **Server-side change required** (Phase 4 D-14 amended): `onJoin` for a new Better-Auth session emits home-portal position, NOT the persisted `characters.x/y`. Anti-cheat trivially satisfied — server is authoritative; client never claims position.

  ### Movement prediction + reconciliation (CLI-04, CLI-06)

  - **D-08 (schema expansion — server broadcasts remote-player input axes):** **Refines Phase 4 D-01.** `PlayerState` schema (`packages/protocol`) gains `input_axes` (last received c2s.input axes for the player). Server tick-loop populates from per-account stored input vector; `@colyseus/schema` delta-syncs. Bumps `PROTOCOL_VERSION`. Coordinated server-before-client deploy (Phase 4 D-03 ritual). Enables clients to run deterministic `packages/game-logic` `step()` for **remote players** during stale-snapshot extrapolation, so extrapolated positions stay collision-correct against the local signed-room layout.
  - **D-09 (event-driven c2s.input — refines Phase 4 D-02):** Client sends `c2s.input` 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; tick loop reads stored value each step. Heartbeat-coupled refresh (every 15 s — Phase 4 D-22 heartbeat cadence) reaffirms held inputs to survive dropped keyup packets. Lowers wire traffic; lowers Phase 4 D-22 input rate-limiter need (planner may relax `input` budget from 25/s burst 35).
  - **D-10 (threshold-gated reconciliation):** Local prediction error vs server snapshot:
    - `divergence < ~22 px` (≈ half a 44 × 40 tile, planner locks concrete number): smooth lerp toward server position over ~100 ms, replaying unacked inputs.
    - `divergence ≥ threshold`: hard snap to server position; no lerp.
    Avoids visible rubber-banding on big corrections while keeping micro-corrections invisible. Standard Source/Overwatch netcode pattern.
  - **D-11 (snapshot interp + step()-driven extrapolation for remote players):** Remote players render at ~100 ms backbuffer, interpolating between received state snapshots. If snapshot stale > 200 ms: extrapolate by running `packages/game-logic` `step()` locally for that player using their broadcast `input_axes` (D-08), with collision against the local signed-room layout — bounded, never floats through walls. Cap extrapolation horizon at 250 ms; thereafter, freeze the sprite. On next valid snapshot, snap-or-lerp by D-10's threshold rule.
  - **D-12 (HiDPI nearest-neighbor + integer scale):** Phaser `Scale.FIT` with `autoRound: true`; `Phaser.Textures.FilterMode.NEAREST` set on every loaded atlas; CSS `image-rendering: pixelated` as belt-and-suspenders. Snap to integer scale on `window.resize`. Closes CLI-06.

  ### Asset pipeline (AST-01, CLI-07)

  - **D-13 (`tools/asset-pipeline/` standalone CLI):** New Phase 6 tool, parallel to `tools/extract-gmd`, `tools/asset-catalog`, `tools/protocol-doc`, `tools/save-format-doc`, `tools/room-converter`. **Outside the pnpm workspace** (Phase 1 D-17 / Phase 2 D-15 boundary preserved). Reused by Phase 7 AST-02..04 (audio + fonts + manifest expansion).
  - **D-14 (two-mode pipeline — bootstrap + build):**
    - `pnpm asset-pipeline bootstrap` (one-shot): reads `extracted/client-5-8/sprites/<id>/<frame>.bmp` + companion `meta.json` (origin, frame_count) via sharp, emits per-sprite Aseprite-export-compatible PNG strips + JSON sidecars at `assets/source/sprites/<sprite_id>.{png,json}`. Output **committed** to git so devs can edit `.aseprite` source files locally, export PNG strips back to `assets/source/sprites/`, re-run `build`, and ship without re-running `bootstrap`.
    - `pnpm asset-pipeline build`: reads `assets/source/**`, packs into a single multi-sprite atlas (`atlas-mvp.png` + Phaser-format `atlas-mvp.json` with `{frames: {sprite_id_frame_n: {x, y, w, h}}, anchors: {sprite_id: {x, y}}}`) plus per-room `background-<room>.png`. Emits `tools/asset-pipeline/output/pipeline-manifest.json` with rich per-sprite metadata (sha256, frame_count, origin, atlas_ref).
  - **D-15 (Aseprite-friendly source format):** `assets/source/sprites/<sprite_id>.png` is a horizontal-strip PNG (Aseprite's canonical export); `assets/source/sprites/<sprite_id>.json` carries `{frame_w, frame_h, frame_count, origin: {x, y}, sprite_id}`. Devs edit `.aseprite` files (kept in `assets/source/sprites/aseprite/<sprite_id>.aseprite`, gitignored or committed per repo size policy — planner picks), export to the canonical PNG strip + JSON via Aseprite CLI (`aseprite -b sprite.aseprite --sheet sprite.png --data sprite.json --format json-array`).
  - **D-16 (hybrid manifest = Vite hashes + asset-pipeline indexes):** Vite handles file hashing + cache-busting (writes `dist/manifest.json` with content-hashed paths); `tools/asset-pipeline` post-processes `dist/manifest.json` into a richer `pipeline-manifest.json` indexing per-sprite metadata against Vite-hashed paths. Client fetches `pipeline-manifest.json` on `BootScene` startup, then loads atlas. Decouples Phase 7 AST-02..04 (audio + fonts) expansion from Vite internals.
  - **D-17 (MVP atlas scope):** Sprites for `mvp-lobby`: NaviStandD/U/R + NaviRunD/U/R (player, ~6 sprites × frame counts) + tile sprites (Tile1, TSide1, VBorder, HBorder). One background image (sourced from the smallest navigable extracted background that fits `mvp-lobby` semantics — planner picks at plan time after `tools/room-converter` outputs are inspected). Atlas budget: 512 × 512 px target (overflow → 1024 × 1024 fallback).

  ### Hosting + CLI-08 verification

  - **D-18 (same Fly app — `apps/server` serves static):** Vite build outputs to `apps/server/public/` (or `apps/server/dist/client/` — planner picks); Express adds `app.use(express.static(...))` mounted **after** `/api/auth/*` routes and **before** the Colyseus WS upgrade path. Single domain per env (`staging.rebno.decidel.com`, `rebno.decidel.com`); zero CORS; cookies `SameSite=Strict` viable. `.github/workflows/deploy-staging.yml` adds a client-build step (`pnpm --filter @rebno/client build:staging`) before the Docker build so the static bundle ships in the image. **Couples** client redeploy to server redeploy — acceptable at MVP scope; revisit at v2 if independent client release cadence becomes valuable.
  - **D-19 (CLI-08 verification = Playwright merge-gate + manual UAT milestone):**
    - **Playwright two-client smoke** — `apps/client/test/cli-08.e2e.test.ts` spawns 2 headless Chromium contexts via `@playwright/test`, logs in as 2 accounts (seeded via `pnpm migrate:legacy-accounts` ritual on staging), navigates to `mvp-lobby`, drives WASD on each, asserts: (a) each client sees the other's nameplate + sprite, (b) chat round-trip (client A sends "hello", client B receives with sender=A), (c) reconnect grace works (kill ws on A, reopen, A's character resumes). Wired into `pnpm verify:phase-6` + `.github/workflows/deploy-staging.yml` post-deploy step (extends Phase 5 D-11 soak harness). Failure marks staging deploy red; gates merge to `main` (closes CLI-08).
    - **Manual UAT (`06-HUMAN-UAT.md`)** — operator opens 2 Chrome tabs against `staging.rebno.decidel.com`, repeats the Playwright scenario by hand, captures screenshot + screen-recording video. Logged as the milestone artifact ("we shipped CLI-08"). Mirrors Phase 5's HUMAN-UAT.md pattern.

  ### Carried forward from prior phases (locked, not re-decided)

  Every prior `D-*` decision stands. Phase 6 explicitly relies on:
  - **ADR 0001** (`docs/adr/0001-client-engine.md`) — Phaser 3.90 locked. No engine flip.
  - **Phase 4 D-01..D-04** wire/state model (Colyseus Schema state-diff + msgpackr events + zod intents + `PROTOCOL_VERSION` first byte of `c2s.auth`). Phase 6 D-08 amends `PlayerState` to add `input_axes`; protocol bump.
  - **Phase 4 D-05..D-08** auth flow (Better-Auth `/api/auth/sign-in/email` returns `session_token`; client opens `wss://server/colyseus?token=<bearer>`; cookie-backed silent reconnect; in-room force-reset overlay; `legacy_credentials_staging` → `accounts` rehash on first login).
  - **Phase 4 D-10..D-13** room-layout signing + hot-reload (`s2c.room_layout`, Ed25519 verify against `VITE_ROOM_SIGNING_PUBKEY`, hot-swap on file change preserving player state). Phase 6 client implements verify + tilemap rebuild on hot-swap.
  - **Phase 4 D-20** `packages/game-logic` purity (no `Date.now`, no `Math.random`, no I/O). Phase 6 wraps `step()` for client prediction without violating the lint.
  - **Phase 4 D-22** rate limiter (token-bucket per `(account_id, msg_type)`). Phase 6 may relax `input` budget given D-09 event-driven cadence (planner decides).
  - **Phase 5 D-02** domains (`staging.rebno.decidel.com`, `rebno.decidel.com`).
  - **Phase 5 D-04** staging access (Fly proxy IP allowlist + `STAGING_INVITE_TOKEN` middleware). Phase 6 client must accept `?invite=<token>` in WSS connect URL when `import.meta.env.VITE_STAGING_MODE === '1'`.
  - **Phase 5 D-19** Ed25519 keypair lifecycle — client embeds `VITE_ROOM_SIGNING_PUBKEY` extracted from prod machine via `fly ssh` per env at build time.
  - **Phase 2 D-04** `mvp: yes|no` tagging in `docs/extracted-engine/` — Phase 6 cherry-picks `mvp:yes` features only; everything else is Phase 7.

  ### Claude's Discretion

  User answered every surfaced gray area; deferred two specific sub-questions to best judgment (recorded above as decisions):
  - **D-13** asset-pipeline tool home → standalone (`tools/asset-pipeline/`, Phase 1 D-17 pattern preserved).
  - **D-16** manifest format → hybrid (Vite handles hashing, asset-pipeline emits richer pipeline-manifest.json).

  Other discretion items inside selected areas:
  - **D-05** remote-player nameplate form-factor (Phaser canvas text vs DOM overlay) — planner picks during plan time after a quick perf comparison.
  - **D-10** divergence threshold concrete value (~22 px is the recommendation given 44 × 40 tile size; planner may pick 24 / 20 / 16 based on prediction-error histograms from Phase 5 staging soak data).
  - **D-11** extrapolation cap horizon concrete value (~250 ms recommendation; planner may tune given measured RTT distribution).
  - **D-15** `.aseprite` source storage location (committed under `assets/source/sprites/aseprite/` vs gitignored) — planner picks per repo size policy.
  - **D-17** MVP background image — selected at plan time after `tools/room-converter` discovers `mvp-lobby` dimensions.
  - **D-18** static-mount path (`apps/server/public/` vs `apps/server/dist/client/`) — planner picks at plan time.
  - Room-rendering — Phaser `TilemapLayer` is the recommendation; planner may flip to custom blit if `TilemapLayer` proves awkward for the canonical layout JSON shape from Phase 4 D-09.
  - Phase 6 plan **must** coordinate D-07 (server-side spawn semantics) + D-08 (schema expansion) + D-09 (event-driven inputs) into a small set of `apps/server` + `packages/protocol` patches landed before or alongside `apps/client`.

  Override any decision in planning if research or codebase reading surfaces a conflict.

  ### Folded Todos

  None — `gsd-sdk query todo.match-phase 6` returned 0 matches.

  </decisions>

  <canonical_refs>
  ## Canonical References

  **Downstream agents (gsd-phase-researcher, gsd-planner, gsd-pattern-mapper, gsd-executor) MUST read these before planning or implementing.**

  ### Project planning
  - `.planning/PROJECT.md` — Stage 6 Active scope ("MVP gate: two players join the same room, see each other move, exchange chat messages — multiplayer over the new server"); core value statement
  - `.planning/REQUIREMENTS.md` §"Client Rebuild — MVP (Stage 6)" — CLI-01..CLI-09 + AST-01 acceptance text
  - `.planning/ROADMAP.md` §"Phase 6: Client Rebuild — MVP Gate (CLI-08 HARD MILESTONE)" — goal + 6 success criteria + hard-milestone language
  - `.planning/STATE.md` — current position (Phase 05 complete; Phase 06 next)
  - `.planning/research/STACK.md` — Phaser 3.90 + Vite 8 + version pins; Fly.io specifics
  - `.planning/research/FEATURES.md` §"MVP-Critical (Stage 6 gate)" — defines `mvp:yes` cherry-pick scope
  - `.planning/research/ARCHITECTURE.md` — three-pipeline topology; where `apps/client` sits relative to `packages/protocol` + `packages/game-logic`
  - `.planning/research/PITFALLS.md` §B1 (server-authoritative — D-07 anti-cheat foundation), §C1 (HiDPI scale-mode pitfalls)
  - `.planning/codebase/CONCERNS.md` — repo-private-through-Phase-7 reminder; client deploy stays internal until cleared

  ### Prior CONTEXT.md (decisions consumed verbatim)
  - `.planning/phases/02-client-engine-documentation/02-CONTEXT.md` — D-04 `mvp` tagging convention
  - `.planning/phases/04-server-rebuild-mvp/04-CONTEXT.md` — D-01..D-25 (wire/state, auth+Colyseus, room hot-reload, persistence, lints); Phase 6 D-07/D-08/D-09 amend D-14/D-01/D-02 respectively
  - `.planning/phases/05-deploy/05-CONTEXT.md` — D-02 (region/domains), D-04 (staging invite token), D-19 (Ed25519 pubkey extract ritual + per-env Vite bundles)

  ### ADRs (locked decisions — Phase 6 consumes)
  - `docs/adr/0001-client-engine.md` — Phaser 3.90 locked (Phase 6 is where it lands)
  - `docs/adr/0002-persistence-layer.md` — SQLite + Litestream → Tigris (server-side; informs D-07 server-side spawn semantics)
  - `docs/adr/0003-canonical-snapshot.md` — `legacy/servers/enlyzeam-current/` import source (Phase 4 already consumed; Phase 6 has nothing new here)
  - `docs/adr/0004-room-hot-reload.md` — Phase 6 client implements verify + tilemap rebuild on `s2c.room_layout` hot-swap
  - `docs/adr/0005-deploy-topology.md` — two Fly apps + region `lax` (Phase 6 client ships into the same apps per D-18)
  - `docs/adr/0006-observability-stack.md` — OpenObserve self-hosted (Phase 6 client telemetry via OTel browser SDK is **deferred**; Phase 7 / v2 territory)

  ### Phase 1–5 outputs (consumed as input)
  - **Extracted assets:** `extracted/client-5-8/sprites/<sprite_id>/` + `meta.json` — `tools/asset-pipeline bootstrap` source (D-14)
  - `extracted/client-5-8/backgrounds/` — background-image source for D-17
  - `extracted/client-5-8/rooms/` — `tools/room-converter` already consumed for `mvp-lobby` (Phase 4)
  - **Server:** `apps/server/rooms/mvp-lobby/000.{json,sig}` — already seeded (Phase 4 D-12); Phase 6 client renders this layout
  - **Server (extension required):** `apps/server/src/RebnoRoom.{ts,js}` — gains `input_axes` broadcast (D-08); `onJoin` adds home-portal-spawn-on-new-session logic (D-07); `onMessage('input')` switches from per-tick to event-driven storage (D-09)
  - **Protocol:** `packages/protocol/src/state.ts` (or equivalent) — `PlayerState` schema gains `input_axes`; `PROTOCOL_VERSION` bumps (D-08)
  - **Protocol:** `packages/protocol/src/intents.ts` — `c2s.input` zod schema updated to event-driven shape `{seq, axes, buttons_down, buttons_up, monotonic_at_ms}` (D-09)
  - **Game-logic:** `packages/game-logic/src/step.ts` — pure `step(state, inputs, dt)` reused **as-is** for client-side prediction + remote-player extrapolation (D-11). No new game-logic in Phase 6.
  - **Tools:** `tools/asset-catalog/output/index.json` — sprite metadata cross-referenced during D-14 bootstrap step
  - **Tools:** `tools/room-converter/` — informs `mvp-lobby` JSON shape; Phase 6 doesn't call it at runtime
  - **Verification:** `apps/server/test/authority.integ.test.ts` + `apps/server/test/reconnect.integ.test.ts` (Phase 4) — Playwright two-client harness pattern reused (D-19)
  - **Verification:** `scripts/soak-staging.mjs` (Phase 5 D-11) — Phase 6 Playwright smoke extends this harness
  - **CI:** `scripts/verify-phase-5.mjs` + `.github/workflows/deploy-staging.yml` (Phase 5) — Phase 6 mirrors shape for `verify-phase-6.mjs` + adds client-build step

  ### Reverse-engineering wiki + extracted-engine docs
  - `docs/extracted-engine/README.md` — task-keyed jump table for engine subsystems
  - `docs/extracted-engine/MATRIX.md` + `MATRIX-rows.json` — engine choice rationale (informs Phaser feature usage in Phase 6)
  - `docs/extracted-engine/rendering.md` — `mvp:yes` rendering primitives Phase 6 must implement
  - `docs/extracted-engine/input.md` — keyboard event handling on the original (informs Phase 6 input layer)
  - `docs/extracted-engine/scene-room-model.md` — original room model; informs Phaser scene wiring + camera follow
  - `docs/extracted-engine/animation.md` — sprite frame/animation conventions for atlas packing (D-14 sidecar JSON shape)
  - `docs/extracted-engine/ui-and-menus.md` — chat HUD overlay pattern reference for D-04
  - `docs/extracted-engine/asset-catalog/index.json` — sprite cross-reference for AST-01 MVP-scope picks
  - `decomp/wiki/15-extraction-pipeline.md` — context for what `extracted/client-5-8/` carries
  - `decomp/wiki/16-bno-bnb-notes.md` — context only (Phase 7 PAR-05 territory; Phase 6 doesn't touch `.bnu`)

  ### External implementation references
  - Phaser 3.90 docs (`phaser.io/docs/3.90`) — Scene lifecycle, `Scale.FIT` + `autoRound`, `DOMElement`, `TilemapLayer`, `Tweens` for D-10 lerp, `Textures.FilterMode.NEAREST` for D-12
  - Colyseus 0.17 client docs (`docs.colyseus.io/colyseus/getting-started/javascript-client/`) — `Client.joinOrCreate`, room `state.onChange`, message handlers, `client.room.send()`, reconnect tokens
  - Vite 8 docs (`vitejs.dev/guide/`) — env modes, `import.meta.env`, manifest output, asset hashing, multi-env build via `--mode`
  - msgpackr browser bundle (`github.com/kriszyp/msgpackr#browser`) — encoder/decoder reuse on client side
  - zod docs — schema sharing between server (Phase 4) + client (Phase 6 reuses zod intents for type-safe send paths)
  - @colyseus/schema docs — client-side `state.onChange` + `MapSchema.onAdd/onChange/onRemove` patterns for player join/leave/move
  - Better-Auth docs (`better-auth.com`) — client SDK (`createAuthClient`) for `/api/auth/sign-in/email`, `signOut`, `me`, cookie semantics; `SameSite` config
  - Playwright docs (`playwright.dev`) — `@playwright/test` multi-context patterns for D-19 two-client harness
  - sharp docs (`sharp.pixelplumbing.com`) — BMP decode + PNG encode for D-14 bootstrap; lossless settings
  - Aseprite CLI docs (`aseprite.org/docs/cli/`) — canonical export to PNG strip + JSON-array sidecar
  - TexturePacker / `free-tex-packer` / sharp-bin-pack — atlas packing algorithm reference for D-14 build mode
  - HiDPI canvas + integer-scale references (`webgl-fundamentals.org/webgl/lessons/webgl-resizing-the-canvas.html`, Phaser `Scale.Manager`) — D-12

  ### What NOT to read in Phase 6
  - `legacy/servers/{enlyzeam-archive,local-current}/` — non-canonical per ADR 0003
  - `legacy/source-archive/` — older Client revisions, reference only
  - `legacy/unity-project/`, `legacy/maya-project/` — abandoned remake attempts; not authoritative
  - Modern decompilers (UTMT, Altar.NET) — incompatible with GM 5.3a (PITFALLS A1)
  - LiteFS / Postgres docs — explicitly out of scope per ADR 0002
  - Phaser 4 docs — explicitly rejected for MVP per ADR 0001 (Phase 7 retro re-evaluates)
  - PixiJS docs — engine choice locked Phaser 3.90 per ADR 0001

  </canonical_refs>

  <code_context>
  ## Existing Code Insights

  ### Reusable Assets

  - **`packages/game-logic/src/step.ts`** (Phase 4 D-20) — pure `step(state, inputs, dt) → state`; runs in browser unmodified. Phase 6 wraps it twice: (a) for the local player's prediction loop, (b) for remote-player extrapolation when snapshots go stale (D-11). Lint-enforced purity (no `Date.now`, no `Math.random`, no I/O) means it's safe in a Phaser scene update without breaking determinism.
  - **`packages/protocol/src/{state,intents,events}.ts`** (Phase 4) — Colyseus Schema classes, zod intents, msgpackr event types. Phase 6 imports browser-side; bumps `PROTOCOL_VERSION`; extends `PlayerState` (D-08) and `c2s.input` (D-09).
  - **`apps/server/src/RebnoRoom.js`** + `apps/server/src/onMessageHandlers.js` (Phase 4) — already handle `c2s.input`, `c2s.chat_send`, `c2s.heartbeat`, `c2s.room_join`. Phase 6 adds `input_axes` broadcast (D-08), event-driven input semantics (D-09), home-portal-spawn-on-new-session in `onJoin` (D-07).
  - **`apps/server/rooms/mvp-lobby/000.{json,sig}`** — already seeded (Phase 4 D-12). Phase 6 client consumes via `s2c.room_layout`.
  - **`apps/server/src/health.ts`** (Phase 4 D-21) + Phase 5 Express boot — Phase 6 adds `app.use(express.static(...))` mount for the Vite client bundle (D-18).
  - **`apps/server/src/auth-bootstrap.js`** + Better-Auth Express routes (Phase 4 D-05) — Phase 6 client SDK calls `/api/auth/sign-in/email`, `/api/auth/me`, `/api/auth/sign-out`, `/api/auth/change-password` with same-origin cookies.
  - **`extracted/client-5-8/sprites/0000-NaviStandD/` etc.** + `meta.json` — `tools/asset-pipeline bootstrap` source for AST-01 (D-14). 854 sprites total; MVP scope ~10 picks.
  - **`docs/extracted-engine/asset-catalog/index.json`** (Phase 2) — sprite metadata cross-reference; informs MVP-pick decisions (D-17).
  - **`tools/extract-gmd/src/types.ts`** — canonical extraction types reused by `tools/asset-pipeline`.
  - **`scripts/verify-phase-5.mjs`** + `.github/workflows/verify-phase-5.yml` (Phase 5) — Phase 6 mirrors shape for `verify-phase-6.mjs` (composite gate including Playwright two-client smoke + asset-pipeline lint + Vite env lint + trace:check).
  - **`scripts/soak-staging.mjs`** (Phase 5 D-11) — Phase 6 Playwright smoke (D-19) extends harness; reuses staging-deploy infrastructure.

  ### Established Patterns

  - **TS-everywhere** (PROJECT.md). `apps/client` is TypeScript-strict, Vite-built.
  - **pnpm workspace** (Phase 4 D-18). `apps/client` becomes the second `apps/*` workspace member; existing `tools/*` stay standalone (Phase 1 D-17 / Phase 2 D-15 boundary preserved → `tools/asset-pipeline` follows suit per D-13).
  - **Headless, CI-runnable** (Phase 1 D-01) — Phase 6 verification surface (Playwright headless Chromium, asset-pipeline lints, Vite env lint) runs on `ubuntu-latest` per Phase 5 precedent.
  - **Reproducibility-first** (Phase 1 D-15) — `tools/asset-pipeline build` output deterministic (sorted keys in manifest, pinned PNG encoder settings via sharp).
  - **Lint-as-forcing-function** (Phase 2/3/4/5 D-2X pattern) — `lint-asset-pipeline.mjs` (manifest schema + atlas integrity), `lint-vite-env.mjs` (`.env.staging`/`.env.prod` carry required `VITE_*` keys, no secrets), wired into `pnpm verify:phase-6`.
  - **Mvp tagging** (Phase 2 D-04) — Phase 6 cherry-picks `mvp:yes` features only.
  - **Anti-port reference** — Phase 6 ships **none** of the original `Ctrl+E` clipboard RCE / `Ctrl+Q` inspect / `Ctrl+O` codes admin model. Phase 7 PAR-07 fills the modernized admin UI; Phase 6 client has zero admin surface.
  - **ADR-on-first-lock** — Phase 6 produces no new ADR; ADR 0001 already locks the engine, ADR 0004 already locks hot-reload. If D-07/D-08/D-09 prove load-bearing in Phase 7 retro, an ADR captures the contract then.

  ### Integration Points

  - **Inputs:**
    - All prior phases' outputs (workspace + tools + extracted assets + signed-room layout + ADRs + research).
    - Phase 4 server runtime (extended with D-07/D-08/D-09 patches landed in Phase 6 plan).
    - Phase 5 deployed staging + prod Fly apps + per-env domains.
    - `legacy/open-source-release/BN Online Client 5-8.gmd` (already extracted; Phase 6 reads the extracted PNG/BMP outputs only).
  - **Outputs:**
    - `apps/client/` — full Vite + Phaser + TS app (`src/scenes/`, `src/net/`, `src/prediction/`, `src/ui/`, `src/auth/`, `src/assets/`, `src/main.ts`, `index.html`, `vite.config.ts`, `package.json`)
    - `apps/client/.env.staging` + `apps/client/.env.prod`
    - `apps/client/test/cli-08.e2e.test.ts` — Playwright two-client smoke (D-19)
    - `tools/asset-pipeline/` — new standalone Node CLI (D-13/D-14/D-15/D-16)
    - `assets/source/sprites/<sprite_id>.{png,json}` — committed Aseprite-export source files (output of `bootstrap`)
    - `apps/client/dist/` (gitignored) — Vite build artefact; shipped via `apps/server/public/` mount per D-18
    - `apps/server/src/RebnoRoom.js` patch — `input_axes` broadcast (D-08), event-driven input storage (D-09), home-portal `onJoin` (D-07)
    - `packages/protocol/src/state.ts` patch — `PlayerState.input_axes`, `PROTOCOL_VERSION` bump (D-08)
    - `packages/protocol/src/intents.ts` patch — `c2s.input` event-driven shape (D-09)
    - `tools/scripts/lint-asset-pipeline.mjs` + `tools/scripts/lint-vite-env.mjs`
    - `scripts/verify-phase-6.mjs` composite gate
    - `.github/workflows/deploy-staging.yml` extension — client-build step + Playwright smoke
    - `06-HUMAN-UAT.md` — milestone artifact (D-19)
  - **Downstream consumers:**
    - **Phase 7 PAR-01** (full client surface) — extends `apps/client/src/scenes/` with multi-room transitions, friends list, whispers, settings menu, message board reader; reuses `BootScene` + `LoginScene` + `GameScene` skeleton.
    - **Phase 7 PAR-03** (full room set) — uses `tools/room-converter` to populate every room; client tilemap rendering generalises from `mvp-lobby` to N rooms; D-04 hot-reload contract preserved.
    - **Phase 7 AST-02..04** (audio + fonts + content-hashed manifest) — extends `tools/asset-pipeline` per D-13/D-16 hybrid manifest.
    - **Phase 7 PAR-04** (full chat) — extends `apps/client/src/ui/chat/` from D-04 baseline (whispers, channels, ignore/block, profanity wordlist, history rolling buffer).
    - **Phase 7 PAR-05** (`.bnu` per-user character migration) — server-side; client unchanged at the protocol layer because home-portal spawn (D-07) means migrated character location is irrelevant on first login.
    - **Phase 7 PAR-07** (admin UI) — separate web UI; Phase 6 client surface has zero admin affordance.

  </code_context>

  <specifics>
  ## Specific Ideas

  - **CLI-08 is THE go/no-go for the rebuild** (ROADMAP.md). Phase 6 plan writes the Playwright smoke FIRST (TDD shape), then implements `apps/client` against it; CI gate prevents regression while the rest of Phase 6 builds out (D-19).
  - **Minecraft-pattern chat is the explicit user lock** (D-04). Enter or T toggles non-modal overlay; movement keys freeze on chat-mode; nameplate text always-on. Planner must NOT silently swap to a modal-popup chat UI.
  - **Anti-cheat via authoritative server is sufficient** (D-07). Server is authoritative; client never claims position. Reconnect "accept new location securely" = server's last-known x/y for the same session; new session = home portal regardless of client state. No behavioural anti-cheat in v1 (out of scope per PROJECT.md).
  - **Exploration is the progression model** (D-07). Cross-session location is intentionally NOT persisted — travel itself matters. This is the explicit user steer; Phase 4 D-14 (`characters.x/y` persisted on graceful-disconnect) is amended for Phase 6+.
  - **Schema expansion is acceptable Phase 6 cost** (D-08). Adding `PlayerState.input_axes` + bumping `PROTOCOL_VERSION` is preferred over client-only collision-clipped extrapolation, because it lets `packages/game-logic` `step()` drive remote-player extrapolation deterministically against the local signed-room layout. Coordinated server-before-client deploy ritual (Phase 4 D-03).
  - **Event-driven inputs** (D-09) — client sends on keydown/keyup transitions; server holds last-known axis vector. Saves bandwidth, simplifies server tick. Planner must add a heartbeat-coupled "still-pressed" reaffirmation message (every 15 s) to survive dropped keyup packets.
  - **Threshold-gated reconciliation with concrete tile size** (D-10) — tiles are 44 × 40 px; threshold candidate ~22 px (≈ half-tile). Planner picks final number from prediction-error histograms collected during the Phase 5 30-min staging soak (D-11). Invisible micro-corrections, no rubber-banding on big corrections.
  - **Aseprite-friendly asset source** (D-15) — devs edit `.aseprite` source files locally; pipeline reads PNG-strip + JSON-array exports (Aseprite's canonical CLI output). Keeps the 30+ year roadmap of "human dev edits a sprite" cheap.
  - **Same-Fly-app hosting** (D-18) — couples client + server deploy intentionally. MVP simplicity wins; CORS-free; cookies stay `SameSite=Strict`. Revisit at v2 if independent client release cadence becomes valuable.
  - **Playwright merge-gate + manual UAT milestone** (D-19) — CI keeps regression risk near zero; manual UAT provides the human "we shipped CLI-08" artifact + screen-recording video for posterity.
  - **Phaser 4 retro window** (ADR 0001) — Phase 6 ships on Phaser 3.90; Phase 7 retro re-evaluates Phaser 4. Phase 6 plan must not silently jump to Phaser 4.

  </specifics>

  <deferred>
  ## Deferred Ideas

  - **AST-02 (MIDI→OGG via ffmpeg + vintage soundfont) + AST-03 (BMP fonts → WOFF2) + AST-04 (full content-hashed manifest)** — Phase 7. Phase 6 ships silent (no audio in `extracted/client-5-8/` per ADR 0001) + uses Phaser default font. Hybrid manifest (D-16) is shaped to extend cleanly.
  - **Multi-room transitions, friends list + presence, whispers, account recovery, account-level settings** — Phase 7 PAR-04 / PAR-06.
  - **Full chat surface** (channels, ignore/block, profanity wordlist, history rolling buffer, whispers) — Phase 7 PAR-04. Phase 6 ships only `chat_send`/`chat_msg` broadcast on top of the D-04 HUD.
  - **`.bnu` per-user transactional character migration** — Phase 7 PAR-05.
  - **Modernized admin web UI** — Phase 7 PAR-07. Phase 6 client surface has zero admin affordance.
  - **Pre-game lobby, character creation, settings menu, message-board reader** — Phase 7.
  - **Mobile / native ports** — out of scope per PROJECT.md.
  - **OTel browser SDK / client-side telemetry to OpenObserve** — v2 / Phase 7. Phase 6 logs to console only; server-side OTel (Phase 5 D-12..D-16) covers the wire side.
  - **Independent client deploy cadence (separate Fly app for static client)** — v2; D-18 same-Fly-app coupling is acceptable at MVP scope.
  - **Behavioural anti-cheat** — out of scope per PROJECT.md. Authoritative server (D-07) is sufficient for v1.
  - **Pixel-diff verification of MVP room against original screenshots** — Phase 7 PAR-03. Phase 6 ships `mvp-lobby` only; pixel-perfect parity against original is Phase 7 territory.
  - **OAuth/passkey providers, public username changes** — Phase 7 PAR-06 + v2.
  - **Phaser 4 migration** — gated on ADR 0001 retro window (post-CLI-08, pre-Phase-7-PR-01).
  - **Independent input cadence rate-limit budget reduction** — D-09 makes Phase 4 D-22's 25/s burst 35 input budget overkill; planner may relax during Phase 6 plan if it doesn't add friction.

  ### Reviewed Todos (not folded)

  None — `gsd-sdk query todo.match-phase 6` returned 0 matches.

  </deferred>

  ---

  # Gap-Closure Addendum (2026-05-10)

  **Trigger:** 06-HUMAN-UAT.md executed by operator decidel 2026-05-10. All 5 tests block CLI-08 closure. Per user decision, all five gap-closure items scoped to **Phase 6** — no Phase 7 advance until CLI-08 truly green.

  **See:** `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md ## Findings` for finding details + sequencing recommendation.

  <gap_closure_decisions>

  ### Finding #1 — Login screen text invisible (high)

  - **D-23 (small CSS fix; one plan):** LoginScene CSS enforces UI-SPEC color tokens — title + field labels switch from `#000` → `#FFFFFF` (or token foreground). No design discussion needed; UI-SPEC §LoginScene already locks tokens. Add a Playwright contrast assertion (axe-core or simple computed-style check) to prevent regression.

  ### Finding #5 — No logout affordance (medium)

  - **D-24 (Esc menu — also fires on game-view click):** Add an in-GameScene **Esc menu** modal overlay (BNO-faithful command-screen UX feel). Menu options: **Logout** (clears Better-Auth session cookie + returns to LoginScene), **Resume** (closes overlay), **Settings** (placeholder, deferred to Phase 7). Trigger conditions: (a) operator presses Esc, (b) operator clicks the game canvas (single click). Movement keys frozen while overlay open. Logout flow: `POST /api/auth/sign-out` → clear cookie → `LoginScene.start()` (do NOT preserve invite token; user re-enters via fresh `?invite=` URL or saved bookmark). One plan.

  ### Finding #2 — Reconnect / cookie auto-login renders blank GameScene (critical)

  - **D-25 (investigate via /gsd-debug FIRST, then plan from findings):** Root cause unknown. Hypothesis space large (HUD mount idempotency, scene re-entry init, Phaser DOMElement re-create, Better-Auth cookie path bypassing BootScene → GameScene init). Run `/gsd-debug` session to isolate before writing a fix plan. Output: a debug summary doc that the next plan consumes verbatim. Fix plan ships with two new Playwright e2e tests added to 06-08's `cli-08.e2e.test.ts` family: (a) cookie-auto-login → GameScene renders HUD + remotes + self, (b) WS-kill → reconnect → GameScene re-renders HUD + remotes + self.

  ### Finding #3 — Movement does not match BNO (critical)

  - **D-26 (split into TWO plans; numerics first, sprite-state after #4):**
    - **Plan A (no asset dep):** Reverse numeric movement constants from extracted Client 5-8 GML and pin into `packages/game-logic`. Files to read (canonical refs below): `extracted/client-5-8/scripts/0285-player_update.gml`, `extracted/client-5-8/scripts/0347-iv_keyactions.gml`, `extracted/client-5-8/scripts/0355-pcode_mover.gml`, **plus the player object's GML events at `extracted/client-5-8/objects/0000-server/`** (⚠ in original BNO the player character object is named `server` — this is the canonical mover, NOT `0042-player` or any `navi*` script). Derive: walk speed (px/tick), key-buffering window, friction/accel if any, sub-pixel accumulator behavior, tick rate assumption. Update `PredictionEngine` + `step()` to use these constants. Add unit tests pinning the derived values (regression guard for any future tweak). **No asset dependency** — runs alongside #4 in parallel waves.
    - **Plan B (depends on Plan A + Finding #4):** 8-direction sprite-state machine — facing direction (D/U/L/R/DR/DL/UR/UL) + walk-cycle phase derived from velocity vector + cycle counter. Maps onto the player sprite atlases that #4 lands. Frame-perfect to BNO (consult `0000-server` events for facing-on-input semantics). Sequenced AFTER Plan A (numerics) AND #4 (atlases land).

  ### Finding #4 — No real BNO assets — first-pass (critical)

  - **D-27 (player sprites + 1 room = BNCentral):** First-pass real assets =
    - **Player sprite set:** all 8-direction Stand + Run sprites — `extracted/client-5-8/sprites/0027-NaviRunD/`, `0028-NaviRunR/` *(R-facing Run; corrects original enumeration omission flagged by plan-checker 2026-05-10)*, `0029-NaviStandR/`, `0030-NaviStandU/`, `0031-NaviRunU/`, `0032-NaviRunL/`, `0033-NaviStandL/`, `0036-NaviStandDR/`, `0037-NaviRunDR/`, `0038-NaviStandUR/`, `0039-NaviRunUR/`, `0040-NaviStandUL/`, `0041-NaviRunUL/`, `0042-NaviStandDL/`, `0043-NaviRunDL/`, plus the existing `0000-NaviStandD/` (already on disk). Total: 16 sprites (1 already-on-disk + 15 new). Bridge through the existing `tools/asset-pipeline/` plumbing (06-08 built the lint + manifest schema; never piped real input). Add a `bootstrap` mode (per existing CONTEXT D-13 reference) that reads `extracted/client-5-8/sprites/<id>/frames/img_*.bmp` + `meta.json` → emits PNG strips + Aseprite-compatible JSON sidecars at `assets/source/sprites/<id>.{png,json}`. Then `build` mode packs into `atlas-mvp.{png,json}`. Phaser loads via existing PlayerRenderer (replaces blue rectangle).
    - **Room:** `extracted/client-5-8/rooms/0058-BNCentral/` — **the actual latest online lobby iteration** in original BNO (NOT `0004-Online_Lobby`, which is older). Bridge `instances.json` + `tiles.json` + `creation-code.gml` + `backgrounds.json` into a server-emittable room-layout JSON consumable by `apps/server/rooms/<room>/000.json` and the Phase 4 SRV-13 contract + Phase 6 D-12 hot-reload protocol. Replaces the synthetic `mvp-lobby` room used in 06-07. Tile sprites needed for BNCentral come along with the bootstrap (the room's `tiles.json` references sprite IDs already covered by extracted/sprites/).
    - **Replaces** the `mvp-lobby` placeholder. Server room registration updated to use `bn-central` (or planner's preferred slug); Playwright e2e (06-08 + new D-25 tests) updated to assert `data-room-id="bn-central"`.
    - **Nametag (user-supplied 2026-05-10):** original BNO renders player display name as a cyan text label always-on above the sprite head. First-pass: cyan (`#22D3EE` accent already in UI-SPEC, or exact BNO cyan if extractable from `0000-server` draw events / `scr_draw*` text-color calls — verify before locking). Anchored to sprite top-center, follows movement, visible for self + remotes. Belongs to player-render plan (06-16 sprite-state Plan B is the natural home since it owns the per-player render path), not a separate plan. Confirm exact font/size/outline against extracted draw code before commit.
    - First-pass scope deliberately excludes audio (Phase 7 AST-02), fonts (Phase 7 AST-03), and the other 9 extracted rooms (Phase 7 PAR-03).

  ### Sequencing (advisory — locked at /gsd-plan-phase 6 time)

  | Wave | Plan | Finding | Depends on |
  |------|------|---------|------------|
  | 8 | 06-10: LoginScene contrast fix + Playwright assertion | #1 | — |
  | 8 | 06-11: Esc menu logout (incl. game-view click) | #5 | — |
  | 9 | 06-12: /gsd-debug session for reconnect/cookie blank-render | #2 | — |
  | 9 | 06-13: Reverse movement numeric constants from extracted GML (Plan A) | #3a | — |
  | 10 | 06-14: Asset-pipeline bootstrap → atlas-mvp + BNCentral room layout | #4 | — |
  | 11 | 06-15: Reconnect/cookie blank-render fix + 2 e2e tests + stale-dispatcher-room fix | #2 | 06-12 (debug findings) |
  | 11 | 06-16: 8-direction sprite-state machine (Plan B) | #3b | 06-13 + 06-14 |
  | 12 | 06-17: Re-run 06-HUMAN-UAT.md, capture real `06-CLI-08-milestone.mp4` | all | 06-10..06-16 |

  Wave 8 and 9 plans run in parallel within their wave (no shared files). Wave 10 (06-14) sequential — touches asset pipeline + server room registration. Wave 11 plans parallel within wave (different file sets). Wave 12 sequential (final UAT + close-out).

  ### Plan-tagging guidance for /gsd-plan-phase 6

  - 06-10 → `[doc->REQ-CDOC-*]` + `[impl/unit->REQ-CLI-06]` (visual/contrast under HiDPI umbrella) — or expand REQ list if a fresh contrast REQ-ID added.
  - 06-11 → `[impl->REQ-CLI-02]` + `[impl->REQ-CLI-03]` (logout is auth lifecycle).
  - 06-12 + 06-15 → `[impl/int->REQ-CLI-09]` (reconnect grace) + `[impl->REQ-CLI-04]` (re-entry render). 06-15 also folds in the secondary defect surfaced by `/gsd-debug` (`GameScene.ts:238-252` `if (!this.inputDispatcher)` guard keeps stale `room` reference after silent_reauth; movement intents would `room.send` through closed old room). Fix: refresh dispatcher's room ref on every reconnect, not only on first connect. Add unit test: dispatcher `.room` matches `GameScene.room` after silent_reauth cycle.
  - 06-13 + 06-16 → `[impl/unit->REQ-CLI-04]` (movement prediction; numerics + sprite-state).
  - 06-14 → `[impl->REQ-AST-01]` + `[impl->REQ-CLI-07]` (room rendering); evaluate whether AST-02..04 stay Phase 7 or partially absorb here (planner call).
  - 06-17 → `[doc->REQ-CLI-08]` (final milestone artifact).

  </gap_closure_decisions>

  <canonical_refs_addendum>

  ### Added 2026-05-10 (gap-closure)

  - `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md` — operator UAT findings; **MUST read** before planning gap-closure.
  - `extracted/client-5-8/objects/0000-server/` — original BNO player character object (named `server`, NOT `player`); contains the canonical mover events for Finding #3.
  - `extracted/client-5-8/scripts/0285-player_update.gml` — player tick/update logic.
  - `extracted/client-5-8/scripts/0347-iv_keyactions.gml` — input-vector keyactions mapping.
  - `extracted/client-5-8/scripts/0355-pcode_mover.gml` — mover pcode (per-tick movement application).
  - `extracted/client-5-8/rooms/0058-BNCentral/` — first-pass room (the actual latest online lobby iteration, replaces synthetic `mvp-lobby`).
  - `extracted/client-5-8/sprites/0000-NaviStandD/` through `extracted/client-5-8/sprites/0043-NaviRunDL/` — full 8-direction Stand+Run player sprite atlases (BMP frames + meta.json) + tiles + doors needed for BNCentral.
  - `tools/asset-pipeline/` (existing 06-08 plumbing) — bootstrap mode addition needed; `build` mode already shipped.

  </canonical_refs_addendum>

  <gotchas_addendum>

  ### Added 2026-05-10 (gap-closure)

  - **Player object naming:** BNO names the player character object `server` (`extracted/client-5-8/objects/0000-server/`), not `player`. `0042-player` is something else (likely lobby/session-related). Movement-fidelity work MUST consult `0000-server` events.
  - **Room slug:** original BNO uses `BNCentral` (room id 58); the room `0004-Online_Lobby` (id 4) is older. Use `0058-BNCentral` for first-pass real-asset work.
  - **Asset pipeline plumbing exists:** 06-08 shipped `tools/asset-pipeline/` (lint + manifest schema + build mode) but never piped real input. Don't rebuild — extend with `bootstrap` mode that consumes `extracted/client-5-8/sprites/<id>/frames/*.bmp`.
  - **`mvp-lobby` was synthetic:** 06-07 GameScene + Playwright assertions reference `mvp-lobby` room id. Updating to `bn-central` requires coordinated changes in `apps/server/rooms/`, `apps/client/src/scenes/`, and 06-08's `cli-08.e2e.test.ts` selectors.

  </gotchas_addendum>

  ---

  *Gap-closure addendum captured: 2026-05-10*
  *Operator: decidel*
  *Next step: `/gsd-plan-phase 6` to scaffold plans 06-10..06-17 per sequencing table above.*
  ```

  ## HUMAN-UAT.md (defects driving gap-closure)
  ```markdown
  ---
  phase: 6
  slug: client-rebuild-mvp-gate-cli-08-hard-milestone
  status: failed
  last_updated: 2026-05-10
  operator: decidel
  operator_run: 2026-05-10
  ---

  # Phase 6 — Human UAT (CLI-08 Milestone Artifact)

  > Five manual verifications driven by `06-VALIDATION.md §Manual-Only Verifications`. Test 2 (CLI-08) is THE milestone artifact for the rebuild — Capcom-derived BNO has had ~25 years between original and rebuild; this video is the proof.

  [doc->REQ-CLI-01] [doc->REQ-CLI-02] [doc->REQ-CLI-06] [doc->REQ-CLI-08] [doc->REQ-CLI-09] [doc->REQ-AST-01]

  > **2026-05-10 result:** ALL FIVE TESTS BLOCK CLI-08 CLOSURE. Networking + chat protocol works; UI/asset/UX layers fall short of "log in with their migrated account, walk around, and chat — bug-free" (CLAUDE.md core value). Five findings documented in `## Findings` below; per user decision (2026-05-10), all five gap-closure items are scoped to Phase 6 (no Phase 7 advance until CLI-08 truly green).

  ## Test 1 — HiDPI nearest-neighbor + integer scale (REQ-CLI-06)

  **Why manual:** Pixel-correctness on real Retina hardware not reproducible in headless Chromium at varying DPI matrices.

  **Instructions:**
  1. Open `https://staging.rebno.decidel.com/?invite=$STAGING_INVITE_TOKEN` on a 4K Retina display
  2. Open the same on a 1080p external monitor
  3. Open the same on a Windows machine with display scaling at 125% AND at 150%
  4. Log in, transition to GameScene
  5. For each environment, take a 4× zoom-in screenshot of the player sprite (NaviStandD)
  6. Visually inspect: pixel edges must be crisp, no anti-aliasing or sub-pixel blur

  **Acceptance:**
  - All sprites render with crisp pixel edges
  - No fractional-scale blur on Retina or Windows 125%/150%
  - Canvas snaps to integer scale on `window.resize`

  **Operator Notes:** HiDPI scale check itself was not exercised because of two upstream blockers surfaced before per-DPI sweeps were possible:
  - (a) Login screen text is unreadable — black title + black field labels on near-black `#0A0E1A` surface (see `uat-test-1-1.png`). Cannot read prompts in any DPI environment. **Finding #1.**
  - (b) GameScene renders only blue rectangles + nameplates — no real BNO assets piped through (see `uat-test-1-2.mp4`). Pixel-correctness on rectangles is meaningless; the test is for sprites. **Finding #4.**

  **Screenshots / Evidence:**
  - `uat-test-1-1.png` — login screen on 1080p Chrome (Windows 125% scaling), black-on-near-black title + labels
  - `uat-test-1-2.mp4` — two-window GameScene side-by-side showing blue-rectangle players (uat_a + uat_b) over empty black background

  **Status:** ⬛ FAIL — blocked by Findings #1 + #4. Re-run after gap-closure plans land real assets + readable login.

  ---

  ## Test 2 — CLI-08 MILESTONE: Two-player MVP gate (REQ-CLI-08)

  **Why manual:** The "we shipped CLI-08" milestone artifact requires human capture per CONTEXT D-19. Playwright provides regression coverage; this is the human deliverable.

  **Instructions:**
  1. Operator opens 2 Chrome browser windows (or two physical machines) against `https://staging.rebno.decidel.com/?invite=$STAGING_INVITE_TOKEN`
  2. Sign in as `uat_a` in window 1 and `uat_b` in window 2 (per psyche carry-over; original doc said `alice`/`bob` — those accounts do not exist in seeded staging DB; **doc bug, fixed in this revision**)
  3. Wait for both clients to enter `mvp-lobby` GameScene (`data-game-ready=true`)
  4. Start a screen recording covering BOTH browser windows (OBS / Loom / built-in screen recorder)
  5. In window 1 (uat_a): walk toward uat_b using WASD (~5 seconds of movement)
  6. In window 2 (uat_b): walk toward uat_a
  7. Confirm visually: each window shows BOTH player sprites + nameplates moving smoothly
  8. In window 1 (uat_a): press Enter, type `hello uat_b`, press Enter
  9. Confirm: window 2 shows `uat_a: hello uat_b` in chat HUD within 2 seconds
  10. In window 2 (uat_b): press Enter, type `hi uat_a`, press Enter
  11. Confirm: window 1 shows `uat_a: hi uat_a`
  12. Stop the recording
  13. Save the video as `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CLI-08-milestone.mp4`

  **Acceptance:**
  - Both clients see each other's sprite + nameplate move
  - Bidirectional chat works in real time
  - Video file exists and is committed (or externally linked)
  - Operator types "approved — CLI-08 shipped" in the resume signal

  **Operator Notes:** Networking + protocol layer works on the fresh-login path. Both clients see each other move; chat lands bidirectionally. BUT the full milestone fails on three counts:
  - (a) Movement does not match BNO (no friction/accel/sub-pixel feel reproduced from extracted GML — uses arbitrary client values). **Finding #3.**
  - (b) Visuals are blue rectangles + cyan nameplates only — no NaviStandD sprite, no room/tile background, no walls. **Finding #4.**
  - (c) Reconnect / cookie auto-login renders only the canvas frame — chat HUD missing, game content empty. Re-entry path is broken; only first-fresh-login renders correctly. **Finding #2.**

  The fresh-login two-player movement+chat slice was captured as `uat-test-1-2.mp4` (operator labeled it as Test 1 evidence; serves dual purpose). This is **NOT** the CLI-08 milestone video — that requires #2/#3/#4 fixed first.

  **Video:** `uat-test-1-2.mp4` (interim — fresh-login slice only, blue rectangles, no rooms). Final `06-CLI-08-milestone.mp4` blocked on Findings #2/#3/#4.

  **Status:** ⬛ FAIL — partial (networking + protocol confirmed working; presentation + reconnect path not). Re-run after Findings #2/#3/#4 close.

  ---

  ## Test 3 — Asset-pipeline Aseprite round-trip (REQ-AST-01)

  **Why manual:** `.aseprite` editor is a desktop app; export-and-rebuild is a human ergonomic check, not a CI assertion.

  **Instructions:**
  1. Open `assets/source/sprites/aseprite/0000-NaviStandD.aseprite` in Aseprite (skip if .aseprite source files were not committed per D-15 discretion)
  2. Modify a single pixel in frame 0 (any visible change)
  3. Export via Aseprite CLI:
     `aseprite -b 0000-NaviStandD.aseprite --sheet ../../source/sprites/0000-NaviStandD.png --data ../../source/sprites/0000-NaviStandD.json --format json-array`
  4. Run `pnpm asset-pipeline:build`
  5. Run `pnpm --filter @rebno/client build:staging`
  6. Reload `http://localhost:5173` (or staging) — confirm the modified pixel is visible
  7. Revert the change

  **Acceptance:**
  - Aseprite export to PNG strip + JSON sidecar succeeds
  - `asset-pipeline:build` re-packs the atlas without errors
  - Client renders the modified sprite

  **Operator Notes:** Cannot exercise — there are no real BNO assets in the project to edit. `assets/source/sprites/aseprite/0000-NaviStandD.aseprite` does not exist; no rooms, no tiles, no sprites are piped through the asset pipeline. The pipeline plumbing (lint:asset-pipeline + asset-pipeline:build/postprocess) was built in 06-08 but never fed real input. **Finding #4.**

  **Status:** ⬛ BLOCKED — by Finding #4. Re-run once first-pass real assets land (NaviStandD + at least one room/tile set extracted from BNO source per CLAUDE.md hard rule #6: extract → document → rewrite).

  ---

  ## Test 4 — Browser password manager + autofill (REQ-CLI-02)

  **Why manual:** Password manager integrations (1Password, Chrome built-in) are not headless-driveable.

  **Instructions:**
  1. Open `https://staging.rebno.decidel.com/?invite=$STAGING_INVITE_TOKEN` in Chrome with built-in password manager enabled (no extensions for first run)
  2. Sign in with a fresh test account (not previously saved)
  3. Confirm: Chrome prompts "Save password?" — accept
  4. Sign out
  5. Reload the page
  6. Confirm: Chrome auto-fills the password field
  7. Repeat with 1Password browser extension if available

  **Acceptance:**
  - Chrome built-in password save prompt appears
  - Autofill works on subsequent visits
  - (Optional) 1Password extension recognises the form
  - `autocomplete="username"` and `autocomplete="current-password"` attributes verified via DevTools (UI-SPEC §Login screen)

  **Operator Notes:** Cannot exercise — there is no logout affordance in the client. Once cookies are set, the LoginScene auto-bypasses to GameScene. The "Sign out" step in the instructions cannot be performed. Need a logout button (or `/logout` route + cookie/session clear) before this test is runnable. **Finding #5.**

  **Status:** ⬛ BLOCKED — by Finding #5. Re-run once logout affordance exists.

  ---

  ## Test 5 — Reconnect grace banner UX (REQ-CLI-09)

  **Why manual:** Banner timing + visual prominence is a subjective UX call beyond "did it reconnect".

  **Instructions:**
  1. Log in to staging; transition to GameScene
  2. Open Chrome DevTools → Console
  3. Run: `(window).colyseus?.connection?.transport?.ws?.close?.() ?? window.__rebno?.room?.connection?.close?.()`
  4. Confirm: amber banner appears within 1 s with text `Reconnecting…`
  5. Wait for auto-reconnect (within 10 s grace per Phase 4 D-22)
  6. Confirm: banner shows `Reconnected.` (transient flash, ~1 s) then disappears
  7. Confirm: player position is preserved (within-grace reconnect path)
  8. Repeat: kill WS + wait > 15 s (forces past grace) → banner should show `Disconnected — click to retry` (destructive red); click → reconnect succeeds → home portal spawn (per D-07 cross-session reset)

  **Acceptance:**
  - Banner appears within 1 s of WS close
  - Banner clears within 1 s of reconnect
  - Reconnect within grace preserves position
  - Reconnect past grace spawns at home portal (D-07)
  - Visual transitions feel smooth (no jank)

  **Operator Notes:** Networking layer reconnects (WS comes back), but post-reconnect the client renders only the canvas frame — no chat HUD, no remote players, no own player visible. Same root cause as Finding #2 (re-entry to GameScene does not re-mount HUD/renderers; init only fires on first fresh-login from LoginScene). Banner timing was not separately graded because the rendered content behind the banner is empty. **Finding #2.**

  **Status:** ⬛ FAIL — by Finding #2. Re-run after re-entry/reconnect path correctly re-mounts game scene.

  ---

  ## Findings (block CLI-08 closure)

  Per user decision 2026-05-10: all five findings scoped to **Phase 6** as gap-closure plans. No Phase 7 advance until CLI-08 truly green.

  | # | Finding | Severity | Suggested gap-closure |
  |---|---------|----------|----------------------|
  | 1 | Login screen text invisible — black title + labels on `#0A0E1A` surface | high (UX blocker) | One LoginScene CSS plan: enforce UI-SPEC color tokens (foreground white/cyan); add visual regression test |
  | 2 | Reconnect / cookie auto-login renders blank GameScene (canvas frame only, no HUD, no remotes, no self) | **critical (regression)** | Plan to fix re-entry init: HUD mount + renderer wiring must idempotently re-fire on every GameScene enter, not only first transition from LoginScene. Add Playwright e2e covering cookie-auto-login + WS-kill-then-reconnect rendering paths (current 06-08 e2e only covers fresh login) |
  | 3 | Movement doesn't match BNO — no friction/accel/sub-pixel feel from extracted client GML | **critical (scope/identity)** | Plan to derive PredictionEngine constants from extracted Client 5-8 GML (`player_step`, `friction`, speed values, key-buffering) per CLAUDE.md hard rule #5/#6. Update game-logic + add unit tests pinning the derived values |
  | 4 | No real BNO assets — blue rectangles only; no rooms, tiles, sprites, atlas content | **critical (scope/identity)** | Plan(s) to extract first-pass assets from BNO source: at minimum NaviStandD sprite atlas + one room (tiles + collision + spawn points). Pipe through existing asset-pipeline:build → atlas → Phaser texture. Tag REQ-AST-01..04 + REQ-PAR-* as appropriate |
  | 5 | No logout — sticky cookie auto-login blocks Test 4 + general UX | medium | Plan to add logout button to GameScene (top-right or Esc menu); cleanly clear Better-Auth session cookie + return to LoginScene |

  **Sequencing recommendation (advisory, lock in `/gsd-discuss-phase 6 --gaps` if used):**
  - Wave 8: #1 (login contrast — small, no deps)
  - Wave 9: #5 (logout button — small, unblocks Test 4)
  - Wave 10: #2 (re-entry render fix — critical regression, must precede final UAT)
  - Wave 11: #3 (movement fidelity from extracted GML — needs Phase 1 extraction artifacts)
  - Wave 12: #4 (first-pass assets from BNO source — needs Phase 1 extraction artifacts; largest)
  - Wave 13: re-run 06-HUMAN-UAT.md, capture final `06-CLI-08-milestone.mp4`

  ## Sign-Off

  | Test | Requirement | Status |
  |------|-------------|--------|
  | 1 — HiDPI | REQ-CLI-06 | ⬛ FAIL (blocked by #1, #4) |
  | 2 — CLI-08 milestone | REQ-CLI-08 | ⬛ FAIL (partial: networking ok; #2 + #3 + #4 fail) |
  | 3 — Aseprite round-trip | REQ-AST-01 | ⬛ BLOCKED (#4) |
  | 4 — Password manager | REQ-CLI-02 | ⬛ BLOCKED (#5) |
  | 5 — Reconnect banner | REQ-CLI-09 | ⬛ FAIL (#2) |

  **Operator approval:** decidel, 2026-05-10 — **NOT APPROVED.** All five tests block CLI-08 milestone closure. Five gap-closure plans required in Phase 6 before re-running UAT.

  **Phase 6 status:** IN_PROGRESS — UAT failed; gap-closure plans scoped (Findings #1–#5).
  ```

  ## Gap-Closure Plans

  ### Plan 06-10
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 10
  type: execute
  wave: 8
  depends_on: []
  files_modified:
    - apps/client/public/forms/login.html
    - apps/client/src/scenes/LoginScene.ts
    - apps/client/test/e2e/login-contrast.e2e.test.ts
    - apps/client/test/e2e/fixtures.ts
  autonomous: true
  requirements: [CLI-06, CLI-02]
  must_haves:
    truths:
      - "Login form heading + Username/Password labels + input text + CTA are readable on the #0A0E1A LoginScene background — measured contrast ratio ≥ 4.5:1 (WCAG AA normal text) for every UI-SPEC §LoginScene element"
      - "Computed-style colors match UI-SPEC §Color tokens exactly — heading + labels render #F3F4F6 (text-on-dominant), CTA renders #0A0E1A on #22D3EE (text-on-accent), error text renders #EF4444"
      - "Playwright contrast assertion fires on every CI run; any future regression that drops a token below 4.5:1 fails the e2e suite (BLOCKER guard against UAT Finding #1 recurrence)"
      - "Operator opening the staging URL on Windows 125% scaling can READ the form prompts (Finding #1 reproducer no longer reproduces)"
    artifacts:
      - path: apps/client/public/forms/login.html
        provides: "Login form HTML with explicit color tokens applied to title, labels, inputs, CTA, error region — no relying on UA defaults that produced the black-on-near-black UAT failure"
        contains: "color: #F3F4F6"
      - path: apps/client/src/scenes/LoginScene.ts
        provides: "Phaser-side LoginScene wires color tokens for any in-canvas backdrop text (Reconnecting-as-username spinner) so backdrop + DOM form share the contract"
        contains: "#F3F4F6"
      - path: apps/client/test/e2e/login-contrast.e2e.test.ts
        provides: "Playwright e2e — visits LoginScene, queries getComputedStyle for every UI-SPEC §LoginScene element, asserts foreground/background contrast ratio ≥ 4.5:1 via WCAG luminance formula"
        contains: "4.5"
    key_links:
      - from: apps/client/public/forms/login.html
        to: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md §Color
        via: "every color: declaration in the form references a token from the UI-SPEC table (#0A0E1A, #1F2937, #22D3EE, #EF4444, #F3F4F6) — no ad-hoc colors"
        pattern: "#(0A0E1A|1F2937|22D3EE|EF4444|F3F4F6)"
      - from: apps/client/test/e2e/login-contrast.e2e.test.ts
        to: apps/client/public/forms/login.html
        via: "page.goto(baseURL/forms/login.html) → page.evaluate(getComputedStyle) → contrast assertion"
        pattern: "getComputedStyle"
      - from: apps/client/playwright.config.ts (existing)
        to: apps/client/test/e2e/login-contrast.e2e.test.ts
        via: "Playwright auto-discovers *.e2e.test.ts under apps/client/test/e2e/; new test runs alongside cli-08.e2e.test.ts"
        pattern: "test/e2e"
  ---

  <objective>
  Close UAT Finding #1 (D-23): the login screen renders black-on-near-black text against the `#0A0E1A` background, making the form unreadable on every DPI environment the operator tested. Repaint LoginScene's DOM form with the UI-SPEC §Color tokens (foreground `#F3F4F6` on dominant `#0A0E1A`, accent `#22D3EE` for CTA), and add a Playwright contrast-ratio assertion as a regression guard so any future CSS drift below 4.5:1 fails CI before it reaches UAT.

  Purpose: per CONTEXT D-23 — UI-SPEC §LoginScene already locks the design tokens; this plan enforces them in code and pins them with an automated check. No design discussion or new tokens. CSS-only intervention plus one regression test.

  Output: a repainted login form (HTML + matching Phaser-side color references), a new Playwright e2e file that asserts WCAG AA contrast on every UI-SPEC §LoginScene element, and a green `pnpm --filter @rebno/client test:e2e` run including the new file. Per gap-closure addendum line 359 tag guidance — `[impl/unit->REQ-CLI-06]` (HiDPI / visual umbrella) plus `[doc->REQ-CLI-02]` (login lifecycle) on the markdown.
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-PATTERNS.md
  @apps/client/public/forms/login.html
  @apps/client/src/scenes/LoginScene.ts
  @apps/client/test/e2e/cli-08.e2e.test.ts
  @apps/client/test/e2e/fixtures.ts
  @apps/client/playwright.config.ts

  <interfaces>
  <!-- UI-SPEC §Color tokens (06-UI-SPEC.md lines 87-96) — locked. -->
  <!-- Every CSS color declaration in the login form MUST reference one of these. -->

  Dominant (60%):  #0A0E1A   (page + form background)
  Secondary (30%): #1F2937   (input field background, divider lines)
  Accent (10%):    #22D3EE   (CTA button background, focused-input border)
  Destructive:     #EF4444   (inline error text)
  Text on dominant:#F3F4F6   (body / label / heading text on #0A0E1A)
  Text on accent:  #0A0E1A   (text on the #22D3EE CTA)

  <!-- Typography (UI-SPEC §Typography lines 67-75): -->
  Body / label:    16px / 400 / line-height 1.5 / monospace stack
  Heading:         24px / 600 / line-height 1.2 / monospace stack
  Display (Phaser spinner): 28px / 600 / line-height 1.2 / monospace stack
  Font stack:      ui-monospace, "SF Mono", Menlo, Consolas, monospace

  <!-- Copy contract (UI-SPEC §Copywriting Contract lines 118-133) — DO NOT alter copy: -->
  Heading:         "Log in to BN Online"
  Username label:  "Username" / autocomplete="username"
  Password label:  "Password" / autocomplete="current-password"
  CTA:             "Log in"
  Spinner copy:    "Reconnecting as <username>…" (single-char ellipsis U+2026)
  Esc hint:        "Press Esc to log in as someone else"

  <!-- WCAG contrast formula (used by the new Playwright assertion) -->
  relativeLuminance(R,G,B):
    for c in [R,G,B]: c /= 255; c = (c <= 0.03928) ? c/12.92 : ((c+0.055)/1.055)**2.4
    return 0.2126*R + 0.7152*G + 0.0722*B
  contrastRatio(L1,L2):
    return (max(L1,L2) + 0.05) / (min(L1,L2) + 0.05)
  WCAG AA normal text: ≥ 4.5
  WCAG AA large text (≥ 18pt or ≥ 14pt bold): ≥ 3.0
  For Phase 6 we use ≥ 4.5 across the board (no large-text exemption — heading at 24px/600 still passes the stricter bar with the chosen tokens).

  <!-- Existing fixtures (apps/client/test/e2e/fixtures.ts) carry baseURL via Playwright config -->
  loginAs(page, account)              — drives form submit; reused as smoke check post-fix
  waitForGameReady(page)              — waits for canvas[data-game-ready=true]; not needed here
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: Apply UI-SPEC §Color tokens to LoginScene form HTML + matching Phaser backdrop colors</name>
    <files>
      apps/client/public/forms/login.html,
      apps/client/src/scenes/LoginScene.ts
    </files>
    <read_first>
      apps/client/public/forms/login.html (current black-on-near-black state),
      apps/client/src/scenes/LoginScene.ts (current Phaser backdrop + spinner color),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md §Color (lines 84-110) + §Typography (lines 67-79) + §Copywriting Contract §Login screen (lines 118-133),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md Test 1 + Findings table row #1 (the operator's exact failure description)
    </read_first>
    <behavior>
      - Form heading "Log in to BN Online" rendered in #F3F4F6 / 24px / weight 600 / monospace stack on #0A0E1A surface
      - Username + Password labels rendered in #F3F4F6 / 16px / weight 400 on #0A0E1A
      - Username + Password input text rendered in #F3F4F6 on #1F2937 (secondary surface) with 1px #1F2937 border that switches to #22D3EE on :focus (UI-SPEC §Color "chat-mode-active cursor / input border" pattern, applied to login inputs as well — accent reserved for the four explicit uses; this is one)
      - CTA button: background #22D3EE, text #0A0E1A, 16px / 400, monospace; :hover lightens to #67E8F9 (NOT in UI-SPEC strictly but standard hover-treatment of accent is acceptable and noted in done-criteria; if checker objects, swap to a 1.1 alpha bump on the same accent)
      - Inline error region rendered in #EF4444 / 16px / 400 — empty by default, populated on submit failure with the UI-SPEC copy ("Wrong username or password.", etc.)
      - Phaser-side LoginScene backdrop fillStyle stays #0A0E1A (matches form so the DOMElement mount blends seamlessly); fast-path "Reconnecting as <username>…" Phaser text uses color #F3F4F6 / monospace 28px (UI-SPEC Display role)
      - autocomplete="username" + autocomplete="current-password" attributes preserved (do not strip — RESEARCH §Pattern 2 pitfall)
      - All form copy strings unchanged (see <interfaces> Copy contract)
    </behavior>
    <action>
  **1. apps/client/public/forms/login.html** — Tag `<!-- [impl->REQ-CLI-02] [impl->REQ-CLI-06] -->` near the top. Replace any existing `color:` and `background:` declarations on `body`, `form`, `h1`, `label`, `input`, `button`, and the error region with the UI-SPEC tokens above. Wire `:focus` border on inputs to `#22D3EE`. Preserve all `autocomplete` attributes, all `id` attributes (`#username`, `#password`), all copy strings, and the form `<button type=submit>` structure (used by `loginAs` in fixtures.ts).

  Use a single inline `<style>` block (matches the file's current shape — do NOT introduce a separate CSS file; per 06-UI-SPEC.md `Tool: none` minimal-chrome posture).

  Concrete style block (paste as a block; adjust indentation to match file):
  ```css
  body {
    margin: 0;
    background: #0A0E1A;
    color: #F3F4F6;
    font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
    font-size: 16px;
    line-height: 1.5;
  }
  form#login-form {
    max-width: 320px;
    margin: 64px auto;
    padding: 24px; /* UI-SPEC spacing token "lg" */
    background: #0A0E1A;
    display: flex;
    flex-direction: column;
    gap: 16px; /* token "md" */
  }
  form#login-form h1 {
    margin: 0 0 16px 0;
    font-size: 24px;
    font-weight: 600;
    line-height: 1.2;
    color: #F3F4F6;
  }
  form#login-form label {
    display: flex;
    flex-direction: column;
    gap: 4px; /* token "xs" */
    color: #F3F4F6;
  }
  form#login-form input[type=text],
  form#login-form input[type=password] {
    font-family: inherit;
    font-size: 16px;
    font-weight: 400;
    line-height: 1.5;
    padding: 8px; /* token "sm" */
    color: #F3F4F6;
    background: #1F2937;
    border: 1px solid #1F2937;
    outline: none;
  }
  form#login-form input[type=text]:focus,
  form#login-form input[type=password]:focus {
    border-color: #22D3EE;
  }
  form#login-form button[type=submit] {
    font-family: inherit;
    font-size: 16px;
    font-weight: 400;
    line-height: 1.5;
    padding: 8px 16px;
    color: #0A0E1A;
    background: #22D3EE;
    border: none;
    cursor: pointer;
  }
  form#login-form button[type=submit]:hover {
    background: #67E8F9; /* same hue, lighter — accepted hover treatment */
  }
  #login-error {
    min-height: 1.5em;
    color: #EF4444;
    font-size: 16px;
  }
  ```

  **2. apps/client/src/scenes/LoginScene.ts** — Tag `// [impl->REQ-CLI-02] [impl->REQ-CLI-06]` near the LoginScene class header. Find the Phaser backdrop fill and the fast-path spinner text:
  - Backdrop fillStyle / camera background → confirm 0x0A0E1A (matches form bg). If currently a different hex (e.g. plain black 0x000000), update to 0x0A0E1A.
  - Fast-path spinner Phaser.GameObjects.Text → set `color: '#F3F4F6'`, `fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace'`, `fontSize: '28px'`, `fontStyle: '600'` (UI-SPEC §Typography Display role; UI-SPEC line 77 explicitly upgrades the prior 24px to 28px for this surface).
  - Esc hint Phaser text under spinner: same color / family, 16px / 400.

  Do not alter the form-mount logic, fast-path timer logic, or any scene-transition logic — those belong to Findings #2 (06-15) and the existing fast-path code paths.
    </action>
    <verify>
      <automated>node -e "const fs=require('fs'); const html=fs.readFileSync('apps/client/public/forms/login.html','utf-8'); const ts=fs.readFileSync('apps/client/src/scenes/LoginScene.ts','utf-8'); const tokens=['#0A0E1A','#F3F4F6','#22D3EE','#EF4444','#1F2937']; for (const t of tokens) { if (!html.includes(t)) { console.error('MISSING in login.html:', t); process.exit(1); } } if (!ts.includes('#F3F4F6') &amp;&amp; !ts.includes('0xF3F4F6')) { console.error('MISSING #F3F4F6 in LoginScene.ts'); process.exit(1); } console.log('contrast tokens present');"</automated>
    </verify>
    <done>
      apps/client/public/forms/login.html contains explicit color tokens for body, form, h1, label, input (default + :focus), button, and #login-error — every color references a UI-SPEC §Color value (no ad-hoc hex). apps/client/src/scenes/LoginScene.ts uses #F3F4F6 (or 0xF3F4F6) for the spinner + Esc hint and 0x0A0E1A for the backdrop. Operator can read the form on a #0A0E1A page on Windows 125% scaling (manual smoke; locked by Task 2's automated check).
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Playwright contrast e2e — getComputedStyle + WCAG luminance assertion ≥ 4.5</name>
    <files>
      apps/client/test/e2e/login-contrast.e2e.test.ts,
      apps/client/test/e2e/fixtures.ts
    </files>
    <read_first>
      apps/client/test/e2e/cli-08.e2e.test.ts (existing e2e shape — copy the imports + describe pattern),
      apps/client/test/e2e/fixtures.ts (existing baseURL handling),
      apps/client/playwright.config.ts (auto-discovery glob — confirm *.e2e.test.ts is picked up)
    </read_first>
    <behavior>
      - Test 1: visiting `/forms/login.html` (or whatever path the LoginScene loads it from — fixtures.ts knows; if the form is mounted dynamically by Phaser, navigate to the app root, wait for the form to appear, then probe) yields a heading element with computed color = rgb(243,244,246) on parent computed background = rgb(10,14,26) — contrast ratio ≥ 4.5
      - Test 2: same probe for both `<label>` elements, both `<input>` elements (default state — no focus), the `<button type=submit>`, and the `#login-error` region (assert ratio ≥ 4.5 even though the region is empty by default — when populated, error text MUST contrast against form bg)
      - Test 3: focusing an input via `.focus()` and re-probing computed border-color asserts `rgb(34, 211, 238)` (#22D3EE) — guards the accent-on-focus contract
      - All three tests use a shared `getContrast(page, selector)` helper that reads getComputedStyle for color + (effective parent background, walking the ancestor chain until a non-transparent background is found) and runs the WCAG luminance formula
    </behavior>
    <action>
  **1. apps/client/test/e2e/fixtures.ts** — Add (do not replace) a small helper export. Tag `// [unit->REQ-CLI-06]` above the export.

  ```ts
  // [unit->REQ-CLI-06]
  export type ContrastProbe = { fg: string; bg: string; ratio: number };

  /** Run inside page.evaluate. Walks ancestors for first opaque background. */
  export const computeContrastInPage = `
    (selector) => {
      const el = document.querySelector(selector);
      if (!el) return null;
      const fg = getComputedStyle(el).color;
      let node = el;
      let bg = 'rgba(0, 0, 0, 0)';
      while (node) {
        const c = getComputedStyle(node).backgroundColor;
        if (c && !c.includes('rgba(0, 0, 0, 0)') && !c.includes('transparent')) { bg = c; break; }
        node = node.parentElement;
      }
      if (bg === 'rgba(0, 0, 0, 0)') bg = getComputedStyle(document.body).backgroundColor;
      const parse = (s) => {
        const m = s.match(/rgba?\\(([^)]+)\\)/);
        if (!m) return [0,0,0];
        return m[1].split(',').slice(0,3).map((v) => parseInt(v.trim(), 10));
      };
      const lum = (rgb) => {
        const cs = rgb.map((v) => { const n = v / 255; return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4); });
        return 0.2126*cs[0] + 0.7152*cs[1] + 0.0722*cs[2];
      };
      const Lfg = lum(parse(fg));
      const Lbg = lum(parse(bg));
      const ratio = (Math.max(Lfg, Lbg) + 0.05) / (Math.min(Lfg, Lbg) + 0.05);
      return { fg, bg, ratio };
    }
  `;
  ```

  **2. apps/client/test/e2e/login-contrast.e2e.test.ts** — New file. Tag `// [int->REQ-CLI-06] [unit->REQ-CLI-02]` at top. Mirror cli-08.e2e.test.ts's imports + describe shape.

  ```ts
  // [int->REQ-CLI-06] [unit->REQ-CLI-02]
  // Regression guard for UAT Finding #1 (D-23): black-on-near-black login text.
  // Asserts WCAG AA contrast ≥ 4.5:1 for every UI-SPEC §LoginScene element.

  import { test, expect } from '@playwright/test';
  import { computeContrastInPage } from './fixtures';

  const ELEMENTS = [
    { sel: 'form#login-form h1', label: 'heading' },
    { sel: 'form#login-form label[for="username"]', label: 'username label' },
    { sel: 'form#login-form label[for="password"]', label: 'password label' },
    { sel: 'form#login-form input#username', label: 'username input' },
    { sel: 'form#login-form input#password', label: 'password input' },
    { sel: 'form#login-form button[type=submit]', label: 'submit CTA' },
  ];

  test.describe('LoginScene contrast (UI-SPEC §Color)', () => {
    test.beforeEach(async ({ page }) => {
      // Navigate to the app root; LoginScene mounts the DOM form into the page.
      // If the form is served as a static include at /forms/login.html, that
      // path also works for a pure-form contrast check; both are valid because
      // the styles are inline.
      await page.goto('/');
      // Wait for the form to be in the DOM (LoginScene mounts it on create()).
      await page.waitForSelector('form#login-form h1', { timeout: 10000 });
    });

    for (const { sel, label } of ELEMENTS) {
      test(`${label} contrast >= 4.5:1`, async ({ page }) => {
        const r = await page.evaluate(
          new Function('selector', 'return (' + computeContrastInPage + ')(selector);') as any,
          sel,
        ) as { fg: string; bg: string; ratio: number } | null;
        if (!r) throw new Error(`element not found: ${sel}`);
        expect(r.ratio, `${label}: fg=${r.fg} bg=${r.bg} ratio=${r.ratio.toFixed(2)}`).toBeGreaterThanOrEqual(4.5);
      });
    }

    test('focused input border switches to accent #22D3EE', async ({ page }) => {
      await page.locator('form#login-form input#username').focus();
      const borderColor = await page.locator('form#login-form input#username').evaluate(
        (el) => getComputedStyle(el as HTMLElement).borderColor,
      );
      // rgb(34, 211, 238) === #22D3EE
      expect(borderColor.replace(/\s+/g, '')).toMatch(/rgb\(34,211,238\)|#22D3EE/i);
    });
  });
  ```

  **3. Run locally** with the dev server already booted by `playwright.config.ts`'s two-mode webServer (per 06-08 SUMMARY): `pnpm --filter @rebno/client test:e2e -- --grep "LoginScene contrast"`. All 7 tests (6 elements × ratio + 1 focus) GREEN before commit.
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test:e2e -- --grep "LoginScene contrast"</automated>
    </verify>
    <done>
      apps/client/test/e2e/login-contrast.e2e.test.ts exists with 7 GREEN tests (6 contrast probes + 1 focus-border probe). fixtures.ts exports computeContrastInPage helper. The new file is auto-discovered by playwright.config.ts and runs in both local and CI modes. Any CSS regression that drops a UI-SPEC §LoginScene element below 4.5:1 contrast fails the e2e suite. Operator's UAT Finding #1 reproducer is now machine-checked.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Untrusted CSS / theme drift → user readability | Future PRs may swap a token without re-running UI-SPEC review; the contrast e2e is the forcing function |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-10-01 | Tampering | Adversary or accidental edit drops form text below WCAG 4.5:1 | mitigate | Playwright contrast e2e (login-contrast.e2e.test.ts) gates merge in apps/client/test:e2e CI run |
  | T-06-10-02 | Disclosure | Inline styles leak design tokens to public bundle | accept | UI-SPEC tokens are not secrets; CSS hex values are public-information by design |
  | T-06-10-03 | Repudiation | Operator claims contrast was OK at UAT but evidence missing | mitigate | The e2e provides per-element contrast ratio in failure output; logs preserved per Playwright reporter |
  </threat_model>

  <verification>
  - `pnpm --filter @rebno/client test:e2e -- --grep "LoginScene contrast"` exits 0 (7 tests green)
  - `pnpm --filter @rebno/client typecheck` exits 0 (TypeScript happy with the new fixtures export)
  - Visual smoke: open `apps/client/public/forms/login.html` in a browser locally — heading + labels + inputs + CTA visibly readable on the dark navy background
  - `pnpm verify:phase-6` (after this plan + downstream Wave 11/12 plans land) passes the e2e step including the new contrast file
  - `pnpm trace:check` reports `[impl->REQ-CLI-06]` and `[unit->REQ-CLI-06]` tags now present for visual contrast coverage
  </verification>

  <success_criteria>
  - UAT Finding #1 (D-23) closed: login form is readable on the #0A0E1A surface across all DPI environments
  - All UI-SPEC §LoginScene §Color tokens enforced in code (heading + labels + inputs + CTA + error region)
  - Playwright contrast e2e (`login-contrast.e2e.test.ts`) is part of `pnpm verify:phase-6` step 9 — regression guard active
  - Phase 6 UAT re-run (06-17) can read the form prompts on first inspection, unblocking all DPI sweep evidence in Test 1 (HiDPI nearest-neighbor)
  - Tags `[impl/unit->REQ-CLI-06]` + `[doc->REQ-CDOC-*]` present (per gap-closure addendum line 359)
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-SUMMARY.md` recording:
  (a) Final color tokens applied (heading / labels / inputs / CTA / error / focus border)
  (b) Computed contrast ratios for each UI-SPEC §LoginScene element (paste from the Playwright reporter output for posterity)
  (c) Confirmation the new e2e file runs in both local + CI modes
  (d) Any deviations from the UI-SPEC §Color table (should be zero — flag the hover treatment if checker objects)
  (e) Confirmation operator can read the form on the next staging deploy (manual smoke check; final lock comes at 06-17 UAT re-run)
  </output>
  </content>
  </invoke>```

  ### Plan 06-11
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 11
  type: execute
  wave: 8
  depends_on: []
  files_modified:
    - apps/client/src/ui/EscMenu.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/auth/client.ts
    - apps/client/src/__test__/esc-menu.test.ts
    - apps/client/test/e2e/logout.e2e.test.ts
    - apps/client/index.html
  autonomous: true
  requirements: [CLI-02, CLI-03]
  must_haves:
    truths:
      - "Operator pressing Esc inside GameScene opens a non-modal menu overlay with three options: Logout / Resume / Settings (placeholder)"
      - "Operator clicking the Phaser game canvas (single click anywhere on the canvas) ALSO opens the same Esc menu — equivalent UX surfaces per D-24"
      - "While the Esc menu is open, movement keys (WASD + arrows) are frozen — keystrokes do not reach the InputDispatcher → no c2s.input over the wire"
      - "Selecting Logout calls POST /api/auth/sign-out, clears the Better-Auth session cookie via Better-Auth client SDK, drops the Colyseus room (room.leave()), and transitions back to LoginScene with no session"
      - "Settings option is rendered but is a no-op placeholder ('Settings — coming in Phase 7') — does NOT enable a deferred Phase 7 surface"
      - "Resume closes the overlay and re-engages movement keys without any auth/network side-effect"
      - "Logout flow does NOT preserve the staging invite token — operator re-enters via fresh `?invite=` URL or saved bookmark per D-24"
      - "Playwright e2e covers: open via Esc, open via canvas click, Logout returns to LoginScene with empty session cookie"
    artifacts:
      - path: apps/client/src/ui/EscMenu.ts
        provides: "DOM overlay component — non-modal-style Esc menu with Logout / Resume / Settings buttons; emits onLogout / onResume / onSettings events; mount/unmount lifecycle similar to existing ChatHUD"
        contains: "EscMenu"
      - path: apps/client/src/scenes/GameScene.ts
        provides: "Wires Esc keydown + canvas pointerdown → escMenu.open(); escMenu.onLogout → signOut + room.leave() + scene.start('LoginScene'); escMenu.open()/close() toggles InputDispatcher freeze state"
        contains: "escMenu"
      - path: apps/client/src/auth/client.ts
        provides: "signOut() helper — wraps Better-Auth SDK signOut + clears any sessionStorage reconnection-token cache; idempotent so double-click of Logout doesn't error"
        contains: "signOut"
      - path: apps/client/src/__test__/esc-menu.test.ts
        provides: "vitest unit suite — EscMenu mount/unmount idempotency, button-click event emission, focus-trap-not-required (non-modal per D-24)"
        contains: "EscMenu"
      - path: apps/client/test/e2e/logout.e2e.test.ts
        provides: "Playwright e2e — login + Esc → menu visible; click Logout → back at LoginScene; document.cookie no longer contains better-auth session"
        contains: "Logout"
      - path: apps/client/index.html
        provides: "DOM-overlay slot for #esc-menu (sibling of #chat-hud + #reconnect-banner under #dom-overlay)"
        contains: "esc-menu"
    key_links:
      - from: apps/client/src/scenes/GameScene.ts
        to: apps/client/src/ui/EscMenu.ts
        via: "import + instantiate in create(); call escMenu.open() on Esc keydown OR canvas pointerdown"
        pattern: "EscMenu|escMenu"
      - from: apps/client/src/ui/EscMenu.ts
        to: apps/client/src/auth/client.ts
        via: "Logout button → onLogout event → GameScene calls signOut() then room.leave() then scene.start('LoginScene')"
        pattern: "signOut"
      - from: apps/client/src/scenes/GameScene.ts
        to: apps/client/src/scenes/LoginScene.ts
        via: "scene.start('LoginScene', { fastPath: false }) on logout — DO NOT pass username/sessionToken; user must re-authenticate"
        pattern: "scene.start.*LoginScene"
      - from: apps/client/src/scenes/GameScene.ts
        to: InputDispatcher freeze state
        via: "while escMenu.isOpen, InputDispatcher.setFrozen(true) — same pattern as chat-mode freeze"
        pattern: "setFrozen|isFrozen"
  ---

  <objective>
  Close UAT Finding #5 (D-24): there is no logout affordance, so operators are stuck with cookie auto-login and can never re-test the Login screen, password manager flow, or "log in as someone else" scenario. Add a BNO-faithful Esc menu overlay (Logout / Resume / Settings-placeholder) wired to BOTH Esc keydown AND a single click on the game canvas. Logout cleanly drops the Better-Auth session cookie + the Colyseus room, returns to LoginScene; Resume closes the overlay; Settings is a Phase 7 deferred placeholder.

  Purpose: per CONTEXT D-24 — the Esc menu UX feels right for BNO's command-screen aesthetic, and dual triggers (Esc + canvas click) cover both keyboard-led and mouse-led operator habits. Unblocks UAT Test 4 (browser password manager + autofill — currently BLOCKED by Finding #5) and adds the missing auth-lifecycle surface for CLI-02/CLI-03.

  Output: a new DOM-overlay component (EscMenu), GameScene wiring for both triggers, an idempotent signOut helper, a vitest unit suite for the overlay, and a Playwright e2e that proves the Logout cookie clear + LoginScene transition. Per gap-closure addendum line 360 — `[impl->REQ-CLI-02] [impl->REQ-CLI-03]`.
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-PATTERNS.md
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/auth/client.ts
  @apps/client/src/ui/ChatHUD.ts
  @apps/client/src/ui/reconnect-banner.ts
  @apps/client/index.html
  @apps/client/test/e2e/cli-08.e2e.test.ts
  @apps/client/test/e2e/fixtures.ts

  <interfaces>
  <!-- Existing patterns to mirror — read each file fully before writing EscMenu. -->

  ChatHUD shape (apps/client/src/ui/ChatHUD.ts) — canonical DOM-overlay reference:
  - export class ChatHUD { mount(parent: HTMLElement); unmount(); ... }
  - if (this.mounted) return; idempotency guard (line 81)
  - mounts under #dom-overlay (apps/client/index.html line 18)
  - emits events via callback constructor args, not DOM CustomEvent
  - styles inline via `el.style.X = ...` (matches UI-SPEC `Tool: none` posture)

  ReconnectBanner shape (apps/client/src/ui/reconnect-banner.ts) — second canonical reference:
  - mount(parent), update(state), unmount()
  - DOM div positioned: fixed; styled inline

  Better-Auth client SDK (apps/client/src/auth/client.ts existing exports):
  - signIn.email({...}) — already used by LoginScene form submit
  - getSession() — already used by BootScene cookie probe
  - signOut() — Better-Auth client SDK exports this; verify it's wired into the createAuthClient instance; if not, expose it from auth/client.ts

  InputDispatcher freeze pattern (existing — chat-mode uses this):
  - this.inputDispatcher.setFrozen(true)  → c2s.input messages suppressed
  - this.inputDispatcher.setFrozen(false) → resume normal input flow
  - If this method does not exist, add it; existing chat-mode wiring per D-04 implies SOME freeze mechanism — reuse it

  GameScene Esc-key handling: Phase 4 D-22 + Phase 6 D-04 reserve Esc for "cancel chat input". The new Esc menu fires when Esc is pressed AND chat is NOT in chat-mode. Order: chat-mode-aware Esc handler runs first; if chat input is closed, falls through to esc-menu open. Implement as a precedence check inside the existing Esc handler.

  Phaser canvas click trigger: this.input.on('pointerdown', () => { if (!escMenu.isOpen() && !chatHud.isChatMode()) escMenu.open(); }). Single click is sufficient (D-24 — "operator clicks the game canvas").

  DOM-overlay slot (apps/client/index.html):
  <div id="dom-overlay">
    <!-- ChatHUD mounts here -->
    <!-- ReconnectBanner mounts here -->
    <!-- EscMenu mounts here (new) -->
  </div>

  UI-SPEC §Color tokens (06-UI-SPEC.md lines 87-96) — Esc menu uses:
  - Background:        #0A0E1A (dominant; matches login form for visual consistency)
  - Button background: #1F2937 (secondary)
  - Logout button accent: #EF4444 (destructive — Logout is the destructive option per UI-SPEC §Destructive reserved-for "destructive-looking surfaces" — explicitly logout DROPS the session, equivalent to the disconnect-banner pattern)
  - Resume button accent: #22D3EE (accent — primary safe action)
  - Settings (disabled placeholder): #1F2937 with #F3F4F6 text at 0.5 opacity to signal disabled
  - Text on dominant: #F3F4F6
  - Font: monospace 16px / 400 (UI-SPEC §Typography body)
  - Heading "Menu": 24px / 600 (UI-SPEC §Typography heading)

  Spacing: padding 24px (token "lg"), gap 16px (token "md") between buttons.

  Position: centered absolutely, 320px wide (matches login form), z-index above canvas + ChatHUD but below ReconnectBanner.
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: EscMenu DOM-overlay component + idempotency unit tests</name>
    <files>
      apps/client/src/ui/EscMenu.ts,
      apps/client/src/__test__/esc-menu.test.ts,
      apps/client/index.html
    </files>
    <read_first>
      apps/client/src/ui/ChatHUD.ts (canonical mount/unmount pattern + event-callback shape),
      apps/client/src/ui/reconnect-banner.ts (second reference for inline styles + state updates),
      apps/client/src/__test__/chat-hud.test.ts (if exists — copy vitest harness shape; if absent, model after any apps/client/src/__test__/*.test.ts using JSDOM),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md §Color + §Spacing + §Typography
    </read_first>
    <behavior>
      - mount() — appends #esc-menu (hidden by default) under #dom-overlay; idempotent (`if (this.mounted) return;`)
      - open() — sets display, populates buttons, emits no-op (caller responsible for freeze); idempotent (`if (this._open) return;`)
      - close() — hides, idempotent
      - isOpen() — returns boolean
      - constructor takes { onLogout, onResume, onSettings } callback bag; Logout/Resume/Settings buttons invoke each respectively
      - unmount() — removes the DOM node; idempotent
      - Settings button is rendered but disabled (button.disabled = true; visible 'Settings — coming in Phase 7' tooltip / sub-text)
      - All inline styles use UI-SPEC §Color tokens (assert via test: button.style.color === 'rgb(243, 244, 246)' etc.)
    </behavior>
    <action>
  **1. apps/client/index.html** — Tag `<!-- [impl->REQ-CLI-02] [impl->REQ-CLI-03] -->` near the dom-overlay div. Add a comment slot for #esc-menu (no actual node — EscMenu mounts dynamically per the ChatHUD pattern):
  ```html
  <!-- [impl->REQ-CLI-02] [impl->REQ-CLI-03] EscMenu mounts here on demand -->
  ```

  **2. apps/client/src/ui/EscMenu.ts** — Tag `// [impl->REQ-CLI-02] [impl->REQ-CLI-03]` at top.

  ```ts
  // [impl->REQ-CLI-02] [impl->REQ-CLI-03]
  // Esc menu overlay (D-24). Non-modal — does NOT trap focus. Movement freeze
  // is the caller's responsibility (GameScene wires inputDispatcher.setFrozen).

  export interface EscMenuCallbacks {
    onLogout: () => void | Promise<void>;
    onResume: () => void;
    onSettings?: () => void; // intentionally unused in Phase 6 — Settings is a Phase 7 placeholder
  }

  export class EscMenu {
    private root: HTMLDivElement | null = null;
    private mounted = false;
    private _open = false;
    constructor(private cbs: EscMenuCallbacks) {}

    mount(parent: HTMLElement): void {
      if (this.mounted) return;
      const root = document.createElement('div');
      root.id = 'esc-menu';
      root.setAttribute('data-testid', 'esc-menu');
      root.style.cssText = [
        'position: fixed', 'top: 50%', 'left: 50%', 'transform: translate(-50%, -50%)',
        'width: 320px', 'padding: 24px', 'background: #0A0E1A',
        'color: #F3F4F6',
        'font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace',
        'font-size: 16px', 'line-height: 1.5',
        'display: none', 'flex-direction: column', 'gap: 16px',
        'z-index: 100',
      ].join('; ') + ';';

      const heading = document.createElement('div');
      heading.textContent = 'Menu';
      heading.style.cssText = 'font-size: 24px; font-weight: 600; line-height: 1.2;';

      const mkBtn = (label: string, bg: string, fg: string, onClick: () => void, opts: { disabled?: boolean; title?: string; testid?: string } = {}) => {
        const b = document.createElement('button');
        b.type = 'button';
        b.textContent = label;
        b.disabled = !!opts.disabled;
        if (opts.title) b.title = opts.title;
        if (opts.testid) b.setAttribute('data-testid', opts.testid);
        b.style.cssText = [
          'font-family: inherit', 'font-size: 16px', 'font-weight: 400',
          'padding: 8px 16px', `color: ${fg}`, `background: ${bg}`,
          'border: none', 'cursor: pointer',
          opts.disabled ? 'opacity: 0.5' : '',
        ].filter(Boolean).join('; ') + ';';
        b.addEventListener('click', () => { if (!b.disabled) onClick(); });
        return b;
      };

      // Resume = primary safe action (accent)
      const resumeBtn = mkBtn('Resume', '#22D3EE', '#0A0E1A', () => this.cbs.onResume(), { testid: 'esc-menu-resume' });
      // Settings = placeholder (Phase 7)
      const settingsBtn = mkBtn('Settings', '#1F2937', '#F3F4F6', () => this.cbs.onSettings?.(), {
        disabled: true, title: 'Settings — coming in Phase 7', testid: 'esc-menu-settings',
      });
      // Logout = destructive (drops session)
      const logoutBtn = mkBtn('Logout', '#EF4444', '#0A0E1A', async () => {
        logoutBtn.disabled = true; // guard against double-click
        try { await this.cbs.onLogout(); } finally { logoutBtn.disabled = false; }
      }, { testid: 'esc-menu-logout' });

      root.append(heading, resumeBtn, settingsBtn, logoutBtn);
      parent.appendChild(root);
      this.root = root;
      this.mounted = true;
    }

    open(): void {
      if (!this.mounted || this._open) return;
      if (this.root) this.root.style.display = 'flex';
      this._open = true;
    }

    close(): void {
      if (!this._open) return;
      if (this.root) this.root.style.display = 'none';
      this._open = false;
    }

    isOpen(): boolean { return this._open; }

    unmount(): void {
      if (!this.mounted) return;
      this.root?.remove();
      this.root = null;
      this.mounted = false;
      this._open = false;
    }
  }
  ```

  **3. apps/client/src/__test__/esc-menu.test.ts** — Tag `// [unit->REQ-CLI-02] [unit->REQ-CLI-03]`.

  ```ts
  // [unit->REQ-CLI-02] [unit->REQ-CLI-03]
  import { describe, it, expect, beforeEach, vi } from 'vitest';
  import { EscMenu } from '../ui/EscMenu';

  describe('EscMenu', () => {
    let parent: HTMLElement;
    beforeEach(() => {
      document.body.innerHTML = '<div id="dom-overlay"></div>';
      parent = document.getElementById('dom-overlay')!;
    });

    it('mounts idempotently', () => {
      const m = new EscMenu({ onLogout: vi.fn(), onResume: vi.fn() });
      m.mount(parent); m.mount(parent);
      expect(parent.querySelectorAll('#esc-menu').length).toBe(1);
    });

    it('opens and closes', () => {
      const m = new EscMenu({ onLogout: vi.fn(), onResume: vi.fn() });
      m.mount(parent);
      expect(m.isOpen()).toBe(false);
      m.open(); expect(m.isOpen()).toBe(true);
      m.close(); expect(m.isOpen()).toBe(false);
    });

    it('open/close are idempotent', () => {
      const m = new EscMenu({ onLogout: vi.fn(), onResume: vi.fn() });
      m.mount(parent);
      m.open(); m.open(); expect(m.isOpen()).toBe(true);
      m.close(); m.close(); expect(m.isOpen()).toBe(false);
    });

    it('Logout button invokes onLogout callback', async () => {
      const onLogout = vi.fn();
      const m = new EscMenu({ onLogout, onResume: vi.fn() });
      m.mount(parent); m.open();
      (parent.querySelector('[data-testid=esc-menu-logout]') as HTMLButtonElement).click();
      expect(onLogout).toHaveBeenCalledOnce();
    });

    it('Resume button invokes onResume callback', () => {
      const onResume = vi.fn();
      const m = new EscMenu({ onLogout: vi.fn(), onResume });
      m.mount(parent); m.open();
      (parent.querySelector('[data-testid=esc-menu-resume]') as HTMLButtonElement).click();
      expect(onResume).toHaveBeenCalledOnce();
    });

    it('Settings button is disabled (Phase 7 placeholder)', () => {
      const m = new EscMenu({ onLogout: vi.fn(), onResume: vi.fn() });
      m.mount(parent); m.open();
      const btn = parent.querySelector('[data-testid=esc-menu-settings]') as HTMLButtonElement;
      expect(btn.disabled).toBe(true);
      expect(btn.title).toMatch(/Phase 7/);
    });

    it('uses UI-SPEC §Color tokens (Resume = accent, Logout = destructive)', () => {
      const m = new EscMenu({ onLogout: vi.fn(), onResume: vi.fn() });
      m.mount(parent); m.open();
      const resume = parent.querySelector('[data-testid=esc-menu-resume]') as HTMLButtonElement;
      const logout = parent.querySelector('[data-testid=esc-menu-logout]') as HTMLButtonElement;
      expect(resume.style.background).toMatch(/#22D3EE|rgb\(34,\s*211,\s*238\)/i);
      expect(logout.style.background).toMatch(/#EF4444|rgb\(239,\s*68,\s*68\)/i);
    });

    it('unmounts cleanly', () => {
      const m = new EscMenu({ onLogout: vi.fn(), onResume: vi.fn() });
      m.mount(parent); m.unmount();
      expect(parent.querySelectorAll('#esc-menu').length).toBe(0);
      expect(m.isOpen()).toBe(false);
    });
  });
  ```
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test -- src/__test__/esc-menu.test.ts</automated>
    </verify>
    <done>
      apps/client/src/ui/EscMenu.ts exports a mount/open/close/unmount lifecycle matching the ChatHUD pattern. 8 vitest tests in esc-menu.test.ts pass. UI-SPEC §Color tokens applied verbatim (Resume = #22D3EE accent, Logout = #EF4444 destructive, Settings disabled at 0.5 opacity). DOM-overlay HTML slot tagged.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: GameScene wiring (Esc + canvas click → menu) + signOut helper + Playwright logout e2e</name>
    <files>
      apps/client/src/scenes/GameScene.ts,
      apps/client/src/auth/client.ts,
      apps/client/test/e2e/logout.e2e.test.ts
    </files>
    <read_first>
      apps/client/src/scenes/GameScene.ts (find: existing Esc handler in chat-mode; existing this.input.on('pointerdown') if any; InputDispatcher freeze API),
      apps/client/src/auth/client.ts (find: existing createAuthClient + signIn.email + getSession; check if signOut is exported),
      apps/client/src/net/colyseus-client.ts (room.leave() pattern — used during reconnect/teardown),
      apps/client/test/e2e/cli-08.e2e.test.ts (Playwright shape — copy describe/test imports + loginAs fixture usage)
    </read_first>
    <behavior>
      - Esc keydown inside GameScene: if chat-mode is open → defer to chat handler; else → escMenu.open() + inputDispatcher.setFrozen(true)
      - Phaser pointerdown on canvas: if escMenu not open AND chat not in chat-mode AND no other modal active → escMenu.open() + inputDispatcher.setFrozen(true)
      - Resume → escMenu.close() + inputDispatcher.setFrozen(false)
      - Logout → call signOut() → room.leave() (best-effort; catch errors) → scene.start('LoginScene', { fastPath: false }) (no username, no sessionToken — fresh login required)
      - signOut() in auth/client.ts: calls Better-Auth client.signOut(); clears any sessionStorage 'colyseus-reconnect' token cache; idempotent (safe to call multiple times)
      - Playwright e2e: loginAs(accountA) → wait for game-ready → press Escape → assert [data-testid=esc-menu] visible → click [data-testid=esc-menu-logout] → assert form#login-form heading visible AND document.cookie does NOT contain better-auth session cookie name
    </behavior>
    <action>
  **1. apps/client/src/auth/client.ts** — Add or expose signOut. Tag `// [impl->REQ-CLI-02] [impl->REQ-CLI-03]` near the new export.

  If the file already creates an authClient via `createAuthClient({...})` per Better-Auth docs, ensure `authClient.signOut` is reachable. Add a small wrapper:

  ```ts
  // [impl->REQ-CLI-02] [impl->REQ-CLI-03]
  /** Signs out the Better-Auth session. Idempotent — safe to call multiple times. */
  export async function signOut(): Promise<void> {
    try {
      await authClient.signOut();
    } catch (err) {
      // Better-Auth signOut on an already-cleared session may 401; treat as success.
      console.warn('[auth] signOut: ignoring non-fatal error', err);
    }
    // Clear Colyseus reconnection-token cache so a stale token can't auto-resume
    try {
      sessionStorage.removeItem('colyseus-reconnect');
    } catch {/* sessionStorage may be unavailable in some test envs */}
  }
  ```

  (Adjust import name if the existing module exports `client` instead of `authClient`. Match existing file conventions.)

  **2. apps/client/src/scenes/GameScene.ts** — Tag `// [impl->REQ-CLI-02] [impl->REQ-CLI-03]` near the new EscMenu wiring section.

  Add to GameScene:
  ```ts
  // imports
  import { EscMenu } from '../ui/EscMenu';
  import { signOut } from '../auth/client';

  // in class fields
  private escMenu!: EscMenu;

  // in create() — after chatHud.mount + reconnectBanner.mount
  this.escMenu = new EscMenu({
    onLogout: async () => {
      // [impl->REQ-CLI-02] [impl->REQ-CLI-03]
      this.escMenu.close();
      try { await signOut(); } catch {/* noop — signOut is idempotent */}
      try { this.room?.leave(); } catch {/* room may already be closed */}
      this.scene.start('LoginScene', { fastPath: false });
    },
    onResume: () => {
      this.escMenu.close();
      this.inputDispatcher?.setFrozen(false);
    },
  });
  const overlay = document.getElementById('dom-overlay');
  if (overlay) this.escMenu.mount(overlay);

  // Esc keydown wiring — adapt to existing handler structure
  // IMPORTANT: precedence — chat-mode Esc cancels chat first; only fires menu when chat is closed.
  this.input.keyboard?.on('keydown-ESC', () => {
    if (this.chatHud?.isChatMode?.()) return; // existing chat-mode handler takes Esc
    if (this.escMenu.isOpen()) {
      this.escMenu.close();
      this.inputDispatcher?.setFrozen(false);
    } else {
      this.escMenu.open();
      this.inputDispatcher?.setFrozen(true);
    }
  });

  // Canvas pointerdown — D-24 secondary trigger
  this.input.on('pointerdown', () => {
    if (this.escMenu.isOpen()) return;
    if (this.chatHud?.isChatMode?.()) return;
    // any other modal (force-reset overlay)? skip if active
    this.escMenu.open();
    this.inputDispatcher?.setFrozen(true);
  });

  // in shutdown()/destroy() — cleanup
  this.escMenu?.unmount();
  ```

  **Edge cases to handle in the executor's discretion (not load-bearing for this plan):**
  - If `inputDispatcher.setFrozen` does not exist as an API yet, add it: `setFrozen(b: boolean) { this._frozen = b; }` + check `_frozen` in dispatch path.
  - If `chatHud.isChatMode` does not exist as a method, add a getter.
  - If GameScene has shutdown() lifecycle missing, add `shutdown() { this.escMenu?.unmount(); }`.

  **3. apps/client/test/e2e/logout.e2e.test.ts** — Tag `// [int->REQ-CLI-02] [int->REQ-CLI-03]`.

  ```ts
  // [int->REQ-CLI-02] [int->REQ-CLI-03]
  // Regression guard for UAT Finding #5 (D-24): no logout affordance.
  // Tests both Esc trigger AND canvas click trigger; verifies cookie cleared.

  import { test, expect } from '@playwright/test';
  import { loginAs, waitForGameReady, accountA } from './fixtures';

  test.describe('Esc menu + logout (D-24)', () => {
    test('Esc opens menu; Logout returns to LoginScene with cleared session', async ({ page }) => {
      await loginAs(page, accountA);
      await waitForGameReady(page);
      await page.keyboard.press('Escape');
      await expect(page.locator('[data-testid=esc-menu]')).toBeVisible();
      await page.click('[data-testid=esc-menu-logout]');
      // Back at LoginScene
      await expect(page.locator('form#login-form h1')).toBeVisible({ timeout: 5000 });
      // Better-Auth session cookie cleared
      const cookies = await page.context().cookies();
      const sessionCookies = cookies.filter((c) => /better-auth|session/i.test(c.name));
      expect(sessionCookies.every((c) => !c.value || c.value === '')).toBe(true);
    });

    test('canvas click opens menu (D-24 secondary trigger)', async ({ page }) => {
      await loginAs(page, accountA);
      await waitForGameReady(page);
      // Click roughly the center of the canvas
      const canvas = page.locator('canvas[data-game-ready=true]');
      await canvas.click({ position: { x: 100, y: 100 } });
      await expect(page.locator('[data-testid=esc-menu]')).toBeVisible();
    });

    test('Resume closes menu and re-engages movement', async ({ page }) => {
      await loginAs(page, accountA);
      await waitForGameReady(page);
      await page.keyboard.press('Escape');
      await expect(page.locator('[data-testid=esc-menu]')).toBeVisible();
      await page.click('[data-testid=esc-menu-resume]');
      await expect(page.locator('[data-testid=esc-menu]')).toBeHidden();
    });
  });
  ```
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test:e2e -- --grep "Esc menu"</automated>
    </verify>
    <done>
      GameScene wires both Esc keydown AND canvas pointerdown to escMenu.open(); chat-mode-Esc precedence preserved. signOut() helper exported from auth/client.ts and idempotent. Logout flow: signOut → room.leave → scene.start('LoginScene'). Playwright e2e (3 tests) GREEN. Operator can now exit GameScene; UAT Test 4 (browser password manager) is unblocked for re-test at 06-17.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Client → Better-Auth signOut endpoint | Logout intent crosses into auth perimeter; cookie clear is server-side authoritative |
  | Operator click → game-canvas → modal escalation | Canvas click is "untrusted" in that it's incidentally fired during gameplay; menu must NOT open spuriously while in chat-mode or during a force-reset overlay |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-11-01 | Elevation | Logout doesn't actually clear the cookie → next session reuses old auth | mitigate | Playwright e2e asserts `document.cookie` does not contain better-auth session after Logout; signOut() wraps Better-Auth SDK + clears sessionStorage reconnection-token cache |
  | T-06-11-02 | DoS | Operator spams canvas click while typing in chat → menu pops over chat input | mitigate | pointerdown handler checks chatHud.isChatMode() and exits early; chat-mode Esc precedence preserved |
  | T-06-11-03 | Tampering | Adversary replaces Resume callback to trigger Logout silently | accept | Code is bundled; no runtime extension surface in Phase 6; if a Phase 7 plugin model is added, revisit |
  | T-06-11-04 | Repudiation | Operator claims Logout didn't fire | mitigate | Logout button has data-testid; e2e captures the click; Playwright reporter logs the failure if button missing |
  </threat_model>

  <verification>
  - `pnpm --filter @rebno/client test -- src/__test__/esc-menu.test.ts` exits 0 (8 vitest tests)
  - `pnpm --filter @rebno/client test:e2e -- --grep "Esc menu"` exits 0 (3 Playwright tests)
  - `pnpm --filter @rebno/client typecheck` exits 0
  - Manual smoke: log in to local dev, press Esc → menu opens; click Logout → back at LoginScene; cookie cleared in DevTools Application tab
  - `pnpm verify:phase-6` (after this plan + Wave 11 land) passes the new e2e file as part of step 9
  - `pnpm trace:check` reports `[impl->REQ-CLI-02]` + `[impl->REQ-CLI-03]` tags satisfied for the new logout surface
  </verification>

  <success_criteria>
  - UAT Finding #5 (D-24) closed: GameScene has a logout affordance reachable via both Esc and canvas click
  - BNO-faithful command-screen UX (per D-24 rationale) — non-modal overlay that freezes movement keys while open
  - UAT Test 4 (browser password manager) unblocked — operator can sign out, sign in again, exercise autofill
  - Settings option visible as a Phase 7 placeholder (does NOT smuggle in deferred functionality)
  - Tags `[impl->REQ-CLI-02] [impl->REQ-CLI-03]` (per gap-closure addendum line 360)
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-11-SUMMARY.md` recording:
  (a) Final EscMenu component public API (mount/open/close/isOpen/unmount + callback shape)
  (b) GameScene precedence rules locked (chat-mode Esc takes precedence; canvas click suppressed during chat-mode + during any modal)
  (c) signOut() idempotency confirmed by manual double-click smoke test
  (d) Confirmation that document.cookie has the better-auth session cleared after Logout (paste a Playwright reporter snippet for traceability)
  (e) Any deviations from D-24 (should be zero — flag if Settings turned out to need a real handler beyond placeholder)
  </output>
  </content>
  </invoke>```

  ### Plan 06-12
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 12
  type: execute
  wave: 9
  depends_on: []
  files_modified:
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md
  autonomous: true
  requirements: [CLI-09, CLI-04]
  must_haves:
    truths:
      - "06-12-DEBUG-FINDINGS.md exists in the phase directory and is the canonical input contract for plan 06-15 — 06-15's executor reads this file (not the generic .planning/debug/reconnect-blank-render.md) to produce the fix"
      - "Document carries the root cause verbatim: the Better-Auth session_token is dropped between BootScene → LoginScene fast-path → GameScene; cookie auto-login forwards only `username`; GameScene early-returns at empty token (line 191-192 of GameScene.ts)"
      - "Document carries the recommended fix direction (Option A — three edits in BootScene/LoginScene/GameScene threading sessionToken through init data; Option B — defence in depth via getSession() self-heal in GameScene.create)"
      - "Document folds in the secondary defect (GameScene.ts:238-252 stale inputDispatcher.room reference after silent_reauth) with a regression test sketch — flagged as in-scope for 06-15 per CONTEXT addendum line 361"
      - "Document specifies the four required regression tests (1 GameScene unit, 1 LoginScene unit, 2 Playwright e2e in cli-08.e2e.test.ts family) so 06-15's executor has a checklist"
      - "No source file modifications occur in this plan — pure-doc consolidation"
    artifacts:
      - path: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md
        provides: "Phase-local consolidation of the diagnose-only output from .planning/debug/reconnect-blank-render.md — input contract for plan 06-15"
        contains: "session_token"
    key_links:
      - from: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md
        to: .planning/debug/reconnect-blank-render.md
        via: "explicit citation in §0 — points to the global debug document for full diagnostic trail; phase-local doc is the curated input for 06-15"
        pattern: "reconnect-blank-render"
      - from: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md
        to: 06-15-PLAN.md (downstream)
        via: "06-15's <context> block references this doc; 06-15's tasks consume the §Required test coverage list verbatim"
        pattern: "06-12-DEBUG-FINDINGS"
  ---

  <objective>
  Consolidate the diagnose-only output from `.planning/debug/reconnect-blank-render.md` (root-causing UAT Finding #2) into a phase-local `06-12-DEBUG-FINDINGS.md` document that plan 06-15 will consume verbatim as its input contract. This plan does NOT modify any source files; it produces ONE Markdown artifact whose job is to give 06-15's executor a self-contained, in-phase reference for the cookie-auto-login blank-render bug, the recommended fix shape, the secondary stale-inputDispatcher-room defect (folded in per CONTEXT addendum line 361), and the four required regression tests.

  Purpose: per CONTEXT D-25 — `/gsd-debug` was run BEFORE planning to isolate the root cause; the global debug doc lives at `.planning/debug/reconnect-blank-render.md` (already root-caused, status `root_caused`). 06-15 needs a phase-local copy curated to "fix shape + test checklist" so the executor doesn't have to re-derive the diagnosis. Also folds in the silently-out-of-scope §6 secondary defect (stale `InputDispatcher.room` after silent_reauth) which the gap-closure addendum scopes INTO 06-15.

  Output: one Markdown artifact (`06-12-DEBUG-FINDINGS.md`) in the phase directory. Per gap-closure addendum line 361 — `[doc->REQ-CDOC-*]` (the doc itself) + `[impl/int->REQ-CLI-09]` is delivered downstream by 06-15.

  Important: this is a doc-only plan with one task. Per planner anti-pattern guidance, normally we'd combine; here the doc IS the single concern and ~10-15% context cost is appropriate as a wave-9 dependency lever (06-15 cannot start without it).
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/debug/reconnect-blank-render.md
  @apps/client/src/scenes/BootScene.ts
  @apps/client/src/scenes/LoginScene.ts
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/auth/client.ts
  @apps/client/src/net/colyseus-client.ts

  <interfaces>
  <!-- The global debug doc IS the input. The phase-local doc is the curated -->
  <!-- output. The interface contract is the section list below — 06-15's      -->
  <!-- executor will read these sections by header name.                       -->

  Required sections in 06-12-DEBUG-FINDINGS.md (in this order):
    §0  Source + status — 1-line citation of .planning/debug/reconnect-blank-render.md, status: root_caused, no source files modified
    §1  Symptom — 4-line summary (cookie auto-login + WS-kill→reload paths both render blank canvas)
    §2  Root cause — sessionToken dropped through Boot→Login fast-path→Game; GameScene early-return at !sessionToken; verbatim line numbers
    §3  Affected files table — 6 rows (BootScene 78-95, LoginScene 80-116 + 106-109, GameScene 191-192 + 238-252, auth/client.ts 84-108, colyseus-client.ts 82-99, RebnoRoom.ts 267-269 read-only)
    §4  Primary fix shape — Option A (3 edits, preferred) + Option B (defence-in-depth) — 06-15's executor picks one; both should ship
    §5  Secondary defect (in scope for 06-15 per CONTEXT addendum) — stale inputDispatcher.room after silent_reauth; fix sketch + unit-test sketch
    §6  Required regression tests — 4-item checklist:
          a) Unit (GameScene): scene.init({ username }) without sessionToken → getSession returns token → joinRebnoRoom IS called
          b) Unit (LoginScene): fast-path forwards sessionToken into scene.start('GameScene', ...)
          c) e2e (cookie auto-login): login → reload → [data-game-ready=true] re-asserts within 5s + chat line renderable
          d) e2e (WS kill within grace): login → __rebno.room.connection.close() → SDK auto-reconnects → [data-game-ready=true] still truthy + chat round-trip
    §7  Out of scope (documented for clarity) — server bearer-vs-cookie posture is correct; pure client-side data flow bug
    §8  References — list of file paths and the global debug doc

  Tagging contract:
    Top of file: [doc->REQ-CDOC-04] (or whichever CDOC ID covers reconnect-flow doc — fall back to a CLI-09 doc tag if no CDOC ID matches; the markdown can carry both safely)
    AND [doc->REQ-CLI-09] [doc->REQ-CLI-04] for downstream traceability
  </interfaces>
  </context>

  <tasks>

  <task type="auto">
    <name>Task 1: Write 06-12-DEBUG-FINDINGS.md per the section contract</name>
    <files>
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md
    </files>
    <read_first>
      .planning/debug/reconnect-blank-render.md (full file — this is the source-of-truth being consolidated; copy line-numbered evidence verbatim where it improves precision),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md (Gap-Closure Addendum lines 307+, especially D-25 + plan-tagging guidance line 361 noting the secondary defect is in scope for 06-15),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md (Test 1 + Test 5 + Findings #2 — operator's exact reproduction steps)
    </read_first>
    <action>
  Create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md` with the following structure. Tag at top:

  ```md
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 12
  type: doc-only
  status: complete
  input_for: 06-15-PLAN.md
  source: .planning/debug/reconnect-blank-render.md
  ---

  # Reconnect / Cookie-Auto-Login Blank GameScene — Consolidated Findings (input for 06-15)

  [doc->REQ-CLI-09] [doc->REQ-CLI-04] [doc->REQ-CDOC-04]

  > **Diagnose-only consolidation.** No source files were edited by this plan. This document is the input contract for plan **06-15** (the fix plan); 06-15 owns code edits, regression tests, and the secondary `InputDispatcher` stale-room defect (folded in per CONTEXT D-25 + addendum line 361).

  ## §0 Source + status

  - Original diagnostic: `.planning/debug/reconnect-blank-render.md` (`status: root_caused`)
  - Operator UAT trigger: `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md` Test 1 + Test 5 + Findings table row #2
  - This plan modifies zero source files.

  ## §1 Symptom

  On either of these reconnect paths the canvas goes blank — only the `#0A0E1A` background, no HUD message history, no remotes, no self avatar:

  1. **Reload the tab** after a fresh login (cookie auto-login fast-path).
  2. **Kill the WS** in DevTools (`__rebno.room.connection.close()`) and let the SDK either auto-resume within grace or fall through to silent reauth. Past-grace branch: the disconnect banner click reloads the tab → same cookie auto-login path as #1.

  Specifically: `data-game-ready` never set; `window.rebno.remotePlayers === []`; no WebSocket in DevTools Network panel; chat hint mounts but fades after 5 s.

  ## §2 Root cause

  **The cookie auto-login path drops the Better-Auth `session_token` between BootScene → LoginScene (fast-path) → GameScene.** The server's WS `onAuth` handler requires `session_token` as a `Bearer` header, so `GameScene.connect()` short-circuits and no Colyseus room is ever joined.

  Verbatim evidence (file paths + line numbers — do not re-derive):

  1. `apps/client/src/scenes/GameScene.ts:191-192` — `if (!this.sessionToken) return;` (silent early-return)
  2. `apps/server/src/RebnoRoom.ts:267-269` — server requires `Authorization: Bearer ${session_token}`; cookie WS auth is NOT wired
  3. `apps/client/src/scenes/BootScene.ts:78-95` — fetches `getSession()` (which returns `{ user, token }`) but only forwards `username` to LoginScene fast-path init data
  4. `apps/client/src/scenes/LoginScene.ts:106-109` — fast-path timer: `this.scene.start('GameScene', { username })` — no sessionToken
  5. `apps/client/src/scenes/LoginScene.ts:173-177` — form-submit path DOES pass `result.session_token` (the working path; reference for the fix shape)
  6. `apps/client/src/auth/client.ts:84-108` — `getSession()` already returns `SessionShape.token`; the data is reachable, just not forwarded
  7. `apps/client/src/net/colyseus-client.ts:82-99` — sessionStorage reconnection-token cache is unreachable because connect() returns early before joinRebnoRoom()

  Why the secondary symptoms vanish too (per debug doc):
  - No remotes: `bindHandlers` never fires → `players.onAdd` never registers → no `playerRenderer.addRemote(...)` calls
  - No self avatar: same chain — `onLocalJoin` (the `players.onAdd` callback for local sessionId) never runs
  - No room background: `onRoomLayout` (the `s2c.room_layout` handler) is bound inside `bindHandlers` and never reached
  - HUD chat hint disappears: ChatHUD mounts before connect() early-return so the hint appears, then fades to opacity 0 after 5 s (ChatHUD.ts:127-129)

  ## §3 Affected files

  | File | Lines | Role in the bug | Edited by 06-15? |
  |------|-------|-----------------|------------------|
  | `apps/client/src/scenes/BootScene.ts` | 78-95 | Drops `session.token`; only forwards `username` | YES |
  | `apps/client/src/scenes/LoginScene.ts` | 80-116, esp 106-109 | Fast-path passes `{ username }` only to GameScene | YES |
  | `apps/client/src/scenes/GameScene.ts` | 191-192 | Early-return on missing `sessionToken` | YES (Option A) or KEEP + Option B self-heal |
  | `apps/client/src/scenes/GameScene.ts` | 238-252 | `if (!this.inputDispatcher)` keeps stale `room` after silent_reauth (§5 secondary defect) | YES (per CONTEXT addendum line 361) |
  | `apps/client/src/auth/client.ts` | 84-108 | `getSession()` already returns `token` — fix consumes directly | NO (read-only) |
  | `apps/client/src/net/colyseus-client.ts` | 82-99 | Cached-reconnect path is reachable without bearer for within-grace branch (informational; fix is upstream) | NO |
  | `apps/server/src/RebnoRoom.ts` | 267-269 | Server contract: WS `onAuth` is bearer-only, no cookie fallback | NO — DO NOT change |

  ## §4 Primary fix shape

  ### Option A (preferred — minimal surface, three edits)

  1. `BootScene.create` (line 88-95): forward `session.token` into `LoginSceneInitData` alongside `username`.
  2. `LoginScene.init` / `LoginSceneInitData` shape (line 26-51): accept `sessionToken?: string`; store on the instance.
  3. `LoginScene.renderFastPath` (line 106-109): pass `{ sessionToken, username }` to `scene.start('GameScene', …)`.

  Keep the existing `GameScene.connect` early-return as a defensive check (still covers the genuinely unauthenticated case) but ensure the cookie path satisfies it.

  ### Option B (defence in depth — combine with A, not instead of)

  In `GameScene.create`, if `this.sessionToken` is undefined, call `getSession()` and adopt `session.token`. Self-heal against future scene-init shape changes. **06-15 should ship BOTH** — they're complementary:
  - A fixes the immediate path
  - B prevents recurrence if anyone refactors scene-init wiring later

  ## §5 Secondary defect (in scope for 06-15 per CONTEXT addendum line 361)

  In the past-grace silent-reauth branch (`GameScene.connect` runs a second time inside the same Phaser scene), the existing `InputDispatcher` keeps a stale `room` reference:

  `apps/client/src/scenes/GameScene.ts:238-252`
  ```ts
  if (!this.inputDispatcher) {
    ...
    this.inputDispatcher = new InputDispatcher(this.room, stubPrediction);
    ...
  }
  ```

  The `if (!this.inputDispatcher)` guard means the dispatcher is created once and never re-pointed at the new `this.room` after a silent reauth. Any movement intent dispatched after reauth will be `room.send`-ed through the closed/garbage-collected old room object → silent movement freeze.

  **Fix sketch (06-15 owns):**

  ```ts
  if (!this.inputDispatcher) {
    this.inputDispatcher = new InputDispatcher(this.room, stubPrediction);
  } else {
    // Refresh room ref — silent_reauth produced a new room object; old one is closed.
    this.inputDispatcher.setRoom(this.room);
  }
  ```

  (Or expose a `room` setter on InputDispatcher if not already there.)

  **Unit test sketch (06-15 owns):**
  ```ts
  // after silent_reauth cycle
  expect(dispatcher.room).toBe(gameScene.room);
  ```

  ## §6 Required regression tests for 06-15

  1. **Unit** (`apps/client/src/__test__/game-scene.test.ts`): add a case where `scene.init({ username: 'me' })` (no `sessionToken`) is followed by `getSession()` returning a token; assert `joinRebnoRoom` IS called with the token. Covers Option B.
  2. **Unit** (`apps/client/src/__test__/login-scene.test.ts`): assert that `LoginScene` fast-path forwards `sessionToken` into `scene.start('GameScene', ...)`. Covers Option A end-to-end at the LoginScene→GameScene boundary.
  3. **e2e** (`apps/client/test/e2e/cli-08.e2e.test.ts` family — add to existing file or create `cookie-reload.e2e.test.ts`):
     - log in with the form;
     - assert `[data-game-ready="true"]` on the canvas;
     - reload the page;
     - re-assert `[data-game-ready="true"]` within 5 s of reload;
     - assert at least one `[data-chat-line]` is renderable (send a message after reload).
  4. **e2e** (kill-WS path — same file or sibling):
     - log in;
     - call `__rebno.room.connection.close()` from `page.evaluate`;
     - wait for the SDK to auto-reconnect (within grace);
     - assert `[data-game-ready="true"]` is STILL truthy and a follow-up chat send round-trips.
  5. **Unit** (covers §5 secondary): after silent_reauth cycle, assert `dispatcher.room === gameScene.room`.

  (Tests #1-4 close Finding #2; test #5 closes the secondary defect bundled into 06-15.)

  ## §7 Out of scope (documented for clarity)

  - Server-side bearer-vs-cookie posture: **correct as-is**. The bug is purely client-side data flow.
  - Future cookie-WS-auth refactor: deferred to a Phase 7 retro if the bearer-only contract proves brittle. Phase 6 ships with bearer-only.
  - Reconnect-banner UX timing: graded by UAT Test 5 separately; not in this finding's scope.

  ## §8 References

  - `apps/client/src/scenes/BootScene.ts`
  - `apps/client/src/scenes/LoginScene.ts`
  - `apps/client/src/scenes/GameScene.ts`
  - `apps/client/src/auth/client.ts`
  - `apps/client/src/net/colyseus-client.ts`
  - `apps/client/src/net/reconnect.ts`
  - `apps/client/src/ui/ChatHUD.ts`
  - `apps/client/src/ui/reconnect-banner.ts`
  - `apps/client/index.html`
  - `apps/server/src/RebnoRoom.ts` (read-only; do not edit)
  - `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md` (Finding #2 source)
  - `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md` (D-25 sequencing + addendum line 361 secondary-defect inclusion)
  - `.planning/debug/reconnect-blank-render.md` (full diagnostic trail; this doc is its phase-local consolidation)
  ```

  Confirm the file is complete and exits with §8 References. No source-file edits.
    </action>
    <verify>
      <automated>node -e "const fs=require('fs'); const p='.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md'; const t=fs.readFileSync(p,'utf-8'); const required=['§0 Source','§1 Symptom','§2 Root cause','§3 Affected files','§4 Primary fix shape','§5 Secondary defect','§6 Required regression tests','§7 Out of scope','§8 References','session_token','InputDispatcher','Option A','Option B','06-15']; for (const s of required) { if (!t.includes(s)) { console.error('MISSING:', s); process.exit(1); } } console.log('all sections present');"</automated>
    </verify>
    <done>
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md exists with §0-§8 sections, root cause + Option A/B fix shape + secondary defect + 5-item test checklist + references. Tagged [doc->REQ-CLI-09] [doc->REQ-CLI-04] [doc->REQ-CDOC-04]. Zero source files modified. Plan 06-15 has its input contract.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Phase-local doc → 06-15's executor | If this doc misrepresents the root cause, 06-15 ships a wrong fix; mitigated by direct citation of line numbers from the global debug doc |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-12-01 | Repudiation | Doc gets edited later, drifts from the truth captured at /gsd-debug time | mitigate | §0 cites the global debug doc + UAT — both immutable per planning-state model; if drift, 06-15 reconciles |
  | T-06-12-02 | Tampering | Doc instructs 06-15 to edit server bearer logic | mitigate | §3 + §4 + §7 explicitly mark `apps/server/src/RebnoRoom.ts` as read-only; instructions are bounded |
  </threat_model>

  <verification>
  - File exists at the canonical phase-local path
  - All required §0-§8 sections present (verified by inline node script)
  - No source-file modifications in apps/, packages/, tools/ (this plan is doc-only — `git status` shows only the new .md file)
  - `pnpm trace:check` reports the new doc tags satisfied for REQ-CLI-09 and REQ-CLI-04 doc-stage coverage
  </verification>

  <success_criteria>
  - 06-15-PLAN.md (next wave) can name `06-12-DEBUG-FINDINGS.md` in its `<context>` block and the executor has self-contained instructions
  - Secondary defect (stale `InputDispatcher.room` after silent_reauth) is documented + bundled into 06-15's scope per CONTEXT addendum line 361
  - Four canonical regression tests are pre-specified — 06-15's executor doesn't have to invent them
  - Tags: `[doc->REQ-CLI-09] [doc->REQ-CLI-04] [doc->REQ-CDOC-04]`
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-SUMMARY.md` recording:
  (a) Confirmation the file shipped at the canonical path
  (b) Any clarifications added beyond the global debug doc (e.g. fold-in language for the secondary defect)
  (c) Pointer that 06-15 is the consumer; this plan completes when the doc exists
  (d) Trace tag verification snippet (paste `pnpm trace:check` output for REQ-CLI-09 + REQ-CLI-04)
  </output>
  </content>
  </invoke>```

  ### Plan 06-13
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 13
  type: execute
  wave: 9
  depends_on: []
  files_modified:
    - packages/game-logic/src/constants.ts
    - packages/game-logic/src/step.ts
    - packages/game-logic/test/movement-constants.test.ts
    - packages/game-logic/test/step-bno-fidelity.test.ts
    - apps/client/src/prediction/predictor.ts
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md
  autonomous: true
  requirements: [CLI-04]
  must_haves:
    truths:
      - "Movement numeric constants in packages/game-logic/src/constants.ts are derived from extracted Client 5-8 GML — NOT invented arbitrarily — and every constant carries a comment citing its source GML file + line"
      - "06-13-MOVEMENT-DERIVATION.md records the derivation trail: which GML files were read, what each numeric value was originally, how it maps onto packages/game-logic units (px/tick, sub-pixel accumulator, tick rate)"
      - "Unit tests pin every derived constant — any future tweak that changes walk speed, friction, key-buffering, sub-pixel accumulator behavior, or tick-rate assumption must update the constant AND the test in the same commit (regression guard against silent drift)"
      - "step() in packages/game-logic/src/step.ts consumes the constants from constants.ts (not inline magic numbers); deterministic output verified by snapshot test against a known-good input/output trace from BNO-faithful expected values"
      - "PredictionEngine in apps/client/src/prediction/predictor.ts uses the same constants (no client-side override); the lint-prevented import of arbitrary numbers is preserved"
      - "CLAUDE.md hard rule #4/#5/#6 honored: extract → document → rewrite. Numbers come from extracted GML (already done in Phase 1), document the derivation (this plan's MOVEMENT-DERIVATION.md), then code consumes (constants.ts + step.ts)"
      - "Player object naming gotcha honored: the canonical mover is `extracted/client-5-8/objects/0000-server/` events (NOT 0042-player or any navi* script — addendum line 387)"
    artifacts:
      - path: packages/game-logic/src/constants.ts
        provides: "Movement constants derived from extracted GML — walk_speed_px_per_tick, key_buffer_window_ms, friction_decel_px_per_tick_sq, accel_px_per_tick_sq, sub_pixel_accumulator_mode, tick_rate_hz; each carries a comment citing source GML file + line"
        contains: "WALK_SPEED"
      - path: packages/game-logic/src/step.ts
        provides: "Pure step(state, inputs, dt) consuming constants.ts; preserves Phase 4 D-20 purity (no Date.now, no Math.random, no I/O)"
        contains: "WALK_SPEED"
      - path: packages/game-logic/test/movement-constants.test.ts
        provides: "Pinning tests — each constant has a test asserting its exact derived value with a comment citing the GML source line; any drift fails the test"
        contains: "WALK_SPEED"
      - path: packages/game-logic/test/step-bno-fidelity.test.ts
        provides: "Snapshot tests — known input vectors (e.g. 'press right for 60 ticks at 30hz') produce known position outputs matching BNO behavior; deterministic"
        contains: "fidelity"
      - path: apps/client/src/prediction/predictor.ts
        provides: "PredictionEngine consumes packages/game-logic step() with no override — no inline magic numbers"
        contains: "step"
      - path: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md
        provides: "Doc-only: derivation trail mapping every GML line to the constant in constants.ts; auditable evidence the numbers are not invented"
        contains: "0000-server"
    key_links:
      - from: packages/game-logic/src/constants.ts
        to: extracted/client-5-8/objects/0000-server/events/Step.gml + Keyboard-37/38/39/40.gml
        via: "every export carries a // SOURCE: comment citing the extracted GML file + line; lint can later assert (out of scope here)"
        pattern: "SOURCE:.*extracted/client-5-8"
      - from: packages/game-logic/src/step.ts
        to: packages/game-logic/src/constants.ts
        via: "import { WALK_SPEED, ACCEL, FRICTION, KEY_BUFFER_WINDOW_MS, TICK_RATE_HZ } from './constants'"
        pattern: "from './constants'"
      - from: apps/client/src/prediction/predictor.ts
        to: packages/game-logic/src/step.ts
        via: "PredictionEngine wraps step(state, inputs, dt) per Phase 6 D-11 extrapolation pattern; no override constants"
        pattern: "step|PredictionEngine"
      - from: packages/game-logic/test/movement-constants.test.ts
        to: packages/game-logic/src/constants.ts
        via: "import + assert each export against the value in MOVEMENT-DERIVATION.md"
        pattern: "expect.*WALK_SPEED|expect.*ACCEL"
  ---

  <objective>
  Close UAT Finding #3a (D-26 Plan A): movement does not match BNO because the prediction loop uses arbitrary client-invented constants instead of values derived from the original Client 5-8 GML. Read the canonical mover events on the original player-character object (`extracted/client-5-8/objects/0000-server/` — NOT 0042-player or navi* scripts; see CONTEXT addendum line 387), reverse the numeric constants (walk speed, key-buffering window, friction/accel if any, sub-pixel accumulator behavior, tick rate), pin them into `packages/game-logic/src/constants.ts`, update `step()` and `PredictionEngine` to consume the constants, write pinning unit tests + a BNO-fidelity snapshot test, and document the derivation trail in `06-13-MOVEMENT-DERIVATION.md`.

  Purpose: per CONTEXT D-26 Plan A — this plan is the numeric foundation. Plan B (06-16) will build the 8-direction sprite-state machine on top of these constants and on top of the asset atlases from 06-14. No asset dependency in this plan; runs in parallel with the other Wave-9 plan (06-12). CLAUDE.md hard rule #4/#5/#6 (extract → document → rewrite) is the governing principle.

  Output: a single canonical constants module + matching tests + derivation doc + minimal step() and PredictionEngine wiring updates. Per gap-closure addendum line 362 — `[impl/unit->REQ-CLI-04]` (movement prediction; numerics).
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-RESEARCH.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-PATTERNS.md
  @packages/game-logic/src/step.ts
  @apps/client/src/prediction/predictor.ts
  @apps/server/src/RebnoRoom.ts
  @decomp/wiki/15-extraction-pipeline.md
  @extracted/client-5-8/objects/0000-server/events/Step.gml
  @extracted/client-5-8/objects/0000-server/events/Step-1.gml
  @extracted/client-5-8/objects/0000-server/events/Step-2.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-37.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-38.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-39.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-40.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-37.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-38.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-39.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-40.gml
  @extracted/client-5-8/objects/0000-server/events/Create.gml
  @extracted/client-5-8/scripts/0285-player_update.gml
  @extracted/client-5-8/scripts/0347-iv_keyactions.gml
  @extracted/client-5-8/scripts/0355-pcode_mover.gml

  <interfaces>
  <!-- Existing interfaces — read each before editing. -->

  packages/game-logic/src/step.ts (Phase 4 D-20 — pure):
    export function step(state, inputs, dt) → state
    - Lint-enforced: no Date.now, no Math.random, no I/O
    - Currently consumes inline magic numbers (THIS plan extracts them to constants.ts)

  apps/client/src/prediction/predictor.ts (Phase 6 plan 06-XX):
    Wraps step() for client-side prediction; reconciler.ts handles divergence
    Currently re-derives some constants client-side (THIS plan removes that override)

  apps/server/src/RebnoRoom.ts (Phase 4 + Phase 6 D-09):
    tickLoop calls step() server-side — automatically picks up new constants
    TICK_RATE_HZ may be referenced here too (Phase 4 D-22 set it; verify the value matches what BNO uses, if not, this plan adjusts)

  GML reading guide (HARD RULES — read first):
    - 0000-server is the player character object in BNO (PROJECT.md gotcha — NOT 0042-player)
    - Step.gml / Step-1.gml / Step-2.gml — per-tick movement application
    - Keyboard-37/38/39/40 — held-key handlers (37=left, 38=up, 39=right, 40=down — Win VK codes)
    - KeyRelease-* — key-up handlers; reveal whether motion stops instantly (no friction) or decelerates
    - Create.gml — initial state; reveals starting speed/accel/friction values
    - Other-*.gml — special cases (room enter, etc.) — usually skip unless they touch motion vars
    - 0285-player_update.gml + 0347-iv_keyactions.gml + 0355-pcode_mover.gml — script-level callbacks invoked from the object events; read after the object events

  Numeric constants to derive (planner has named these; executor confirms exact values from GML):
    WALK_SPEED_PX_PER_TICK            — speed magnitude when a direction key is held (most likely "spd" or "sp" var in GML)
    ACCEL_PX_PER_TICK_SQ              — only if Step.gml shows acceleration (e.g. spd += accel until cap); else 0 (BNO is likely instant-stop, no accel)
    FRICTION_PX_PER_TICK_SQ           — only if KeyRelease shows decay; else 0 (instant stop on release)
    KEY_BUFFER_WINDOW_MS              — only if Keyboard-* shows key-pressed-recently grace; else 0
    SUB_PIXEL_ACCUMULATOR_MODE        — 'none' | 'snap-floor' | 'snap-round' | 'fractional' (look for `x = floor(x + ...)` or `x += ...` patterns; GM5 may store integer vs real)
    TICK_RATE_HZ                      — `room_speed` global in Create.gml or first room init (commonly 30 or 60 in GM5; BNO is probably 30)
    STARTING_DIRECTION                — direction at spawn (likely DOWN for BNO)

  If a GML construct doesn't translate cleanly (e.g. GM5 uses `motion_set(direction, speed)` builtins), document the equivalent in the derivation doc and pick the closest TS-numeric model.

  Lint guard (deferred to a future plan, NOT in scope here): a future lint could enforce that every export in constants.ts carries a SOURCE: comment. Plan now by writing the comments; the lint can pin later.

  Tagging contract:
    packages/game-logic/src/constants.ts → // [impl->REQ-CLI-04]
    packages/game-logic/src/step.ts (header)→ // [impl->REQ-CLI-04]
    packages/game-logic/test/*.test.ts → // [unit->REQ-CLI-04]
    apps/client/src/prediction/predictor.ts (header) → // [impl->REQ-CLI-04]
    06-13-MOVEMENT-DERIVATION.md → [doc->REQ-CLI-04]
  </interfaces>
  </context>

  <tasks>

  <task type="auto">
    <name>Task 1: Reverse-engineer movement constants from extracted GML; document derivation</name>
    <files>
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md,
      packages/game-logic/src/constants.ts
    </files>
    <read_first>
      extracted/client-5-8/objects/0000-server/events/Create.gml (look for: spd, sp, hsp, vsp, friction, accel, room_speed; whatever variables are initialized HERE govern the rest),
      extracted/client-5-8/objects/0000-server/events/Step.gml + Step-1.gml + Step-2.gml (per-tick movement application — confirm what hsp/vsp do per tick),
      extracted/client-5-8/objects/0000-server/events/Keyboard-37.gml + 38.gml + 39.gml + 40.gml (held-key handlers — what speed do they set? do they set vector or speed+direction?),
      extracted/client-5-8/objects/0000-server/events/KeyRelease-37.gml + 38.gml + 39.gml + 40.gml (key-up — instant stop or friction-decay?),
      extracted/client-5-8/scripts/0285-player_update.gml + 0347-iv_keyactions.gml + 0355-pcode_mover.gml (cross-reference — does the player object call these scripts? what do they do?),
      extracted/client-5-8/objects/0000-server/meta.json (object metadata — sprite refs, depth, persistence; informs derivation context),
      extracted/client-5-8/rooms/0058-BNCentral/meta.json (look for room_speed if not in object Create; rooms can override)
    </read_first>
    <action>
  **1. Read all the GML files above.** Note observations as you go. Common GM5 patterns:

  | GML pattern | Meaning |
  |-------------|---------|
  | `room_speed = 30;` (in room init or game setup) | TICK_RATE_HZ = 30 |
  | `spd = 4;` or `sp = 4;` | WALK_SPEED_PX_PER_TICK = 4 (verify the unit; some games store px/sec) |
  | `motion_set(0, 4);` | direction-aware motion: 0 = right, 90 = up, 180 = left, 270 = down; speed 4 |
  | `hsp = 4; vsp = 0;` | direct h/v vector |
  | `x += hsp; y += vsp;` (in Step) | applied per tick — px/tick |
  | `if (keyboard_check(vk_right)) hsp = 4;` | instant-set; no accel |
  | `if (keyboard_check(vk_right)) hsp += 0.5;` | accelerating; FRICTION + ACCEL apply |
  | `hsp *= 0.9;` (in KeyRelease or Step when no key) | friction = 0.1 per tick (decay factor 0.9) |
  | `hsp = 0;` (in KeyRelease) | instant stop, no friction |
  | `x = round(x + hsp);` or `x += hsp; x = floor(x);` | sub-pixel: snap-round / snap-floor |
  | `x += hsp;` (no rounding, x is real) | sub-pixel: fractional |

  **2. Write `06-13-MOVEMENT-DERIVATION.md`** documenting the derivation:

  ```md
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 13
  type: doc-only-derivation
  status: complete
  ---

  # Movement Constants — Derivation from Extracted Client 5-8 GML

  [doc->REQ-CLI-04]

  > Per CLAUDE.md hard rule #4/#5/#6: extract → document → rewrite. Phase 1 produced `extracted/client-5-8/`; this document maps each numeric constant in `packages/game-logic/src/constants.ts` to its source GML line, satisfying the "document" stage.

  ## Source GML (read by this plan)

  | File | Role in derivation |
  |------|--------------------|
  | `extracted/client-5-8/objects/0000-server/events/Create.gml` | Initial values: spd / sp / hsp / vsp / friction / accel / room_speed if set here |
  | `extracted/client-5-8/objects/0000-server/events/Step.gml` (+ Step-1 + Step-2) | Per-tick application: how x/y advance, sub-pixel handling |
  | `extracted/client-5-8/objects/0000-server/events/Keyboard-37/38/39/40.gml` | Held-key direction handlers (Win VK 37-40 = L/U/R/D) |
  | `extracted/client-5-8/objects/0000-server/events/KeyRelease-37/38/39/40.gml` | Key-up: instant stop vs friction decay |
  | `extracted/client-5-8/scripts/0285-player_update.gml` | Script invoked from object events (cross-reference) |
  | `extracted/client-5-8/scripts/0347-iv_keyactions.gml` | Input-vector mapping (cross-reference) |
  | `extracted/client-5-8/scripts/0355-pcode_mover.gml` | Mover pcode (cross-reference) |
  | `extracted/client-5-8/rooms/0058-BNCentral/meta.json` | room_speed override if present |

  ## Derivation table

  | Constant | Value | Source | Notes |
  |----------|-------|--------|-------|
  | `TICK_RATE_HZ` | _<derived>_ | _<file>:<line>_ | _<verbatim GML quote>_ |
  | `WALK_SPEED_PX_PER_TICK` | _<derived>_ | _<file>:<line>_ | _<verbatim>_ |
  | `ACCEL_PX_PER_TICK_SQ` | _<derived or 0>_ | _<file>:<line>_ or "absent — instant-set per Keyboard-* events" | |
  | `FRICTION_PX_PER_TICK_SQ` | _<derived or 0>_ | _<file>:<line>_ or "absent — instant-stop per KeyRelease-* events" | |
  | `KEY_BUFFER_WINDOW_MS` | _<derived or 0>_ | _<file>:<line>_ or "absent — direct keyboard_check, no buffering" | |
  | `SUB_PIXEL_ACCUMULATOR_MODE` | _'fractional' | 'snap-floor' | 'snap-round' | 'none'_ | _<file>:<line>_ | _<verbatim quote of x/y assignment line>_ |
  | `STARTING_DIRECTION` | _<derived; likely 270 = DOWN>_ | _<Create.gml line>_ | |
  | `STARTING_X`, `STARTING_Y` | _<derived if present in Create>_ | _<Create.gml line>_ | (informational; server-side D-07 home-portal spawn overrides) |

  ## GML constructs that don't translate directly

  _(For each: brief note on the GM5 builtin used and the chosen TS equivalent.)_

  - `motion_set(dir, speed)` → equivalent: hsp = cos(dir°) * speed; vsp = -sin(dir°) * speed (TS step() applies)
  - _<other constructs encountered>_

  ## Decisions made by this plan

  _(Document any judgement calls — e.g. "GML uses `keyboard_check_pressed` for buffering at line N; we map this to KEY_BUFFER_WINDOW_MS = X")_

  ## Open questions for 06-16 (sprite-state plan)

  The 8-direction facing-on-input semantics will read additional events:
  - Draw.gml — sprite selection per facing direction
  - Step events again — facing transitions

  Out of scope for this numeric-only plan but flagged so 06-16 doesn't have to rediscover the source files.
  ```

  **Fill in the table from your GML reading. Use exact verbatim quotes.** If a constant is "absent" (e.g. no friction in BNO), say so explicitly with the negative-evidence quote.

  **3. Write `packages/game-logic/src/constants.ts`** — Tag at top:

  ```ts
  // [impl->REQ-CLI-04]
  // Movement constants derived from extracted Client 5-8 GML.
  // See .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md
  // Every export here carries a SOURCE: comment citing the extracted GML file + line.
  // Pure data module — no Date.now, no Math.random, no I/O (Phase 4 D-20 lint applies).

  /** Tick rate. SOURCE: extracted/client-5-8/objects/0000-server/events/Create.gml:<line> ("room_speed = N;") */
  export const TICK_RATE_HZ = <DERIVED>;

  /** Walk speed in pixels per tick when a direction key is held.
   *  SOURCE: extracted/client-5-8/objects/0000-server/events/Keyboard-39.gml:<line> ("hsp = N;" or "spd = N;") */
  export const WALK_SPEED_PX_PER_TICK = <DERIVED>;

  /** Acceleration in px/tick² (0 if BNO uses instant-set, no acceleration).
   *  SOURCE: <file>:<line> (or "absent — instant-set per Keyboard-* handlers") */
  export const ACCEL_PX_PER_TICK_SQ = <DERIVED>;

  /** Friction (deceleration) in px/tick² (0 if KeyRelease sets hsp = 0 directly).
   *  SOURCE: <file>:<line> (or "absent — instant-stop per KeyRelease-* handlers") */
  export const FRICTION_PX_PER_TICK_SQ = <DERIVED>;

  /** Key-buffering window in ms (0 if BNO uses keyboard_check directly without buffering).
   *  SOURCE: <file>:<line> (or "absent") */
  export const KEY_BUFFER_WINDOW_MS = <DERIVED>;

  /** Sub-pixel accumulator mode.
   *  SOURCE: extracted/client-5-8/objects/0000-server/events/Step.gml:<line> ("x += hsp;" → 'fractional', or "x = floor(x + hsp);" → 'snap-floor') */
  export const SUB_PIXEL_ACCUMULATOR_MODE = <'fractional' | 'snap-floor' | 'snap-round' | 'none'> as const;

  /** Starting facing direction. 270 = DOWN per BNO sprite convention.
   *  SOURCE: extracted/client-5-8/objects/0000-server/events/Create.gml:<line> */
  export const STARTING_DIRECTION_DEGREES = <DERIVED>;
  ```

  (Fill `<DERIVED>` from your GML reading. If a value is uncertain, prefer the most conservative interpretation — instant-set/instant-stop defaults — and document the uncertainty in MOVEMENT-DERIVATION.md.)
    </action>
    <verify>
      <automated>node -e "const fs=require('fs'); const c=fs.readFileSync('packages/game-logic/src/constants.ts','utf-8'); if (c.includes('<DERIVED>')) { console.error('UNRESOLVED: <DERIVED> placeholder left in constants.ts — fill all values from extracted GML before commit'); process.exit(1); } const required=['TICK_RATE_HZ','WALK_SPEED_PX_PER_TICK','ACCEL_PX_PER_TICK_SQ','FRICTION_PX_PER_TICK_SQ','KEY_BUFFER_WINDOW_MS','SUB_PIXEL_ACCUMULATOR_MODE','SOURCE:','extracted/client-5-8']; for (const s of required) { if (!c.includes(s)) { console.error('MISSING in constants.ts:', s); process.exit(1); } } const d=fs.readFileSync('.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md','utf-8'); if (d.includes('<DERIVED>')) { console.error('UNRESOLVED: <DERIVED> placeholder left in derivation doc'); process.exit(1); } for (const s of ['Derivation table','TICK_RATE_HZ','WALK_SPEED_PX_PER_TICK','SUB_PIXEL_ACCUMULATOR_MODE','0000-server']) { if (!d.includes(s)) { console.error('MISSING in derivation doc:', s); process.exit(1); } } console.log('constants + derivation present, no placeholders remain');"</automated>
    </verify>
    <done>
      constants.ts exports 6+ named constants, each with a SOURCE: comment citing extracted GML. 06-13-MOVEMENT-DERIVATION.md records the derivation trail with file+line citations and verbatim GML quotes. Any 'absent' constant is explicitly flagged with negative evidence. No source files outside packages/game-logic/src/constants.ts touched in this task.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Update step() + PredictionEngine to consume constants; pin with unit + fidelity tests</name>
    <files>
      packages/game-logic/src/step.ts,
      packages/game-logic/test/movement-constants.test.ts,
      packages/game-logic/test/step-bno-fidelity.test.ts,
      apps/client/src/prediction/predictor.ts
    </files>
    <read_first>
      packages/game-logic/src/step.ts (current implementation — find the inline magic numbers being replaced),
      packages/game-logic/test/*.test.ts (existing test layout — copy harness shape; vitest or node:test depending on what's already there),
      apps/client/src/prediction/predictor.ts (find any inline constants the client-side overrode; remove and use the canonical ones),
      apps/server/src/RebnoRoom.ts (cross-check: server tickLoop should use step() unmodified — verify TICK_RATE_HZ here matches packages/game-logic constants.ts; if drift, this plan unifies),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md (your own derivation doc — the test expected values come from here)
    </read_first>
    <behavior>
      - movement-constants.test.ts: one test per exported constant in constants.ts; each test asserts the exact derived value with a comment citing the GML source line; test fails if any export's value drifts
      - step-bno-fidelity.test.ts: a small set of golden input → output snapshots — e.g. "from (0, 0), press right for 60 ticks at TICK_RATE_HZ → end position is (60 * WALK_SPEED_PX_PER_TICK, 0)"; "press right for 1 tick then release → end position depends on FRICTION; if FRICTION=0, snap to (WALK_SPEED, 0); if FRICTION>0, decay to 0 over N ticks"; each scenario calls step() repeatedly and asserts the trajectory
      - PredictionEngine in apps/client/src/prediction/predictor.ts no longer overrides any movement constant — fully consumes step() output
      - All tests are deterministic (no Date.now, no Math.random)
    </behavior>
    <action>
  **1. packages/game-logic/src/step.ts** — Tag `// [impl->REQ-CLI-04]` near the export. Replace any inline magic numbers (e.g. `4` for speed, `30` for tick rate, `0.9` for friction factor) with imports from `./constants`:

  ```ts
  // [impl->REQ-CLI-04]
  import {
    WALK_SPEED_PX_PER_TICK,
    ACCEL_PX_PER_TICK_SQ,
    FRICTION_PX_PER_TICK_SQ,
    TICK_RATE_HZ,
    SUB_PIXEL_ACCUMULATOR_MODE,
  } from './constants';

  // ... existing step() signature unchanged ...
  // Replace inline numbers with the imported constants. Sub-pixel handling
  // follows SUB_PIXEL_ACCUMULATOR_MODE per the derivation doc.
  ```

  (The exact replacement depends on the existing step.ts shape. The executor reads it, identifies every magic number used in motion math, and substitutes the named constant. If step() currently does NOT exist or is a stub, implement the minimal correct shape per the derivation: instant-set on key-down, instant-stop on key-release, sub-pixel mode applied per accumulator MODE.)

  **2. packages/game-logic/test/movement-constants.test.ts** — Tag `// [unit->REQ-CLI-04]`.

  ```ts
  // [unit->REQ-CLI-04]
  // Pinning tests for movement constants. Drift = test failure.
  // Source values: see ../src/constants.ts SOURCE: comments + 06-13-MOVEMENT-DERIVATION.md.

  import { describe, it, expect } from 'vitest'; // or node:test if that's the existing harness
  import {
    TICK_RATE_HZ,
    WALK_SPEED_PX_PER_TICK,
    ACCEL_PX_PER_TICK_SQ,
    FRICTION_PX_PER_TICK_SQ,
    KEY_BUFFER_WINDOW_MS,
    SUB_PIXEL_ACCUMULATOR_MODE,
    STARTING_DIRECTION_DEGREES,
  } from '../src/constants';

  describe('movement constants — derived from extracted Client 5-8 GML', () => {
    it('TICK_RATE_HZ pins to BNO room_speed', () => {
      expect(TICK_RATE_HZ).toBe(<DERIVED>); // SOURCE: <file>:<line>
    });
    it('WALK_SPEED_PX_PER_TICK pins to BNO Keyboard-* hsp/spd value', () => {
      expect(WALK_SPEED_PX_PER_TICK).toBe(<DERIVED>);
    });
    it('ACCEL_PX_PER_TICK_SQ pins (0 if instant-set in BNO)', () => {
      expect(ACCEL_PX_PER_TICK_SQ).toBe(<DERIVED>);
    });
    it('FRICTION_PX_PER_TICK_SQ pins (0 if instant-stop in BNO)', () => {
      expect(FRICTION_PX_PER_TICK_SQ).toBe(<DERIVED>);
    });
    it('KEY_BUFFER_WINDOW_MS pins (0 if direct keyboard_check)', () => {
      expect(KEY_BUFFER_WINDOW_MS).toBe(<DERIVED>);
    });
    it('SUB_PIXEL_ACCUMULATOR_MODE pins to Step.gml x/y assignment style', () => {
      expect(SUB_PIXEL_ACCUMULATOR_MODE).toBe(<'fractional' | 'snap-floor' | 'snap-round' | 'none'>);
    });
    it('STARTING_DIRECTION_DEGREES pins (270 = DOWN per BNO convention)', () => {
      expect(STARTING_DIRECTION_DEGREES).toBe(<DERIVED>);
    });
  });
  ```

  **3. packages/game-logic/test/step-bno-fidelity.test.ts** — Tag `// [unit->REQ-CLI-04]`.

  ```ts
  // [unit->REQ-CLI-04]
  // BNO movement fidelity — golden input/output snapshots from derived constants.
  // Each scenario calls step() N times and asserts the deterministic trajectory.

  import { describe, it, expect } from 'vitest';
  import { step } from '../src/step';
  import { WALK_SPEED_PX_PER_TICK, FRICTION_PX_PER_TICK_SQ } from '../src/constants';

  const initState = () => ({ /* match the existing state shape — players: { local: { x: 0, y: 0, ... } } */ });
  const inputsHeldRight = { axes: { x: 1, y: 0 }, /* ... */ };
  const inputsRelease = { axes: { x: 0, y: 0 }, /* ... */ };

  describe('step() fidelity vs BNO constants', () => {
    it('press right for 60 ticks → x advances by 60 * WALK_SPEED_PX_PER_TICK (instant-set, no accel)', () => {
      let s = initState();
      for (let i = 0; i < 60; i++) {
        s = step(s, inputsHeldRight, 1 / TICK_RATE_HZ);
      }
      expect(s.players.local.x).toBe(60 * WALK_SPEED_PX_PER_TICK);
      expect(s.players.local.y).toBe(0);
    });

    it('press right for 1 tick then release → instant-stop OR friction-decay per FRICTION', () => {
      let s = initState();
      s = step(s, inputsHeldRight, 1 / TICK_RATE_HZ);
      const xAfterPress = s.players.local.x;
      s = step(s, inputsRelease, 1 / TICK_RATE_HZ);
      if (FRICTION_PX_PER_TICK_SQ === 0) {
        // Instant stop: x stays at xAfterPress (no further motion)
        expect(s.players.local.x).toBe(xAfterPress);
      } else {
        // Friction-decay: x advances less than another full WALK_SPEED on the next tick
        expect(s.players.local.x).toBeLessThan(xAfterPress + WALK_SPEED_PX_PER_TICK);
        expect(s.players.local.x).toBeGreaterThanOrEqual(xAfterPress);
      }
    });

    it('determinism: same input → same output across 1000 runs', () => {
      let final1 = initState();
      let final2 = initState();
      for (let i = 0; i < 100; i++) {
        final1 = step(final1, inputsHeldRight, 1 / TICK_RATE_HZ);
        final2 = step(final2, inputsHeldRight, 1 / TICK_RATE_HZ);
      }
      expect(final1).toEqual(final2);
    });
  });
  ```

  (Adjust the state shape + step() signature to match the existing module. The TICK_RATE_HZ import is needed if the dt parameter is in seconds.)

  **4. apps/client/src/prediction/predictor.ts** — Tag `// [impl->REQ-CLI-04]` at top. Find any local `const SPEED = 4` or similar magic numbers; replace with imports from `@rebno/game-logic/constants` (or whatever the workspace export path is). If predictor.ts already correctly delegates to step() without overrides, just add the tag and a comment noting "no client-side override; consumes packages/game-logic step() unmodified per Phase 6 D-11."

  **5. Verify server-side too**: read `apps/server/src/RebnoRoom.ts` for any hardcoded `30` (tick rate) or duplicated movement constants. If found, the executor MAY (within this plan's scope) update RebnoRoom.ts to import TICK_RATE_HZ from `@rebno/game-logic/constants` to keep the server in sync. If the server uses a different tick-loop module, document the cross-reference but do NOT block — Phase 4 D-22 already established the server-side rate; this plan's responsibility is the game-logic + client side.
    </action>
    <verify>
      <automated>pnpm --filter @rebno/game-logic test &amp;&amp; pnpm --filter @rebno/client typecheck</automated>
    </verify>
    <done>
      step.ts imports all motion constants from constants.ts (no inline magic numbers in motion math). predictor.ts has no client-side override of WALK_SPEED / FRICTION / ACCEL / TICK_RATE. movement-constants.test.ts pins 7 constants (one test each). step-bno-fidelity.test.ts has 3+ golden trajectory tests including a determinism check. All tests GREEN. RebnoRoom.ts cross-reference verified (either uses constants from @rebno/game-logic or documented why not in the SUMMARY).
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Extracted GML → derived TS constants | If derivation misreads a GML expression (e.g. units px/tick vs px/sec), client + server simulate at wrong speed → players see desync; mitigated by Plan A's pinning tests + 06-17 UAT video evidence |
  | packages/game-logic constants → server step() | Server is authoritative; if server is faster than client, prediction overshoots → reconciliation snaps; if slower, prediction undershoots → reconciliation catches up. D-10 threshold absorbs small drift; large drift means a constants bug |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-13-01 | Tampering | Future PR silently changes WALK_SPEED to "feel better" — diverges from BNO fidelity | mitigate | movement-constants.test.ts pins exact value; SOURCE: comment forces PR author to update derivation doc + GML citation |
  | T-06-13-02 | Information leakage | None — constants are not secrets | accept | n/a |
  | T-06-13-03 | Repudiation | "We don't know where this number came from" | mitigate | SOURCE: comment + MOVEMENT-DERIVATION.md = full trail from GML line → constant → test |
  | T-06-13-04 | DoS | A constant set wrong (e.g. WALK_SPEED = 1000) creates server-side spd that exceeds rate-limit / collision step → bug, not security; caught by step-bno-fidelity tests |
  </threat_model>

  <verification>
  - `pnpm --filter @rebno/game-logic test` exits 0 (movement-constants + step-bno-fidelity all GREEN)
  - `pnpm --filter @rebno/client typecheck` exits 0
  - `pnpm --filter @rebno/client test` exits 0 (no PredictionEngine regressions)
  - `pnpm trace:check` reports `[impl->REQ-CLI-04]` and `[unit->REQ-CLI-04]` tags satisfied for the new files
  - 06-13-MOVEMENT-DERIVATION.md exists, has the derivation table fully populated (no `<DERIVED>` placeholders left)
  - Manual smoke (deferred to 06-17 UAT): two-client move feels closer to BNO. Final lock at 06-17 UAT video.
  </verification>

  <success_criteria>
  - Numeric movement constants derived from extracted Client 5-8 GML (per CLAUDE.md hard rule #4/#5/#6) and pinned in code + tests
  - Derivation trail auditable in 06-13-MOVEMENT-DERIVATION.md
  - step() and PredictionEngine consume constants from a single source (constants.ts) — no client/server drift
  - Plan B (06-16 sprite-state machine) has a stable numeric foundation to build on
  - Tags `[impl/unit->REQ-CLI-04]` (per gap-closure addendum line 362)
  - Player object naming gotcha (0000-server, NOT 0042-player or navi*) honored throughout
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-SUMMARY.md` recording:
  (a) Final values of all 7+ derived constants (paste from constants.ts)
  (b) Any GML-construct-doesn't-translate notes (e.g. motion_set → cos/sin decomposition)
  (c) Any uncertainty flagged in derivation (acknowledge if a value was best-guess from ambiguous GML)
  (d) Cross-reference outcome: did RebnoRoom.ts get updated to import TICK_RATE_HZ from constants.ts, or is the server's existing value verified to match?
  (e) Confirmation 06-16 (sprite-state plan) has a stable numeric foundation
  (f) Trace tag verification snippet for REQ-CLI-04 impl + unit stages
  </output>
  </content>
  </invoke>```

  ### Plan 06-14
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 14
  type: execute
  wave: 10
  depends_on: []
  files_modified:
    - tools/asset-pipeline/src/bootstrap.ts
    - tools/asset-pipeline/cli.ts
    - tools/asset-pipeline/test/bootstrap.test.ts
    - tools/asset-pipeline/src/build.ts
    - tools/room-converter/src/bn-central.ts
    - tools/room-converter/test/bn-central.test.ts
    - apps/server/rooms/bn-central/000.json
    - apps/server/rooms/bn-central/000.sig
    - apps/server/src/RebnoRoom.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/assets/room-renderer.ts
    - apps/client/src/assets/atlas-loader.ts
    - apps/client/src/render/PlayerRenderer.ts
    - apps/client/test/e2e/cli-08.e2e.test.ts
    - assets/source/sprites/.gitkeep
    - package.json
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md
  autonomous: true
  requirements: [AST-01, CLI-07]
  must_haves:
    truths:
      - "tools/asset-pipeline ships a `bootstrap` mode that reads extracted/client-5-8/sprites/<id>/frames/img_*.bmp + meta.json and emits assets/source/sprites/<id>.{png,json} (Aseprite-compatible PNG strip + JSON sidecar)"
      - "All 15 sprite IDs from CONTEXT addendum line 336 (0000, 0027, 0029-0033, 0036-0043) are bootstrapped → committed Aseprite-compatible source files; tile/door sprites referenced by 0058-BNCentral/tiles.json are also bootstrapped"
      - "tools/asset-pipeline `build` packs the bootstrapped sprites into atlas-mvp.{png,json} via existing 06-08 build mode (extending, not rebuilding)"
      - "tools/room-converter has a bn-central converter that reads extracted/client-5-8/rooms/0058-BNCentral/{instances.json,tiles.json,creation-code.gml,backgrounds.json,meta.json} and emits apps/server/rooms/bn-central/000.json conforming to Phase 4 SRV-13 room-layout schema + Ed25519 signed (000.sig) per Phase 4 D-12"
      - "Server room registration in apps/server/src/RebnoRoom.ts replaces 'mvp-lobby' with 'bn-central' (or registers both during transition; final state = bn-central is the canonical room for CLI-08 UAT)"
      - "Client GameScene + RoomRenderer + PlayerRenderer load atlas-mvp.{png,json} and render real BNO sprites (replacing the blue rectangle placeholder); PlayerRenderer.addRemote/ensureLocal use NaviStandD as default frame"
      - "Playwright e2e (06-08's cli-08.e2e.test.ts) updated to assert data-room-id='bn-central' and that a non-blue sprite is rendered (e.g. canvas pixel sample at player position is NOT solid #0000FF / blue rectangle marker)"
      - "Replaces synthetic mvp-lobby — does NOT keep both as production rooms; mvp-lobby may stay as a dev-only / test-only fallback at Claude's discretion but bn-central is the canonical CLI-08 room"
      - "First-pass scope EXCLUDES audio (Phase 7 AST-02), fonts (Phase 7 AST-03), other 9 extracted rooms (Phase 7 PAR-03) — explicitly per addendum line 340"
    artifacts:
      - path: tools/asset-pipeline/src/bootstrap.ts
        provides: "Bootstrap mode entry point — reads extracted sprite frames + meta, emits Aseprite-compatible source files via sharp BMP→PNG strip"
        contains: "bootstrap"
      - path: tools/asset-pipeline/cli.ts
        provides: "CLI dispatcher — adds `bootstrap` subcommand alongside the existing `build`"
        contains: "bootstrap"
      - path: tools/room-converter/src/bn-central.ts
        provides: "BN Central room converter — bridges extracted/client-5-8/rooms/0058-BNCentral/* → apps/server/rooms/bn-central/000.json (SRV-13 schema)"
        contains: "0058-BNCentral"
      - path: apps/server/rooms/bn-central/000.json
        provides: "Signed room-layout JSON (SRV-13 contract) for BN Central — instances + tiles + backgrounds, ready for s2c.room_layout broadcast"
        contains: "bn-central"
      - path: apps/server/rooms/bn-central/000.sig
        provides: "Ed25519 signature over 000.json (Phase 4 D-12 / Phase 5 D-19 ritual)"
        contains: ""
      - path: assets/source/sprites/.gitkeep
        provides: "Marks the directory as committed; bootstrap output (per-sprite .png + .json) goes here per CONTEXT D-14"
        contains: ""
      - path: apps/client/src/render/PlayerRenderer.ts
        provides: "Loads atlas-mvp; renders real NaviStandD sprite for self + remotes (no more blue rectangle); 8-direction sprite SELECTION wiring lives in 06-16, not here — this plan ships StandD as the default-frame baseline"
        contains: "NaviStandD"
      - path: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md
        provides: "Doc-only — lists every sprite/tile bootstrapped + the 0058-BNCentral conversion summary; auditable AST-01 first-pass scope"
        contains: "0027"
    key_links:
      - from: tools/asset-pipeline/src/bootstrap.ts
        to: extracted/client-5-8/sprites/<id>/frames/img_*.bmp + meta.json
        via: "sharp.raw().resize().png() pipeline; reads meta.json for origin + frame_count; emits assets/source/sprites/<id>.{png,json}"
        pattern: "extracted/client-5-8/sprites"
      - from: tools/room-converter/src/bn-central.ts
        to: extracted/client-5-8/rooms/0058-BNCentral/*.json + creation-code.gml
        via: "JSON parse + GML lex (light — extract instance.create_code if needed); maps to SRV-13 layout shape"
        pattern: "0058-BNCentral"
      - from: apps/server/src/RebnoRoom.ts
        to: apps/server/rooms/bn-central/000.json
        via: "broadcastRoomLayout reads bn-central instead of mvp-lobby; room registration matchmaker.defineRoomType('bn-central', ...)"
        pattern: "bn-central"
      - from: apps/client/src/scenes/GameScene.ts
        to: apps/client/src/render/PlayerRenderer.ts + apps/client/src/assets/atlas-loader.ts
        via: "preload atlas-mvp via this.load.atlas('mvp', '/atlas-mvp.png', '/atlas-mvp.json'); PlayerRenderer.ensureLocal/addRemote use atlas frame keys"
        pattern: "atlas-mvp"
      - from: apps/client/test/e2e/cli-08.e2e.test.ts
        to: data-room-id="bn-central" + non-blue-pixel assertion
        via: "expect(canvas.evaluate(canvas.getAttribute('data-room-id'))).toBe('bn-central')"
        pattern: "bn-central"
  ---

  <objective>
  Close UAT Finding #4 (D-27): there are no real BNO assets in the project — GameScene shows blue rectangles + cyan nameplates over an empty black background, and the synthetic `mvp-lobby` room used in 06-07 has no relationship to the original game. Bridge the existing `tools/asset-pipeline/` (06-08 lint + manifest schema; never piped real input) with a `bootstrap` mode that converts extracted Client 5-8 BMP sprite frames + meta into Aseprite-compatible PNG-strip source files; pack via the existing `build` mode into `atlas-mvp.{png,json}`; convert the canonical first-pass room (`extracted/client-5-8/rooms/0058-BNCentral/` — the actual latest online lobby in BNO, NOT the older `0004-Online_Lobby`) into a Phase 4 SRV-13 room-layout JSON signed per Phase 4 D-12; wire server room registration to use `bn-central` (replacing `mvp-lobby`); update GameScene + PlayerRenderer + RoomRenderer to load the atlas and render real sprites + the BN Central room background and tiles; update the 06-08 Playwright e2e to assert `data-room-id="bn-central"` and that the player render is no longer a solid-blue rectangle.

  Purpose: per CONTEXT D-27 — first-pass real assets are the unblock for UAT Test 1 (HiDPI nearest-neighbor — needs real sprites to evaluate pixel correctness), Test 2 (CLI-08 milestone artifact — needs real game presentation), Test 3 (Aseprite round-trip — needs source files committed). Player object naming gotcha (0000-server, NOT 0042-player) and room naming gotcha (0058-BNCentral, NOT 0004-Online_Lobby) are honored. Nametag (D-27a) ships in 06-16 (where the per-player render path lives), NOT here.

  Output: extended asset-pipeline bootstrap mode + bn-central room converter + signed room JSON + server registration update + client atlas loader + PlayerRenderer real-sprite rendering + e2e assertion update + asset manifest doc. Per gap-closure addendum line 363 — `[impl->REQ-AST-01] [impl->REQ-CLI-07]`.

  This is the largest plan in the gap-closure set (3 tasks, multiple subsystems). Splitting further would fragment a single coherent feature ("real assets land end-to-end"); the natural seam is bootstrap+atlas (Task 1) → room conversion + server (Task 2) → client render + e2e (Task 3).
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-RESEARCH.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-PATTERNS.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-08-SUMMARY.md
  @tools/asset-pipeline/cli.ts
  @tools/asset-pipeline/src/build.ts
  @tools/asset-pipeline/src/manifest.ts
  @tools/asset-catalog/cli.ts
  @tools/extract-gmd/cli.ts
  @tools/room-converter/src/index.ts
  @tools/scripts/lint-room-layout.mjs
  @tools/scripts/lint-asset-pipeline.mjs
  @apps/server/src/RebnoRoom.ts
  @apps/server/rooms/mvp-lobby/000.json
  @apps/server/rooms/mvp-lobby/000.sig
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/assets/room-renderer.ts
  @apps/client/src/assets/atlas-loader.ts
  @apps/client/src/render/PlayerRenderer.ts
  @apps/client/test/e2e/cli-08.e2e.test.ts
  @apps/client/test/e2e/fixtures.ts
  @extracted/client-5-8/sprites/0000-NaviStandD/meta.json
  @extracted/client-5-8/sprites/0027-NaviRunD/meta.json
  @extracted/client-5-8/rooms/0058-BNCentral/meta.json
  @extracted/client-5-8/rooms/0058-BNCentral/instances.json
  @extracted/client-5-8/rooms/0058-BNCentral/tiles.json
  @extracted/client-5-8/rooms/0058-BNCentral/backgrounds.json
  @extracted/client-5-8/rooms/0058-BNCentral/creation-code.gml

  <interfaces>
  <!-- Existing tools/asset-pipeline shape (06-08 SUMMARY confirms shipped) -->

  tools/asset-pipeline/cli.ts:
    Subcommands today: `build`, `postprocess` (per 06-09 verify-phase-6.mjs steps 4 + 6)
    Add: `bootstrap`

  tools/asset-pipeline/src/build.ts:
    Reads assets/source/sprites/<id>.{png,json}; packs into atlas-mvp.{png,json}
    Uses sharp + a bin-pack algorithm
    Already shipped (06-08); THIS plan extends inputs (more sprites) but DOES NOT change the build algorithm

  tools/asset-pipeline/src/manifest.ts:
    Emits tools/asset-pipeline/output/pipeline-manifest.json with sha256 + paths
    Already shipped; THIS plan adds entries for the new sprites (manifest is regenerated)

  tools/scripts/lint-asset-pipeline.mjs:
    Validates pipeline-manifest.json schema_version === 1; cross-checks atlas sha256
    Already shipped; THIS plan's bootstrap output flows through it unchanged

  <!-- New: bootstrap mode contract -->

  bootstrap input shape:
    extracted/client-5-8/sprites/<id>/meta.json:
      { "name": "NaviStandD", "frame_w": <int>, "frame_h": <int>, "frame_count": <int>, "origin_x": <int>, "origin_y": <int>, ... }
    extracted/client-5-8/sprites/<id>/frames/img_NNN.bmp (one BMP per frame)

  bootstrap output shape (Aseprite-compatible — D-15):
    assets/source/sprites/<id>.png   (horizontal strip; frame_count frames laid out left-to-right at frame_w × frame_h)
    assets/source/sprites/<id>.json:
      {
        "frames": { "<id>_0": { "frame": { "x": 0, "y": 0, "w": frame_w, "h": frame_h } }, ... },
        "meta": {
          "image": "<id>.png",
          "format": "RGBA8888",
          "size": { "w": frame_count * frame_w, "h": frame_h },
          "scale": "1",
          "frame_w": frame_w, "frame_h": frame_h,
          "frame_count": frame_count,
          "origin": { "x": origin_x, "y": origin_y },
          "sprite_id": "<id>",
          "source": "extracted/client-5-8/sprites/<id>/"
        }
      }

    This shape matches Aseprite's `--format json-array` export (D-15) so devs can edit
    the .png + .json in Aseprite, re-export with the same shape, and re-run `build`
    without re-running `bootstrap`.

  <!-- Sprite IDs to bootstrap (CONTEXT addendum line 336): -->
    Player sprites (16 total):
      0000-NaviStandD, 0027-NaviRunD, 0028-NaviRunR,
      0029-NaviStandR, 0030-NaviStandU, 0031-NaviRunU, 0032-NaviRunL, 0033-NaviStandL,
      0036-NaviStandDR, 0037-NaviRunDR,
      0038-NaviStandUR, 0039-NaviRunUR,
      0040-NaviStandUL, 0041-NaviRunUL,
      0042-NaviStandDL, 0043-NaviRunDL
    Tile sprites:
      Whatever sprite IDs are referenced by 0058-BNCentral/tiles.json
      (read tiles.json — it lists tileset_id → sprite_id; bootstrap each)
      Plus any door/object sprites referenced by 0058-BNCentral/instances.json
      (read instances.json — `object_id` references; cross-look-up in extracted/client-5-8/objects/<id>/meta.json
      for the `sprite_id` field, then bootstrap that sprite)

  <!-- Room layout SRV-13 schema (Phase 4 D-12) -->
  apps/server/rooms/<room>/000.json:
    {
      "room_id": "bn-central",
      "version": 1,
      "width_tiles": <int>, "height_tiles": <int>,
      "tile_w": 44, "tile_h": 40,                           // Phase 4 / Phase 6 standard
      "background": { "sprite_id": "<bg_id>", "tile_x": 0, "tile_y": 0 },
      "tiles": [{ "tileset_sprite_id": "<id>", "x": <px>, "y": <px>, "frame": <int> }, ...],
      "instances": [{ "object_id": "<original_obj_id>", "x": <px>, "y": <px> }, ...],
      "spawn_points": [{ "name": "home_portal", "x": <px>, "y": <px> }, ...]
    }
    (Reference apps/server/rooms/mvp-lobby/000.json for the EXACT shape; mirror it.)

  apps/server/rooms/<room>/000.sig:
    Ed25519 signature over the canonical-JSON-bytes of 000.json
    Use the existing signing helper (Phase 4 D-12 / Phase 5 D-19 ritual)
    Private key extraction is operator-side via `fly ssh`; for local dev there is a dev keypair already
    per 06-08 fixtures and the Phase 5 ROOM_SIGNING_PRIVATE_KEY ritual.

  <!-- Server room registration (apps/server/src/RebnoRoom.ts) -->

  Today: matchmaker.defineRoomType('mvp-lobby', RebnoRoom, { layoutPath: 'apps/server/rooms/mvp-lobby/000.json' })
  After: matchmaker.defineRoomType('bn-central', RebnoRoom, { layoutPath: 'apps/server/rooms/bn-central/000.json' })

  home-portal spawn (Phase 6 D-07): on first-session join, server reads spawn_points.home_portal from the loaded layout
  and uses {x, y}; verify the bn-central room HAS a home_portal spawn point — if 0058-BNCentral/instances.json has a
  specific portal/door object marking the entry, that's it; if not, the planner picks a sensible center-of-room default
  (documented in 06-14-ASSET-MANIFEST.md).

  <!-- Client wiring -->

  apps/client/src/scenes/GameScene.ts (preload):
    this.load.atlas('mvp', '/atlas-mvp.png', '/atlas-mvp.json');
    // Vite dev: paths served from public/; staging/prod: served from apps/server/public/ via D-18 mount

  apps/client/src/render/PlayerRenderer.ts (replace blue rect):
    ensureLocal() / addRemote() build a Phaser.GameObjects.Sprite using atlas frame:
      const sprite = scene.add.sprite(x, y, 'mvp', '0000-NaviStandD_0');
      sprite.setOrigin(origin_x / frame_w, origin_y / frame_h);  // honor extracted origin
    THIS plan ships only the StandD default frame; 06-16 wires 8-direction selection.

  apps/client/src/assets/room-renderer.ts (extend):
    Read the SRV-13 layout JSON; for each tile entry, render via Phaser tilemap or sprite-blit;
    background rendered as a single Phaser.GameObjects.Image at world origin.
    Mirror the existing mvp-lobby render path; replace mvp-lobby paths with bn-central (or
    generalize to read room_id from layout JSON).

  apps/client/src/assets/atlas-loader.ts:
    Already wires Phaser.Textures.FilterMode.NEAREST per Phase 6 D-12; just ensure it sets the
    filter on the new 'mvp' atlas after this.load.atlas resolves.

  <!-- e2e update -->

  apps/client/test/e2e/cli-08.e2e.test.ts:
    Add/update assertions:
      - canvas[data-room-id="bn-central"] (or wherever the attribute lives — set on Phaser canvas root after room layout received)
      - Player render is NOT a solid color rectangle: page.evaluate sample of canvas pixel
        at known player position; assert R/G/B is not (0, 0, 255) (blue marker) and the
        pixel is opaque (alpha = 255). Coarse but sufficient for a non-blue-rect check.
      - Existing chat round-trip + nameplate assertions remain unchanged
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: tools/asset-pipeline bootstrap mode + bootstrap all 15 player sprites + bn-central tile/object sprites; rebuild atlas-mvp</name>
    <files>
      tools/asset-pipeline/src/bootstrap.ts,
      tools/asset-pipeline/cli.ts,
      tools/asset-pipeline/test/bootstrap.test.ts,
      tools/asset-pipeline/src/build.ts,
      assets/source/sprites/.gitkeep,
      package.json
    </files>
    <read_first>
      tools/asset-pipeline/cli.ts (current dispatcher — find existing build / postprocess subcommands; mirror the registration shape for `bootstrap`),
      tools/asset-pipeline/src/build.ts (verify it handles the bootstrap output shape — assets/source/sprites/<id>.{png,json} — without changes),
      tools/asset-pipeline/src/manifest.ts (verify it picks up new sprite entries automatically; if it has a hard-coded list, update it),
      tools/asset-catalog/cli.ts (BMP→PNG sharp pattern reference — exact-mirror analog from PATTERNS lines 80-83),
      tools/extract-gmd/cli.ts (sharp pipeline reference),
      extracted/client-5-8/sprites/0000-NaviStandD/meta.json (confirm exact field names: frame_w / frame_h / origin / frame_count — the bootstrap reader must match these names),
      extracted/client-5-8/sprites/0027-NaviRunD/meta.json (multi-frame example; verify frame_count = 6 from the directory listing),
      extracted/client-5-8/rooms/0058-BNCentral/tiles.json (extract the list of tileset sprite_ids referenced),
      extracted/client-5-8/rooms/0058-BNCentral/instances.json (extract the list of object_ids; cross-reference each in extracted/client-5-8/objects/<id>/meta.json to find sprite_id),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-08-SUMMARY.md (lint:asset-pipeline contract — bootstrap output flows through unchanged)
    </read_first>
    <behavior>
      - tools/asset-pipeline/cli.ts: registers `bootstrap` subcommand alongside `build` + `postprocess`
      - tools/asset-pipeline/src/bootstrap.ts: exports bootstrap({ sourceRoot, outDir, spriteIds }) → reads each sprite from extracted/, decodes BMP frames via sharp, composites a horizontal strip PNG, writes <id>.png + <id>.json (Aseprite shape) to outDir; deterministic (sorted keys, pinned PNG encoder settings) per Phase 1 D-15
      - bootstrap is idempotent — re-running produces byte-identical output for the same input
      - bootstrap.test.ts: 5+ vitest tests covering: (a) single sprite bootstrap produces correct strip dimensions, (b) frame_count = 6 produces 6 horizontal cells, (c) origin from meta.json is preserved in the output JSON, (d) JSON shape matches Aseprite json-array, (e) determinism (sha256 over output stable across two runs)
      - bootstrap CLI invocation: `pnpm asset-pipeline bootstrap` reads a hard-coded sprite-id list (or accepts a CSV arg) covering the 15 player sprites + bn-central tile/object sprites
      - After bootstrap runs, `pnpm asset-pipeline:build` (existing) packs all of assets/source/sprites/* into atlas-mvp.{png,json} — atlas grows from 06-08's empty/test state to include all real sprites
      - assets/source/sprites/ directory is committed (with .gitkeep + the bootstrapped output files); aseprite/ subdirectory is committed-or-gitignored per planner discretion (CONTEXT D-15) — this plan picks GITIGNORED (the .aseprite source files come later when devs edit; first-pass workflow is bootstrap → ship; aseprite source storage is a v2 ergonomics question, not first-pass)
    </behavior>
    <action>
  **1. tools/asset-pipeline/src/bootstrap.ts** — Tag `// [impl->REQ-AST-01]` at top.

  ```ts
  // [impl->REQ-AST-01]
  // Bootstrap mode (CONTEXT D-14): reads extracted/client-5-8/sprites/<id>/frames/img_*.bmp
  // + meta.json, emits assets/source/sprites/<id>.{png,json} (Aseprite-compatible).
  //
  // Idempotent: re-running with the same input produces byte-identical output
  // (Phase 1 D-15 reproducibility).

  import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
  import { join, resolve } from 'node:path';
  import sharp from 'sharp';

  export interface SpriteMeta {
    name?: string;
    frame_w: number;
    frame_h: number;
    frame_count: number;
    origin_x?: number;
    origin_y?: number;
    // ... other extracted fields preserved verbatim in the output JSON
    [k: string]: unknown;
  }

  export interface BootstrapOptions {
    sourceRoot: string;   // e.g. 'extracted/client-5-8/sprites'
    outDir: string;       // e.g. 'assets/source/sprites'
    spriteIds: string[];  // e.g. ['0000-NaviStandD', '0027-NaviRunD', ...]
  }

  export interface BootstrapResult {
    sprites: Array<{
      id: string;
      framesIn: number;
      pngOut: string;
      jsonOut: string;
      sha256_png: string;
    }>;
  }

  export async function bootstrap(opts: BootstrapOptions): Promise<BootstrapResult> {
    if (!existsSync(opts.outDir)) mkdirSync(opts.outDir, { recursive: true });
    const result: BootstrapResult = { sprites: [] };

    for (const id of [...opts.spriteIds].sort()) {
      const spriteDir = join(opts.sourceRoot, id);
      if (!existsSync(spriteDir)) {
        throw new Error(`bootstrap: sprite directory not found: ${spriteDir}`);
      }
      const metaPath = join(spriteDir, 'meta.json');
      const meta = JSON.parse(readFileSync(metaPath, 'utf-8')) as SpriteMeta;
      const framesDir = join(spriteDir, 'frames');
      const frameFiles = readdirSync(framesDir).filter((f) => f.endsWith('.bmp')).sort();
      if (frameFiles.length !== meta.frame_count) {
        throw new Error(`bootstrap: ${id} meta.frame_count=${meta.frame_count} but found ${frameFiles.length} BMPs`);
      }

      // Composite horizontal strip
      const stripWidth = meta.frame_w * meta.frame_count;
      const stripHeight = meta.frame_h;
      const composite: sharp.OverlayOptions[] = [];
      for (let i = 0; i < frameFiles.length; i++) {
        const bmpBuf = readFileSync(join(framesDir, frameFiles[i]));
        const pngBuf = await sharp(bmpBuf).png({ compressionLevel: 9, palette: false }).toBuffer();
        composite.push({ input: pngBuf, left: i * meta.frame_w, top: 0 });
      }

      const stripBuf = await sharp({
        create: { width: stripWidth, height: stripHeight, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
      }).composite(composite).png({ compressionLevel: 9, palette: false }).toBuffer();

      const pngOut = join(opts.outDir, `${id}.png`);
      writeFileSync(pngOut, stripBuf);

      // Aseprite-compatible JSON sidecar (D-15)
      const frames: Record<string, unknown> = {};
      for (let i = 0; i < meta.frame_count; i++) {
        frames[`${id}_${i}`] = {
          frame: { x: i * meta.frame_w, y: 0, w: meta.frame_w, h: meta.frame_h },
          rotated: false, trimmed: false,
          spriteSourceSize: { x: 0, y: 0, w: meta.frame_w, h: meta.frame_h },
          sourceSize: { w: meta.frame_w, h: meta.frame_h },
        };
      }
      const sidecar = {
        frames,
        meta: {
          app: 'rebno-asset-pipeline-bootstrap',
          version: '1',
          image: `${id}.png`,
          format: 'RGBA8888',
          size: { w: stripWidth, h: stripHeight },
          scale: '1',
          frame_w: meta.frame_w,
          frame_h: meta.frame_h,
          frame_count: meta.frame_count,
          origin: { x: meta.origin_x ?? 0, y: meta.origin_y ?? 0 },
          sprite_id: id,
          source: `extracted/client-5-8/sprites/${id}/`,
        },
      };
      const jsonOut = join(opts.outDir, `${id}.json`);
      // Deterministic deep-sorted JSON (Phase 1 D-15) — use canonical helper.
      // `canonicalStringify` lives in tools/asset-pipeline/src/manifest.ts (06-08 scaffold).
      // If absent, write the 6-line recursive helper inline (see manifest.ts pattern).
      writeFileSync(jsonOut, canonicalStringify(sidecar) + '\n');

      const sha256_png = await sha256(stripBuf);
      result.sprites.push({ id, framesIn: frameFiles.length, pngOut, jsonOut, sha256_png });
    }
    return result;
  }

  async function sha256(buf: Buffer): Promise<string> {
    const { createHash } = await import('node:crypto');
    return createHash('sha256').update(buf).digest('hex');
  }
  ```

  (Determinism note: `JSON.stringify`'s second arg is a replacer function or property-name filter, NOT a sort. Use `canonicalStringify` from `tools/asset-pipeline/src/manifest.ts`. If that helper does not exist yet in 06-08's scaffold, add it as a 6-line recursive sort: walk each object, sort keys, recurse on values, pass through arrays. Intent: same input → byte-identical JSON output.)

  **2. tools/asset-pipeline/cli.ts** — Tag `// [impl->REQ-AST-01]` near the new subcommand.

  ```ts
  // [impl->REQ-AST-01]
  // Add `bootstrap` subcommand alongside existing `build` + `postprocess`.

  import { bootstrap } from './src/bootstrap';

  const PLAYER_SPRITE_IDS = [
    '0000-NaviStandD',
    '0027-NaviRunD',
    '0028-NaviRunR',
    '0029-NaviStandR',
    '0030-NaviStandU',
    '0031-NaviRunU',
    '0032-NaviRunL',
    '0033-NaviStandL',
    '0036-NaviStandDR',
    '0037-NaviRunDR',
    '0038-NaviStandUR',
    '0039-NaviRunUR',
    '0040-NaviStandUL',
    '0041-NaviRunUL',
    '0042-NaviStandDL',
    '0043-NaviRunDL',
  ];

  // Tile + object sprites: derived at bootstrap time from
  // extracted/client-5-8/rooms/0058-BNCentral/tiles.json + instances.json
  // (cross-reference each instance.object_id → extracted/client-5-8/objects/<id>/meta.json
  //  → meta.sprite_id). The executor builds this list dynamically from the
  // extracted JSONs and concatenates with PLAYER_SPRITE_IDS.

  // Add to the existing dispatcher (mirror the build/postprocess registration shape):
  case 'bootstrap': {
    const tileObjectIds = await deriveBnCentralSpriteIds('extracted/client-5-8');
    const allIds = Array.from(new Set([...PLAYER_SPRITE_IDS, ...tileObjectIds])).sort();
    const r = await bootstrap({
      sourceRoot: 'extracted/client-5-8/sprites',
      outDir: 'assets/source/sprites',
      spriteIds: allIds,
    });
    console.log(`bootstrap: ${r.sprites.length} sprites → assets/source/sprites/`);
    break;
  }
  ```

  (Implement `deriveBnCentralSpriteIds` as a small helper inside cli.ts or a sibling module. It reads the room's tiles.json + instances.json and resolves sprite IDs.)

  **3. tools/asset-pipeline/test/bootstrap.test.ts** — Tag `// [unit->REQ-AST-01]`. 5+ vitest tests:

  ```ts
  // [unit->REQ-AST-01]
  import { describe, it, expect, beforeEach } from 'vitest';
  import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
  import { tmpdir } from 'node:os';
  import { join } from 'node:path';
  import { bootstrap } from '../src/bootstrap';

  function makeFixture(root: string, id: string, frame_w: number, frame_h: number, frame_count: number) {
    // Write a minimal extracted/<id>/ tree with meta.json + N solid-color BMPs.
    // Use sharp to generate the BMPs synchronously inside the test (acceptable
    // for unit-test cost; ~milliseconds).
    // ... (executor implements the small fixture builder)
  }

  describe('asset-pipeline bootstrap', () => {
    let tmp: string;
    beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'asset-pipe-')); });

    it('single-frame sprite produces strip with correct dimensions', async () => { /* assert PNG.size === frame_w * frame_h */ });
    it('multi-frame sprite (frame_count=6) produces 6-cell horizontal strip', async () => { /* assert size = 6*frame_w x frame_h */ });
    it('JSON sidecar carries origin from meta.json', async () => { /* assert sidecar.meta.origin.x/y */ });
    it('JSON shape matches Aseprite json-array (frames + meta keys)', async () => { /* assert shape */ });
    it('idempotent — two runs produce byte-identical output (sha256 stable)', async () => { /* run twice, compare sha256 of PNG + JSON */ });
    it('throws clearly when meta.frame_count mismatches BMP count', async () => { /* assert thrown error message */ });
  });
  ```

  **4. assets/source/sprites/.gitkeep** — Empty file. Confirms the directory is committed.

  **5. package.json** — Add npm scripts (alongside the existing asset-pipeline:build / :postprocess from 06-08):

  ```jsonc
  "asset-pipeline:bootstrap": "node tools/asset-pipeline/dist/cli.js bootstrap",
  // (or whatever the existing invocation pattern is — match build / postprocess shape exactly)
  ```

  Then run end-to-end:
  1. `pnpm --filter @rebno/asset-pipeline build` (compiles tools/asset-pipeline TS)
  2. `pnpm asset-pipeline:bootstrap` (produces assets/source/sprites/<id>.{png,json} for all 15 player sprites + bn-central tile/object sprites)
  3. `pnpm asset-pipeline:build` (existing — packs all of assets/source/sprites/* into atlas-mvp.{png,json})
  4. `pnpm asset-pipeline:postprocess` (existing — rewrites manifest paths)
  5. `pnpm lint:asset-pipeline` (existing — sha256 cross-check fires; should pass)

  **Commit assets/source/sprites/*.{png,json}** along with the code changes — these are the bootstrapped output, committed per CONTEXT D-14 ("output committed to git so devs can edit `.aseprite` source files locally").
    </action>
    <verify>
      <automated>pnpm --filter @rebno/asset-pipeline test &amp;&amp; pnpm asset-pipeline:bootstrap &amp;&amp; pnpm asset-pipeline:build &amp;&amp; pnpm asset-pipeline:postprocess &amp;&amp; pnpm lint:asset-pipeline &amp;&amp; node -e "const fs=require('fs'); const ids=['0000-NaviStandD','0027-NaviRunD','0028-NaviRunR','0029-NaviStandR','0030-NaviStandU','0031-NaviRunU','0032-NaviRunL','0033-NaviStandL','0036-NaviStandDR','0037-NaviRunDR','0038-NaviStandUR','0039-NaviRunUR','0040-NaviStandUL','0041-NaviRunUL','0042-NaviStandDL','0043-NaviRunDL']; for (const id of ids) { if (!fs.existsSync('assets/source/sprites/'+id+'.png')) { console.error('MISSING:', id+'.png'); process.exit(1); } if (!fs.existsSync('assets/source/sprites/'+id+'.json')) { console.error('MISSING:', id+'.json'); process.exit(1); } } console.log('all 16 player sprites bootstrapped + atlas built + lint passed');"</automated>
    </verify>
    <done>
      bootstrap subcommand wired; 6+ unit tests pass; all 15 player sprites + bn-central tile/object sprites have .png + .json under assets/source/sprites/; pnpm asset-pipeline:build produces atlas-mvp.{png,json} including the new sprites; pnpm lint:asset-pipeline passes (manifest schema + sha256 integrity); idempotent re-runs are byte-stable.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Convert 0058-BNCentral → apps/server/rooms/bn-central/000.json + signed; register on server (replaces mvp-lobby)</name>
    <files>
      tools/room-converter/src/bn-central.ts,
      tools/room-converter/test/bn-central.test.ts,
      apps/server/rooms/bn-central/000.json,
      apps/server/rooms/bn-central/000.sig,
      apps/server/src/RebnoRoom.ts,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md
    </files>
    <read_first>
      extracted/client-5-8/rooms/0058-BNCentral/meta.json (room dimensions, room_speed if present),
      extracted/client-5-8/rooms/0058-BNCentral/instances.json (object instance list with x/y/object_id),
      extracted/client-5-8/rooms/0058-BNCentral/tiles.json (tile list with x/y/tileset_id/frame),
      extracted/client-5-8/rooms/0058-BNCentral/backgrounds.json (background image refs),
      extracted/client-5-8/rooms/0058-BNCentral/creation-code.gml (room init code; usually irrelevant for layout but read to confirm),
      apps/server/rooms/mvp-lobby/000.json (canonical SRV-13 schema reference — mirror exactly),
      apps/server/rooms/mvp-lobby/000.sig (signature ritual reference — find the helper that produced it),
      apps/server/src/RebnoRoom.ts (find: matchmaker.defineRoomType('mvp-lobby', ...); replace with bn-central; verify home-portal spawn read path D-07),
      tools/room-converter/src/index.ts (existing entry; mirror its conventions — file structure + cli wiring),
      tools/scripts/lint-room-layout.mjs (validates 000.json + 000.sig — verify the new bn-central files pass)
    </read_first>
    <behavior>
      - tools/room-converter/src/bn-central.ts: exports convertBnCentral({ srcRoot, outRoot }) → reads the 5 source files, builds the SRV-13 layout JSON, writes apps/server/rooms/bn-central/000.json + signs to 000.sig
      - All sprite/object IDs preserved verbatim (object_id stays as the original BNO id like '0042-player' even though the player object is actually 0000-server — this is the room's instance list, not the player avatar; the room may legitimately reference 0042-player for some other instance type, document if so)
      - home_portal spawn point: identified from instances.json (a specific portal/door object) OR fallback to room center; documented in 06-14-ASSET-MANIFEST.md
      - 000.sig produced by the existing Phase 4 D-12 signing helper (use the dev key for local; staging/prod keys ship per Phase 5 D-19 ritual)
      - bn-central.test.ts: 4+ tests — fixture-based conversion produces expected layout shape, sprite IDs cross-reference correctly, signature verifies, idempotent
      - Server: RebnoRoom.ts replaces 'mvp-lobby' with 'bn-central' in matchmaker.defineRoomType (or runs both during transition; final committed state is bn-central is the canonical CLI-08 room — leave mvp-lobby registration if needed for backward compat with any other test, but the e2e + UAT target bn-central)
      - 06-14-ASSET-MANIFEST.md documents: list of sprites bootstrapped, source for each (extracted path), home-portal spawn coordinates + reasoning, any tile/object IDs that couldn't be resolved (with reason)
    </behavior>
    <action>
  **1. tools/room-converter/src/bn-central.ts** — Tag `// [impl->REQ-AST-01] [impl->REQ-CLI-07]`.

  ```ts
  // [impl->REQ-AST-01] [impl->REQ-CLI-07]
  // BN Central room converter — bridges extracted/client-5-8/rooms/0058-BNCentral/*
  // → apps/server/rooms/bn-central/000.json (SRV-13 schema) + signed 000.sig.

  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
  import { join } from 'node:path';
  // import { signRoomLayout } from '@rebno/server/dev-keys';  // or wherever Phase 4 D-12 helper lives

  export interface ConvertOptions {
    srcRoot: string;          // 'extracted/client-5-8/rooms/0058-BNCentral'
    outRoot: string;          // 'apps/server/rooms/bn-central'
    signingKeyPath?: string;  // dev key for local; production via Phase 5 D-19 ritual
  }

  export async function convertBnCentral(opts: ConvertOptions): Promise<{ jsonPath: string; sigPath: string }> {
    const meta = JSON.parse(readFileSync(join(opts.srcRoot, 'meta.json'), 'utf-8'));
    const instances = JSON.parse(readFileSync(join(opts.srcRoot, 'instances.json'), 'utf-8'));
    const tiles = JSON.parse(readFileSync(join(opts.srcRoot, 'tiles.json'), 'utf-8'));
    const backgrounds = JSON.parse(readFileSync(join(opts.srcRoot, 'backgrounds.json'), 'utf-8'));

    // Phase 4 / Phase 6 standard tile sizes (verify against meta.json — BNO original may differ)
    const tile_w = 44;
    const tile_h = 40;

    const layout = {
      room_id: 'bn-central',
      version: 1,
      width_tiles: Math.ceil(meta.width / tile_w),   // adapt to meta.json shape; verify field name
      height_tiles: Math.ceil(meta.height / tile_h),
      tile_w, tile_h,
      background: extractBackground(backgrounds), // map to { sprite_id, tile_x, tile_y } or {color} fallback
      tiles: tiles.map((t: any) => ({
        tileset_sprite_id: resolveSpriteId(t.tileset_id ?? t.background_id),
        x: t.x, y: t.y,
        frame: t.frame ?? 0,
      })),
      instances: instances.map((i: any) => ({
        object_id: i.object_id ?? i.name,    // verify field name from extracted JSON
        x: i.x, y: i.y,
      })),
      spawn_points: deriveSpawnPoints(instances, meta),  // home_portal from a specific object OR room center fallback
    };

    if (!existsSync(opts.outRoot)) mkdirSync(opts.outRoot, { recursive: true });
    const canonical = canonicalJson(layout);  // sorted-keys, deterministic
    const jsonPath = join(opts.outRoot, '000.json');
    writeFileSync(jsonPath, canonical + '\n');

    // Sign — call the existing Phase 4 D-12 helper. If it lives in apps/server/src/room-signing.ts,
    // import it; if it's a CLI script, invoke it via spawnSync. Verify the existing mvp-lobby
    // pipeline and mirror exactly.
    const sigPath = join(opts.outRoot, '000.sig');
    await signLayout(jsonPath, sigPath, opts.signingKeyPath);

    return { jsonPath, sigPath };
  }

  // Helpers — extractBackground / resolveSpriteId / deriveSpawnPoints / canonicalJson / signLayout
  // ... (executor implements per the existing mvp-lobby reference)
  ```

  **Key derivation decisions (executor records in 06-14-ASSET-MANIFEST.md):**

  a) **Tile size**: 44×40 is the Phase 6 standard (CONTEXT D-10 reference). If extracted/0058-BNCentral/meta.json shows a different value (e.g. 16×16), USE THE EXTRACTED VALUE — Phase 6 must adapt to BNO truth, not impose a different tile size. Document the choice.

  b) **Background**: backgrounds.json may reference a sprite ID from extracted/client-5-8/backgrounds/ (different namespace from sprites/). Verify; bootstrap that background asset too if needed (Task 1 was player+tile+object sprites; if backgrounds need their own bootstrap-like step, add it now or scope to a tiny helper).

  c) **home_portal spawn**: Look in instances.json for an obvious portal/door/spawn-marker object. If none, use room-center: `(width/2, height/2)`. Document choice + coordinates.

  d) **Object IDs that don't have a sprite**: some original-BNO objects are invisible (logic-only). Skip in the rendered instances list OR include with a sentinel sprite_id; document.

  **2. tools/room-converter/test/bn-central.test.ts** — Tag `// [unit->REQ-AST-01]`.

  4+ vitest tests using a fixture extracted-room directory:
  - (a) Layout JSON matches SRV-13 schema (validate via lint-room-layout.mjs's schema constants)
  - (b) Tile + instance counts match source JSON inputs
  - (c) home_portal spawn point is present and within room bounds
  - (d) Idempotent — two runs produce byte-identical 000.json
  - (e) Signature verifies against the dev pubkey

  **3. Run the converter** to produce real outputs:
  ```bash
  pnpm --filter @rebno/room-converter build  # if needed
  node tools/room-converter/dist/cli.js bn-central
  # OR a one-off script call to convertBnCentral(...) — verify tools/room-converter's existing CLI shape
  ```

  This produces `apps/server/rooms/bn-central/000.json` and `apps/server/rooms/bn-central/000.sig`. Commit both.

  **4. apps/server/src/RebnoRoom.ts** — Tag `// [impl->REQ-CLI-07]`. Find the existing `matchmaker.defineRoomType('mvp-lobby', ...)` call (or wherever room registration happens). Add a parallel registration for 'bn-central' OR replace mvp-lobby with bn-central. Decision: REPLACE (mvp-lobby was synthetic; bn-central is canonical now). Keep mvp-lobby as a fallback ONLY if any other test depends on it; otherwise remove from production server.

  ```ts
  // [impl->REQ-CLI-07]
  // Replaces synthetic mvp-lobby with first-pass real BN Central room (D-27).
  matchmaker.defineRoomType('bn-central', RebnoRoom, { layoutPath: 'apps/server/rooms/bn-central/000.json' });
  ```

  Verify the home-portal spawn read path (Phase 6 D-07): `onJoin` for a new session reads `spawn_points.home_portal.{x,y}` from the loaded layout. If the existing code reads `mvp-lobby` spawn coordinates, generalize to read from the layout's spawn_points by name.

  **5. .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md** — Doc-only.

  ```md
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 14
  type: doc-only-manifest
  status: complete
  ---

  # First-Pass Real Asset Manifest (D-27)

  [doc->REQ-AST-01] [doc->REQ-CLI-07]

  ## Player sprites bootstrapped (15)

  | Sprite ID | Source | Frames | Origin (x,y) | Notes |
  |-----------|--------|--------|--------------|-------|
  | 0000-NaviStandD | extracted/client-5-8/sprites/0000-NaviStandD/ | 1 | (x,y) | Default-frame baseline for PlayerRenderer |
  | 0027-NaviRunD | ... | 6 | ... | Walk-cycle south |
  | 0028-NaviRunR | ... | ? | ... | Walk-cycle east (R-facing); restored after CONTEXT-line-336 omission |
  | 0029-NaviStandR | ... | 1 | ... | |
  | 0030-NaviStandU | ... | 1 | ... | |
  | 0031-NaviRunU | ... | ? | ... | Walk-cycle north |
  | 0032-NaviRunL | ... | ? | ... | Walk-cycle west |
  | 0033-NaviStandL | ... | 1 | ... | |
  | 0036-NaviStandDR | ... | 1 | ... | Diagonal stand SE |
  | 0037-NaviRunDR | ... | ? | ... | Diagonal walk SE |
  | 0038-NaviStandUR | ... | 1 | ... | Diagonal stand NE |
  | 0039-NaviRunUR | ... | ? | ... | Diagonal walk NE |
  | 0040-NaviStandUL | ... | 1 | ... | Diagonal stand NW |
  | 0041-NaviRunUL | ... | ? | ... | Diagonal walk NW |
  | 0042-NaviStandDL | ... | 1 | ... | Diagonal stand SW |
  | 0043-NaviRunDL | ... | ? | ... | Diagonal walk SW |

  ## Tile sprites bootstrapped (derived from 0058-BNCentral/tiles.json)

  | Sprite ID | Source | Frames | Used for | Notes |
  |-----------|--------|--------|----------|-------|
  | _<derived>_ | _<extracted path>_ | _<n>_ | Floor tile / wall / etc. | |

  ## Object sprites bootstrapped (derived from 0058-BNCentral/instances.json + objects/<id>/meta.json)

  | Sprite ID | From object_id | Source | Notes |
  |-----------|----------------|--------|-------|
  | _<derived>_ | _<obj id>_ | _<extracted path>_ | |

  ## Background

  | Background ID | Source | Notes |
  |---------------|--------|-------|
  | _<derived>_ | extracted/client-5-8/backgrounds/<id>/ | Bootstrapped via _<bootstrap helper>_ |

  ## Room conversion: 0058-BNCentral → bn-central

  - Source room dimensions: _<from meta.json>_ (e.g. 640×480 px)
  - Tile size adopted: 44×40 (Phase 6 D-10 standard) OR _<extracted value if different>_ — RATIONALE: _<...>_
  - Tile count: _<n>_ (from tiles.json)
  - Instance count: _<n>_ (from instances.json)
  - home_portal spawn: (x, y) = _<derived>_; SOURCE: _<which instance/object marked it OR "room center fallback">_
  - Signature: 000.sig produced by _<helper name>_ using _<dev key path>_; verifies against _<pubkey path>_

  ## Decisions made

  _(Document any judgment calls — invisible objects skipped, tile size mismatch, sprite_id couldn't be resolved, etc.)_

  ## Out of scope (Phase 7)

  - Audio (AST-02 — extracted/client-5-8/ has no audio per ADR 0001)
  - Fonts (AST-03)
  - Other 9 extracted rooms (PAR-03)
  - Aseprite source files (devs may add later; bootstrap output is the first-pass committed source)
  ```

  Fill in the `<derived>` placeholders from your actual reading of the extracted JSONs.
    </action>
    <verify>
      <automated>node -e "const fs=require('fs'); if (!fs.existsSync('apps/server/rooms/bn-central/000.json')) { console.error('MISSING 000.json'); process.exit(1); } if (!fs.existsSync('apps/server/rooms/bn-central/000.sig')) { console.error('MISSING 000.sig'); process.exit(1); } const layout=JSON.parse(fs.readFileSync('apps/server/rooms/bn-central/000.json','utf-8')); if (layout.room_id !== 'bn-central') { console.error('room_id wrong:', layout.room_id); process.exit(1); } if (!Array.isArray(layout.tiles) || layout.tiles.length === 0) { console.error('no tiles'); process.exit(1); } if (!layout.spawn_points || !layout.spawn_points.find(s => s.name === 'home_portal')) { console.error('no home_portal spawn'); process.exit(1); } console.log('bn-central layout OK:', layout.tiles.length, 'tiles,', layout.instances.length, 'instances'); const r=fs.readFileSync('apps/server/src/RebnoRoom.ts','utf-8'); if (!r.includes('bn-central')) { console.error('RebnoRoom does not register bn-central'); process.exit(1); } console.log('server registers bn-central'); &amp;&amp; pnpm --filter @rebno/room-converter test &amp;&amp; pnpm trace:check"</automated>
    </verify>
    <done>
      apps/server/rooms/bn-central/000.json + 000.sig committed; SRV-13 schema valid (lint-room-layout.mjs passes); RebnoRoom.ts registers 'bn-central' as the canonical room (mvp-lobby removed or kept as deprecated fallback per discretion); tools/room-converter/src/bn-central.ts shipped with 4+ tests; 06-14-ASSET-MANIFEST.md fully populated. Server boots without error against the new room.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: Client atlas load + PlayerRenderer real-sprite + RoomRenderer bn-central + e2e assertion update</name>
    <files>
      apps/client/src/scenes/GameScene.ts,
      apps/client/src/assets/atlas-loader.ts,
      apps/client/src/assets/room-renderer.ts,
      apps/client/src/render/PlayerRenderer.ts,
      apps/client/test/e2e/cli-08.e2e.test.ts
    </files>
    <read_first>
      apps/client/src/scenes/GameScene.ts (find: preload(); current room rendering call site; current PlayerRenderer instantiation),
      apps/client/src/assets/atlas-loader.ts (current implementation; understand how it sets Phaser.Textures.FilterMode.NEAREST),
      apps/client/src/assets/room-renderer.ts (find: render(layout) method; current mvp-lobby-specific code paths),
      apps/client/src/render/PlayerRenderer.ts (find: ensureLocal / addRemote — replace blue rectangle with atlas sprite),
      apps/client/test/e2e/cli-08.e2e.test.ts (find: existing room-id assertion if any; existing player-render assertion if any; this plan adds/updates),
      apps/client/vite.config.ts (verify the public/ static dir or asset import path — atlas-mvp.png needs to be served at /atlas-mvp.png in dev),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md (cross-reference sprite IDs)
    </read_first>
    <behavior>
      - GameScene.preload(): this.load.atlas('mvp', '/atlas-mvp.png', '/atlas-mvp.json'); set NEAREST filter on the loaded atlas
      - GameScene.create(): set canvas attribute data-room-id='bn-central' (or read from received layout) so e2e can assert
      - PlayerRenderer.ensureLocal(): replaces blue rectangle with `scene.add.sprite(x, y, 'mvp', '0000-NaviStandD_0')`; honors origin from atlas json sidecar
      - PlayerRenderer.addRemote(): same shape; uses atlas-loaded sprite
      - 8-direction sprite SELECTION wiring is OUT OF SCOPE here (06-16 owns it); this plan ships StandD as the default frame for both self + remotes
      - RoomRenderer.render(layout): handles bn-central layout (tiles + instances + background) using atlas frames where applicable
      - e2e cli-08.e2e.test.ts: adds two assertions — (a) data-room-id === 'bn-central'; (b) canvas pixel sample at player position is NOT solid blue (RGB ≠ (0,0,255)); existing chat + nameplate assertions preserved
    </behavior>
    <action>
  **1. apps/client/src/scenes/GameScene.ts** — Tag `// [impl->REQ-AST-01] [impl->REQ-CLI-07]` near the preload + room-id-set lines.

  ```ts
  // [impl->REQ-AST-01] [impl->REQ-CLI-07]

  preload(): void {
    // Atlas (D-13/D-14/D-17)
    this.load.atlas('mvp', '/atlas-mvp.png', '/atlas-mvp.json');
    // existing preload calls...
  }

  create(): void {
    // existing create()...
    // After atlas loaded, set NEAREST filter (Phase 6 D-12)
    const tex = this.textures.get('mvp');
    if (tex) tex.setFilter(Phaser.Textures.FilterMode.NEAREST);
    // Mark room id for e2e selectors — set on first s2c.room_layout receive in onRoomLayout()
    // (or set immediately if room id is known from join init data)
  }

  private onRoomLayout(layout: RoomLayout): void {
    // existing render call...
    this.roomRenderer.render(layout);
    // Mark on canvas for e2e
    const canvas = this.game.canvas as HTMLCanvasElement | null;
    if (canvas) canvas.setAttribute('data-room-id', layout.room_id);
  }
  ```

  **2. apps/client/src/assets/atlas-loader.ts** — Tag `// [impl->REQ-AST-01]`. Verify it sets NEAREST filter on the 'mvp' atlas after load. If absent, add the wiring.

  **3. apps/client/src/render/PlayerRenderer.ts** — Tag `// [impl->REQ-AST-01]` near the ensureLocal/addRemote methods.

  ```ts
  // [impl->REQ-AST-01]
  // Replace blue rectangle with real atlas sprite. 8-direction sprite SELECTION
  // wiring lives in 06-16 (sprite-state machine). This plan ships StandD as the
  // default frame for self + remotes.

  ensureLocal(scene: Phaser.Scene, x: number, y: number, sessionId: string): Phaser.GameObjects.Sprite {
    if (this.local) return this.local;
    const sprite = scene.add.sprite(x, y, 'mvp', '0000-NaviStandD_0');
    // Honor origin from atlas json sidecar (executor reads atlas-mvp.json frame meta + applies setOrigin)
    this.local = sprite;
    return sprite;
  }

  addRemote(scene: Phaser.Scene, x: number, y: number, sessionId: string, username: string): void {
    const sprite = scene.add.sprite(x, y, 'mvp', '0000-NaviStandD_0');
    // Nameplate wiring stays as-is (D-27a cyan nametag lands in 06-16)
    this.remotes.set(sessionId, sprite);
  }
  ```

  (If PlayerRenderer's existing API differs, adapt — the principle is "atlas frame replaces rectangle.")

  **4. apps/client/src/assets/room-renderer.ts** — Tag `// [impl->REQ-CLI-07]`.

  ```ts
  // [impl->REQ-CLI-07]
  render(layout: RoomLayout): void {
    // Background
    if (layout.background?.sprite_id) {
      this.scene.add.image(0, 0, 'mvp', `${layout.background.sprite_id}_0`).setOrigin(0, 0);
    }
    // Tiles — render via atlas sprites at world coords
    for (const t of layout.tiles) {
      const frameKey = `${t.tileset_sprite_id}_${t.frame ?? 0}`;
      this.scene.add.sprite(t.x, t.y, 'mvp', frameKey).setOrigin(0, 0);
    }
    // Instances — only render those with a corresponding sprite (some are invisible logic objects)
    for (const inst of layout.instances) {
      const spriteId = this.resolveInstanceSprite(inst.object_id);
      if (spriteId) this.scene.add.sprite(inst.x, inst.y, 'mvp', `${spriteId}_0`);
    }
  }
  ```

  (executor implements `resolveInstanceSprite` — a small lookup of object_id → sprite_id from the manifest or a hard-coded table from 06-14-ASSET-MANIFEST.md; if Phaser TilemapLayer is preferred per RESEARCH §room-rendering recommendation, swap to that — both work for SRV-13's tile array shape.)

  **5. apps/client/test/e2e/cli-08.e2e.test.ts** — Tag `// [int->REQ-AST-01] [int->REQ-CLI-07]`.

  Update existing assertions (do NOT rewrite the file; add to it):

  ```ts
  // [int->REQ-AST-01] [int->REQ-CLI-07]
  test('renders bn-central room (replaces synthetic mvp-lobby)', async ({ page }) => {
    await loginAs(page, accountA);
    await waitForGameReady(page);
    // Room-id assertion
    const roomId = await page.locator('canvas[data-game-ready=true]').getAttribute('data-room-id');
    expect(roomId).toBe('bn-central');
  });

  test('player render is not a solid blue rectangle (real atlas sprite loaded)', async ({ page }) => {
    await loginAs(page, accountA);
    await waitForGameReady(page);
    // Sample canvas pixel at the local-player position. Coarse but sufficient.
    const isBlueRect = await page.evaluate(async () => {
      const canvas = document.querySelector('canvas[data-game-ready=true]') as HTMLCanvasElement;
      if (!canvas) return null;
      // Sample at canvas center (server spawns at home_portal which the camera follows;
      // the local player should be near canvas center after camera follow)
      const ctx = canvas.getContext('2d') ?? canvas.getContext('webgl2');
      if (!ctx || !('getImageData' in ctx)) {
        // WebGL canvas — use a different sampling approach via toDataURL or skip
        return 'webgl-skip';
      }
      const px = (ctx as CanvasRenderingContext2D).getImageData(canvas.width / 2, canvas.height / 2, 1, 1).data;
      const [r, g, b, a] = [px[0], px[1], px[2], px[3]];
      // Solid blue marker would be ~(0, 0, 255); real sprite pixels are NOT this
      return r === 0 && g === 0 && b === 255 && a === 255;
    });
    if (isBlueRect === 'webgl-skip') {
      // Phaser uses WebGL by default; if pixel sample isn't reachable, fall back to checking
      // that the 'mvp' texture is loaded by querying window.__rebno or similar dev hook
      const atlasLoaded = await page.evaluate(() => {
        const game = (window as any).__rebno?.game as Phaser.Game | undefined;
        return !!game?.textures?.exists?.('mvp');
      });
      expect(atlasLoaded).toBe(true);
    } else {
      expect(isBlueRect).toBe(false);
    }
  });
  ```

  (executor adapts to actual GameScene Phaser renderer — WebGL pixel sample is awkward; the texture-loaded fallback is the practical assertion. Either is acceptable as a "not blue rectangle" guard.)

  Existing chat + nameplate + reconnect tests in cli-08.e2e.test.ts are preserved unchanged. The room-id selector update may require updating any other test that currently asserts `data-room-id="mvp-lobby"` — search and replace those references.
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client typecheck &amp;&amp; pnpm --filter @rebno/client test:e2e -- --grep "bn-central|atlas|player render"</automated>
    </verify>
    <done>
      GameScene preloads atlas-mvp; sets NEAREST filter; sets data-room-id on canvas. PlayerRenderer renders 0000-NaviStandD_0 atlas frame instead of blue rectangle. RoomRenderer renders BN Central tiles + background + instances from atlas frames. cli-08.e2e.test.ts asserts data-room-id=='bn-central' AND non-blue player render (or atlas-loaded fallback). All updated tests GREEN. Manual smoke (deferred to 06-17): operator opens local dev → sees real BNO sprites in BN Central room.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Extracted assets → committed Aseprite-compatible source | If bootstrap mis-decodes BMP, every downstream artifact is corrupted; mitigated by sha256 cross-check (lint:asset-pipeline) |
  | Bootstrapped source → atlas pack | Phase 1 D-15 reproducibility requires deterministic output; canonical-JSON + sorted spriteIds enforce |
  | Server-emitted bn-central layout → client | Phase 4 D-12 Ed25519 signature gates; client verifies before tilemap render |
  | Client atlas asset path → public/ static mount | D-18 same-Fly-app hosting; atlas-mvp.png served from apps/server/public/; CSP must not block |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-14-01 | Tampering | Adversary swaps atlas-mvp.png with bogus content | mitigate | lint:asset-pipeline cross-checks sha256_png against pipeline-manifest.json; CI runs the lint per verify-phase-6 step 7 |
  | T-06-14-02 | Tampering | Room layout 000.json edited without re-signing | mitigate | Phase 4 D-12 Ed25519 signature; client verify-on-load fails closed; server-side test ensures 000.sig matches 000.json before bootstrap |
  | T-06-14-03 | Disclosure | Extracted-asset filenames committed publicly leak BNO content via path leak | accept | Repo private through Phase 7 (CLAUDE.md hard rule #8); Phase 7 PAR-08 legal-prep checklist gates public flip |
  | T-06-14-04 | DoS | Atlas grows beyond 1024×1024 fallback → Phaser texture limit on lower-spec GPUs | mitigate | Atlas budget per CONTEXT D-17 (target 512×512, fallback 1024×1024); 06-14-ASSET-MANIFEST.md records final dimensions; if exceeded, split atlas |
  | T-06-14-05 | Repudiation | Sprite ID resolution unclear (which extracted file → which atlas frame) | mitigate | 06-14-ASSET-MANIFEST.md tables record every sprite_id → extracted/path → atlas frame; auditable |
  </threat_model>

  <verification>
  - `pnpm --filter @rebno/asset-pipeline test` exits 0 (bootstrap unit tests + existing tests)
  - `pnpm asset-pipeline:bootstrap && pnpm asset-pipeline:build && pnpm asset-pipeline:postprocess && pnpm lint:asset-pipeline` exits 0 end-to-end
  - `pnpm --filter @rebno/room-converter test` exits 0 (bn-central converter tests)
  - `node tools/room-converter/dist/cli.js bn-central` produces 000.json + 000.sig at apps/server/rooms/bn-central/
  - `pnpm --filter @rebno/server test` exits 0 (server boots with bn-central registered)
  - `pnpm --filter @rebno/client typecheck` exits 0
  - `pnpm --filter @rebno/client test:e2e` exits 0 (existing + new bn-central + non-blue-render assertions)
  - 06-14-ASSET-MANIFEST.md exists and has all `<derived>` placeholders filled
  - assets/source/sprites/<id>.{png,json} committed for all 15 player sprites + bn-central tile/object sprites
  - `pnpm trace:check` reports `[impl->REQ-AST-01] [impl->REQ-CLI-07]` tags satisfied
  </verification>

  <success_criteria>
  - UAT Finding #4 (D-27) closed: real BNO assets land end-to-end (sprites + room + atlas + server registration + client render)
  - mvp-lobby (synthetic) replaced with bn-central (canonical first-pass real room — the actual latest online lobby in BNO per addendum line 388)
  - Player object naming gotcha (0000-server) and room naming gotcha (0058-BNCentral, NOT 0004-Online_Lobby) honored
  - 8-direction sprite-state machine + cyan nametag (D-27a) explicitly DEFERRED to 06-16 — this plan ships StandD as default frame baseline
  - First-pass scope EXCLUDES audio + fonts + other 9 rooms (Phase 7) per addendum line 340
  - Tags `[impl->REQ-AST-01] [impl->REQ-CLI-07]` (per gap-closure addendum line 363)
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-SUMMARY.md` recording:
  (a) Final atlas-mvp dimensions (px × px) + total sprite count packed
  (b) Final bn-central layout dimensions (width_tiles × height_tiles, tile_w × tile_h) + tile/instance counts
  (c) home_portal spawn coordinates + which extracted source identified them
  (d) Any sprite_id that couldn't be resolved (with reason — invisible logic object, missing extracted asset, etc.)
  (e) Confirmation `pnpm verify:phase-6` passes end-to-end after Wave 11+12 plans land
  (f) Pointer to 06-16 for D-27a nametag + 8-direction sprite-state wiring
  (g) Trace tag verification snippet for REQ-AST-01 + REQ-CLI-07 impl + doc + int stages
  </output>
  </content>
  </invoke>```

  ### Plan 06-15
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 15
  type: execute
  wave: 11
  depends_on: [06-12]
  files_modified:
    - apps/client/src/scenes/BootScene.ts
    - apps/client/src/scenes/LoginScene.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/net/InputDispatcher.ts
    - apps/client/src/__test__/game-scene.test.ts
    - apps/client/src/__test__/login-scene.test.ts
    - apps/client/src/__test__/input-dispatcher.test.ts
    - apps/client/test/e2e/cookie-reload.e2e.test.ts
    - apps/client/test/e2e/ws-kill-reconnect.e2e.test.ts
  autonomous: true
  requirements: [CLI-09, CLI-04]
  must_haves:
    truths:
      - "Cookie auto-login path threads Better-Auth session.token through BootScene → LoginSceneInitData → GameSceneInitData (Option A — three edits per 06-12-DEBUG-FINDINGS.md §4)"
      - "GameScene self-heals on missing token by calling getSession() in create() and adopting session.token if init-data sessionToken is undefined (Option B — defence in depth, complementary to A)"
      - "GameScene.connect() no longer silently early-returns on the cookie path: with token present, joinRebnoRoom is called → bindHandlers fires → players.onAdd / onRoomLayout / chat all wire up → canvas renders content"
      - "Stale InputDispatcher.room reference fixed: after silent_reauth, dispatcher.room === GameScene.room (not the closed/garbage-collected old room object) — addresses §5 secondary defect from 06-12-DEBUG-FINDINGS"
      - "Two new Playwright e2e files (cookie-reload + ws-kill-reconnect) cover both reconnect paths from 06-HUMAN-UAT.md Test 1 reload-after-login + Test 5 WS-kill-then-reconnect — both assert canvas[data-game-ready=true] re-renders within 5s + a chat round-trip works post-reconnect"
      - "Three new vitest unit files lock the regression: GameScene.test (cookie path token threading + Option B self-heal), LoginScene.test (fast-path forwards sessionToken), InputDispatcher.test (room ref refreshed after silent_reauth)"
      - "No server-side changes — server bearer-vs-cookie posture is correct per 06-12-DEBUG-FINDINGS §7"
    artifacts:
      - path: apps/client/src/scenes/BootScene.ts
        provides: "Forwards session.token into LoginSceneInitData alongside username (Option A edit 1 of 3)"
        contains: "sessionToken"
      - path: apps/client/src/scenes/LoginScene.ts
        provides: "LoginSceneInitData accepts sessionToken; renderFastPath forwards { sessionToken, username } to scene.start('GameScene', ...) (Option A edits 2 + 3 of 3)"
        contains: "sessionToken"
      - path: apps/client/src/scenes/GameScene.ts
        provides: "create() self-heals on missing sessionToken via getSession() (Option B); connect() now reaches joinRebnoRoom on cookie path; InputDispatcher room ref refreshed on every reconnect"
        contains: "sessionToken"
      - path: apps/client/src/net/InputDispatcher.ts
        provides: "Adds setRoom(room) method (or reassigns the room field via the existing API surface) so GameScene can refresh the dispatcher's room ref after silent_reauth"
        contains: "setRoom"
      - path: apps/client/src/__test__/game-scene.test.ts
        provides: "Adds cookie-path test: scene.init({ username }) with no sessionToken → mocked getSession returns token → assert joinRebnoRoom IS called with the token"
        contains: "cookie path"
      - path: apps/client/src/__test__/login-scene.test.ts
        provides: "Adds fast-path test: assert scene.start('GameScene', ...) is called with { sessionToken, username } when LoginScene receives fastPath: true + sessionToken"
        contains: "fast-path"
      - path: apps/client/src/__test__/input-dispatcher.test.ts
        provides: "Adds silent-reauth test: dispatcher.room === gameScene.room after a simulated reconnect cycle"
        contains: "silent_reauth"
      - path: apps/client/test/e2e/cookie-reload.e2e.test.ts
        provides: "Playwright e2e — login + reload → canvas[data-game-ready=true] within 5s + chat line renderable"
        contains: "reload"
      - path: apps/client/test/e2e/ws-kill-reconnect.e2e.test.ts
        provides: "Playwright e2e — login + page.evaluate(__rebno.room.connection.close()) → SDK auto-reconnects within grace → canvas[data-game-ready=true] still truthy + chat round-trips"
        contains: "ws-kill"
    key_links:
      - from: apps/client/src/scenes/BootScene.ts
        to: apps/client/src/auth/client.ts (getSession returns SessionShape.token)
        via: "const session = await getSession(); → forward session.token alongside session.user.username"
        pattern: "session.token"
      - from: apps/client/src/scenes/LoginScene.ts
        to: apps/client/src/scenes/GameScene.ts
        via: "scene.start('GameScene', { sessionToken, username, mustForceReset }) — fast path matches form-submit shape (LoginScene.ts:173-177)"
        pattern: "sessionToken"
      - from: apps/client/src/scenes/GameScene.ts (refreshed room ref)
        to: apps/client/src/net/InputDispatcher.ts
        via: "this.inputDispatcher.setRoom(this.room) on every reconnect (refresh-not-recreate; preserves any pending input buffer state)"
        pattern: "setRoom|inputDispatcher.room"
      - from: apps/client/test/e2e/cookie-reload.e2e.test.ts + ws-kill-reconnect.e2e.test.ts
        to: apps/client/test/e2e/fixtures.ts
        via: "loginAs + waitForGameReady fixtures — reuse 06-08 patterns; the new tests extend, not replace, the cli-08.e2e.test.ts family"
        pattern: "loginAs|waitForGameReady"
  ---

  <objective>
  Close UAT Finding #2 (D-25) and the secondary defect (§5 in 06-12-DEBUG-FINDINGS.md): the cookie auto-login path drops the Better-Auth `session_token` between BootScene → LoginScene fast-path → GameScene, causing GameScene.connect() to silently early-return at the empty-token guard. Apply Option A (three edits threading the token through scene-init data) AND Option B (GameScene self-heal via getSession() in create()) — they're complementary, ship both. Also fold in the secondary defect: `InputDispatcher` keeps a stale `room` reference after silent_reauth (the `if (!this.inputDispatcher)` guard prevents re-pointing at the new room object) — refresh the dispatcher's room ref on every reconnect.

  Lock the regression with five new tests:
  1. Vitest GameScene unit (cookie path: init without sessionToken → getSession self-heal → joinRebnoRoom IS called)
  2. Vitest LoginScene unit (fast-path forwards sessionToken to GameScene)
  3. Vitest InputDispatcher unit (room ref refreshed after silent_reauth — `dispatcher.room === gameScene.room`)
  4. Playwright cookie-reload e2e (login → reload → canvas re-renders + chat works)
  5. Playwright ws-kill-reconnect e2e (login → kill WS → SDK auto-reconnects within grace → canvas still truthy + chat round-trips)

  Purpose: per 06-12-DEBUG-FINDINGS.md (the input contract for this plan, produced by 06-12). Server-side bearer-vs-cookie posture is CORRECT — do NOT touch `apps/server/src/RebnoRoom.ts` or any server bearer logic; the bug is purely client-side data flow per §7 of the debug doc.

  Output: 4 client-source edits + 1 dispatcher API tweak + 3 vitest files + 2 Playwright files. Per gap-closure addendum line 361 — `[impl/int->REQ-CLI-09] [impl->REQ-CLI-04]`.
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md
  @.planning/debug/reconnect-blank-render.md
  @apps/client/src/scenes/BootScene.ts
  @apps/client/src/scenes/LoginScene.ts
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/auth/client.ts
  @apps/client/src/net/colyseus-client.ts
  @apps/client/src/net/reconnect.ts
  @apps/client/src/net/InputDispatcher.ts
  @apps/client/test/e2e/cli-08.e2e.test.ts
  @apps/client/test/e2e/fixtures.ts

  <interfaces>
  <!-- Verbatim from 06-12-DEBUG-FINDINGS.md §3 §4 §5 — do not re-derive. -->

  Affected files (06-12 §3) — edit by 06-15:
    - apps/client/src/scenes/BootScene.ts (line 78-95) — Option A edit 1
    - apps/client/src/scenes/LoginScene.ts (line 80-116, esp. 106-109) — Option A edits 2 + 3
    - apps/client/src/scenes/GameScene.ts (line 191-192) — Option B self-heal in create()
    - apps/client/src/scenes/GameScene.ts (line 238-252) — secondary defect: refresh InputDispatcher.room
    - apps/client/src/net/InputDispatcher.ts — add setRoom method (or expose room as settable)

  Affected files (06-12 §3) — read-only / DO NOT touch:
    - apps/client/src/auth/client.ts (84-108) — getSession already returns token; consume verbatim
    - apps/client/src/net/colyseus-client.ts (82-99) — cached-reconnect path; do not modify
    - apps/server/src/RebnoRoom.ts (267-269) — server bearer-only contract is CORRECT; do not modify

  Option A (06-12 §4): three edits in priority order:
    1. BootScene.create line 88-95: forward session.token into LoginSceneInitData
    2. LoginScene.init / LoginSceneInitData line 26-51: accept sessionToken?: string; store on instance
    3. LoginScene.renderFastPath line 106-109: pass { sessionToken, username } to scene.start('GameScene', ...)

  Option B (06-12 §4): in GameScene.create, if this.sessionToken is undefined, call getSession() and adopt session.token. Combine with A — they're complementary.

  Secondary defect (06-12 §5):
    Current code in GameScene.ts:238-252 — `if (!this.inputDispatcher)` guard creates dispatcher once;
    never re-points at new this.room after silent_reauth.
    Fix: change to either always-recreate, or call this.inputDispatcher.setRoom(this.room) on the
    not-first reconnect path.

  Required regression tests (06-12 §6) — five total:
    1. Unit (game-scene.test.ts): cookie path token-threading + Option B self-heal
    2. Unit (login-scene.test.ts): fast-path forwards sessionToken
    3. Unit (input-dispatcher.test.ts): silent_reauth — dispatcher.room === gameScene.room
    4. e2e (cookie-reload.e2e.test.ts): login → reload → re-renders + chat
    5. e2e (ws-kill-reconnect.e2e.test.ts): kill WS → reconnect → still renders + chat round-trips

  LoginSceneInitData shape (existing, find at LoginScene.ts:26-51):
    interface LoginSceneInitData {
      fastPath?: boolean;
      username?: string;
      // ADD: sessionToken?: string;
    }

  GameSceneInitData shape (existing, find at GameScene.ts top):
    interface GameSceneInitData {
      sessionToken?: string;       // already present per LoginScene form-submit path
      username?: string;
      mustForceReset?: boolean;
    }

  InputDispatcher API (existing, find at apps/client/src/net/InputDispatcher.ts):
    class InputDispatcher {
      constructor(public room: Room, ...);
      // ADD: setRoom(room: Room): void { this.room = room; /* preserve any internal buffer state */ }
    }

  E2E fixtures (existing, apps/client/test/e2e/fixtures.ts):
    loginAs(page, account)              — drives form submit
    waitForGameReady(page)              — waits for canvas[data-game-ready=true]
    accountA / accountB                 — UAT seed accounts
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: Option A token-threading edits (BootScene + LoginScene) + Option B self-heal (GameScene) + LoginScene unit test</name>
    <files>
      apps/client/src/scenes/BootScene.ts,
      apps/client/src/scenes/LoginScene.ts,
      apps/client/src/scenes/GameScene.ts,
      apps/client/src/__test__/login-scene.test.ts,
      apps/client/src/__test__/game-scene.test.ts
    </files>
    <read_first>
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md (whole file — input contract),
      apps/client/src/scenes/BootScene.ts (lines 78-95 — current cookie-fetch + scene.start to LoginScene),
      apps/client/src/scenes/LoginScene.ts (lines 26-51 init data shape; lines 80-116 fast-path render; lines 173-177 form-submit-path scene.start as the canonical reference),
      apps/client/src/scenes/GameScene.ts (lines 191-192 connect early-return; line 238-252 InputDispatcher; init data shape + create() lifecycle),
      apps/client/src/auth/client.ts (lines 84-108 — getSession returns SessionShape with token),
      apps/client/src/__test__/game-scene.test.ts (existing test harness shape — copy imports + describe/it pattern; mock createAuthClient or getSession appropriately),
      apps/client/src/__test__/login-scene.test.ts (existing if any; if absent, model after game-scene.test.ts)
    </read_first>
    <behavior>
      - BootScene: when getSession() returns a non-null session, scene.start('LoginScene', { fastPath: true, username, sessionToken: session.token }) — adds the third field
      - LoginSceneInitData type adds sessionToken?: string; LoginScene stores it on the instance in init()
      - LoginScene.renderFastPath (the timer that transitions to GameScene at line 106-109): scene.start('GameScene', { sessionToken: this.sessionToken, username: this.username }) — matches form-submit-path shape
      - GameScene.create(): if this.sessionToken is undefined AND init data carried { username }, call getSession() asynchronously; on resolve, adopt session.token into this.sessionToken; then call connect(). This is Option B — defensive against future scene-init shape drift
      - GameScene.connect early-return at line 191-192 stays as a defensive guard but should now never fire on the cookie path
      - Vitest tests: LoginScene fast-path forwards sessionToken; GameScene cookie-path self-heal — getSession mock returns token + assert joinRebnoRoom IS called with that token
    </behavior>
    <action>
  **1. apps/client/src/scenes/BootScene.ts** — Tag `// [impl->REQ-CLI-09] [impl->REQ-CLI-04]` near the cookie-fetch block. Find lines 78-95:

  Current:
  ```ts
  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 });
  }
  ```

  Change to:
  ```ts
  // [impl->REQ-CLI-09] [impl->REQ-CLI-04]
  // Option A: forward session.token alongside username so LoginScene fast-path can
  // pass it to GameScene (otherwise GameScene.connect() silently early-returns at
  // the !sessionToken guard — UAT Finding #2 / D-25).
  const session = await getSession().catch(() => null);
  ...
  if (session) {
    this.scene.start('LoginScene', {
      fastPath: true,
      username: session.user.username,
      sessionToken: session.token,
    });
  } else {
    this.scene.start('LoginScene', { fastPath: false });
  }
  ```

  **2. apps/client/src/scenes/LoginScene.ts** — Tag `// [impl->REQ-CLI-09] [impl->REQ-CLI-04]` near the init data type + at the renderFastPath site.

  Find LoginSceneInitData (lines 26-51 area):
  ```ts
  interface LoginSceneInitData {
    fastPath?: boolean;
    username?: string;
    // ADD:
    sessionToken?: string;
  }
  ```

  Find init() method — store sessionToken on the instance:
  ```ts
  init(data: LoginSceneInitData = {}): void {
    this.fastPath = !!data.fastPath;
    this.username = data.username;
    this.sessionToken = data.sessionToken;  // ADD
    // ... existing init body
  }
  ```

  Find renderFastPath / fastPathTimer (lines 106-109):
  ```ts
  this.fastPathTimer = this.time.delayedCall(500, () => {
    spinner.destroy();
    // OPTION A edit 3: forward sessionToken (matches form-submit-path shape at line 173-177)
    this.scene.start('GameScene', {
      sessionToken: this.sessionToken,
      username: this.username,
    });
  });
  ```

  **3. apps/client/src/scenes/GameScene.ts** — Tag `// [impl->REQ-CLI-09] [impl->REQ-CLI-04]` near create() + the early-return guard.

  In create() — Option B self-heal:
  ```ts
  create(): void {
    // ... existing chatHud/banner/escMenu mount ...
    // [impl->REQ-CLI-09] [impl->REQ-CLI-04]
    // Option B: if init data did not carry sessionToken (e.g. future scene-init refactor
    // breaks the cookie path again), self-heal by re-fetching the session and adopting
    // its token. Defence-in-depth alongside Option A.
    if (!this.sessionToken && this.username) {
      void this.recoverSessionTokenAndConnect();
    } else {
      void this.connect(env.wssUrl);
    }
  }

  private async recoverSessionTokenAndConnect(): Promise<void> {
    try {
      const session = await getSession();
      if (session?.token) {
        this.sessionToken = session.token;
      }
    } catch (err) {
      console.warn('[GameScene] recoverSessionTokenAndConnect: getSession failed', err);
    }
    await this.connect(env.wssUrl);
  }
  ```

  Leave the existing `if (!this.sessionToken) return;` guard at line 191-192 as a defensive check — Option B feeds the token before connect() is called, so the guard now never fires on the cookie path.

  (Adapt to the actual create() control flow — if create() already invokes connect() at the bottom, gate that call behind the same async-recovery branch. The intent: connect() always sees a token by the time it runs on the cookie path.)

  **4. apps/client/src/__test__/login-scene.test.ts** — Tag `// [unit->REQ-CLI-09]`. New file or extend existing.

  ```ts
  // [unit->REQ-CLI-09]
  import { describe, it, expect, vi, beforeEach } from 'vitest';
  import { LoginScene } from '../scenes/LoginScene';

  describe('LoginScene fast-path token threading (regression for UAT Finding #2)', () => {
    it('forwards sessionToken to GameScene when fastPath + sessionToken provided', () => {
      const scene = new LoginScene();
      // Mock the Phaser scene API surface needed
      const sceneStartSpy = vi.fn();
      (scene as any).scene = { start: sceneStartSpy };
      (scene as any).time = { delayedCall: (_ms: number, cb: () => void) => cb() }; // execute immediately
      (scene as any).add = { text: () => ({ destroy: vi.fn() }) }; // minimal stub for spinner

      scene.init({ fastPath: true, username: 'me', sessionToken: 'tok-123' });
      // call the fast-path render method (or invoke create() if fast path runs from there)
      (scene as any).renderFastPath?.() ?? scene.create?.();

      expect(sceneStartSpy).toHaveBeenCalledWith('GameScene', expect.objectContaining({
        sessionToken: 'tok-123',
        username: 'me',
      }));
    });

    it('does NOT forward sessionToken when fastPath is false (form-submit path takes over)', () => {
      const scene = new LoginScene();
      const sceneStartSpy = vi.fn();
      (scene as any).scene = { start: sceneStartSpy };
      scene.init({ fastPath: false });
      // form-submit path is exercised by other tests; here just assert fast-path doesn't fire
      expect(sceneStartSpy).not.toHaveBeenCalled();
    });
  });
  ```

  (Adapt the mocked scene API to LoginScene's actual Phaser dependencies — the executor reads the file and stubs only what's needed.)

  **5. apps/client/src/__test__/game-scene.test.ts** — Add cookie-path test. Tag `// [unit->REQ-CLI-09] [unit->REQ-CLI-04]`.

  ```ts
  // [unit->REQ-CLI-09] [unit->REQ-CLI-04]
  import { describe, it, expect, vi } from 'vitest';
  import { GameScene } from '../scenes/GameScene';
  import * as authClient from '../auth/client';
  import * as colyseusClient from '../net/colyseus-client';

  describe('GameScene cookie-path self-heal (Option B regression)', () => {
    it('init({ username }) without sessionToken → getSession returns token → joinRebnoRoom called with token', async () => {
      const joinSpy = vi.spyOn(colyseusClient, 'joinRebnoRoom').mockResolvedValue({ /* mock Room */ } as any);
      vi.spyOn(authClient, 'getSession').mockResolvedValue({ user: { username: 'me' }, token: 'recovered-tok' } as any);

      const scene = new GameScene();
      // Stub Phaser surface as needed
      (scene as any).cameras = { main: { setBackgroundColor: vi.fn() } };
      (scene as any).load = { atlas: vi.fn() };
      (scene as any).add = { sprite: vi.fn() };

      scene.init({ username: 'me' }); // no sessionToken — cookie path drift simulation
      await scene.create();

      // Allow any pending microtasks (recoverSessionTokenAndConnect) to resolve
      await new Promise((r) => setTimeout(r, 50));

      expect(authClient.getSession).toHaveBeenCalled();
      expect(joinSpy).toHaveBeenCalledWith(
        expect.anything(),
        expect.objectContaining({ token: 'recovered-tok' }),
      );
    });

    it('init({ sessionToken, username }) → joinRebnoRoom called immediately without getSession', async () => {
      const joinSpy = vi.spyOn(colyseusClient, 'joinRebnoRoom').mockResolvedValue({ /* mock Room */ } as any);
      const sessionSpy = vi.spyOn(authClient, 'getSession');

      const scene = new GameScene();
      (scene as any).cameras = { main: { setBackgroundColor: vi.fn() } };
      (scene as any).load = { atlas: vi.fn() };
      (scene as any).add = { sprite: vi.fn() };

      scene.init({ sessionToken: 'tok-direct', username: 'me' });
      await scene.create();
      await new Promise((r) => setTimeout(r, 50));

      expect(sessionSpy).not.toHaveBeenCalled(); // Option A path — no recovery needed
      expect(joinSpy).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ token: 'tok-direct' }));
    });
  });
  ```

  (Adjust mocks to match the actual joinRebnoRoom + getSession signatures.)
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test -- src/__test__/login-scene.test.ts src/__test__/game-scene.test.ts &amp;&amp; pnpm --filter @rebno/client typecheck</automated>
    </verify>
    <done>
      BootScene forwards session.token; LoginScene init data accepts sessionToken + fast-path forwards it; GameScene create() self-heals via getSession when sessionToken absent. Two vitest test files lock both Option A and Option B paths. typecheck green. Cookie auto-login no longer renders blank canvas (manual smoke deferred to Task 3 e2e + 06-17 UAT).
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: InputDispatcher.setRoom + GameScene refresh on every reconnect + dispatcher unit test</name>
    <files>
      apps/client/src/net/InputDispatcher.ts,
      apps/client/src/scenes/GameScene.ts,
      apps/client/src/__test__/input-dispatcher.test.ts
    </files>
    <read_first>
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md §5 (secondary defect — verbatim),
      apps/client/src/net/InputDispatcher.ts (current shape — find the room field; check whether it's exposed as constructor-only or settable),
      apps/client/src/scenes/GameScene.ts (lines 238-252 — current `if (!this.inputDispatcher)` guard)
    </read_first>
    <behavior>
      - InputDispatcher exposes setRoom(room) — refreshes the internal room reference without recreating the dispatcher (preserves any pending input buffer / sequence counter state)
      - GameScene.connect (or wherever the post-reconnect re-bind happens, around line 238-252): replaces `if (!this.inputDispatcher) { ... new InputDispatcher(this.room, ...) }` with: if first connect → create; if subsequent reconnect → setRoom(this.room)
      - Unit test: simulate two-step lifecycle (first connect → dispatch creation; silent_reauth → setRoom) and assert dispatcher.room === gameScene.room (referential equality on the new room object)
    </behavior>
    <action>
  **1. apps/client/src/net/InputDispatcher.ts** — Tag `// [impl->REQ-CLI-09] [impl->REQ-CLI-04]` near the new method.

  ```ts
  // [impl->REQ-CLI-09] [impl->REQ-CLI-04]
  export class InputDispatcher {
    // ... existing fields including `private _room: Room` (or `public room: Room`) ...

    constructor(room: Room, /* ... */) {
      this._room = room;
      // ... existing constructor body ...
    }

    /** Refresh the room reference after a silent_reauth without recreating the
     *  dispatcher — preserves any internal sequence counter / buffer state.
     *  Closes UAT Finding #2 secondary defect (06-12-DEBUG-FINDINGS §5). */
    setRoom(room: Room): void {
      this._room = room;
    }

    /** Public read for tests */
    get room(): Room { return this._room; }

    // ... existing dispatch() etc. — all internal references go through this._room
  }
  ```

  (If InputDispatcher already exposes `room` as a public readonly field, change to a private + getter+setter without breaking existing callers. Tests rely on `.room` read access.)

  **2. apps/client/src/scenes/GameScene.ts** — Tag `// [impl->REQ-CLI-09]` near the InputDispatcher block (lines 238-252).

  ```ts
  // [impl->REQ-CLI-09]
  // Refresh dispatcher's room ref after silent_reauth. Without this, the
  // `if (!this.inputDispatcher)` guard left a closed/garbage-collected room
  // reference inside the dispatcher → movement intents went to /dev/null
  // (06-12-DEBUG-FINDINGS §5 secondary defect, in scope here per CONTEXT
  // addendum line 361).
  if (!this.inputDispatcher) {
    this.inputDispatcher = new InputDispatcher(this.room, stubPrediction);
  } else {
    this.inputDispatcher.setRoom(this.room);
  }
  ```

  **3. apps/client/src/__test__/input-dispatcher.test.ts** — New or extended. Tag `// [unit->REQ-CLI-09]`.

  ```ts
  // [unit->REQ-CLI-09]
  import { describe, it, expect } from 'vitest';
  import { InputDispatcher } from '../net/InputDispatcher';

  describe('InputDispatcher.setRoom (silent_reauth fix — 06-12-DEBUG-FINDINGS §5)', () => {
    it('exposes setRoom that refreshes the internal room reference', () => {
      const room1 = { send: () => {}, sessionId: 'r1' } as any;
      const room2 = { send: () => {}, sessionId: 'r2' } as any;
      const stubPrediction = { /* minimal stub */ } as any;
      const d = new InputDispatcher(room1, stubPrediction);
      expect(d.room).toBe(room1);
      d.setRoom(room2);
      expect(d.room).toBe(room2);
    });

    it('preserves internal state across setRoom (no buffer reset)', () => {
      // If InputDispatcher carries a sequence counter or input buffer, the
      // executor adds an assertion here that it survives the setRoom call.
      const room1 = { send: () => {}, sessionId: 'r1' } as any;
      const room2 = { send: () => {}, sessionId: 'r2' } as any;
      const d = new InputDispatcher(room1, {} as any);
      // Simulate one input dispatch (advance internal seq counter if exposed)
      if ((d as any).dispatch) (d as any).dispatch({ axes: { x: 1, y: 0 } });
      const seqBefore = (d as any).seq ?? null;
      d.setRoom(room2);
      const seqAfter = (d as any).seq ?? null;
      if (seqBefore !== null) expect(seqAfter).toBe(seqBefore);
    });
  });
  ```

  (Adapt mocks to InputDispatcher's actual constructor signature.)
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test -- src/__test__/input-dispatcher.test.ts &amp;&amp; pnpm --filter @rebno/client typecheck</automated>
    </verify>
    <done>
      InputDispatcher.setRoom shipped + 2 vitest tests GREEN. GameScene's reconnect path uses setRoom on subsequent connects (refresh) instead of leaving a stale ref. typecheck green. Movement after silent_reauth now reaches the new room object (manual smoke deferred to Task 3 e2e).
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: Two new Playwright e2e files — cookie-reload + ws-kill-reconnect</name>
    <files>
      apps/client/test/e2e/cookie-reload.e2e.test.ts,
      apps/client/test/e2e/ws-kill-reconnect.e2e.test.ts
    </files>
    <read_first>
      apps/client/test/e2e/cli-08.e2e.test.ts (canonical e2e shape — copy describe/test imports + loginAs + waitForGameReady fixture usage),
      apps/client/test/e2e/fixtures.ts (loginAs / waitForGameReady / accountA / inviteSuffix; reuse all),
      apps/client/playwright.config.ts (verify auto-discovery picks up *.e2e.test.ts; both modes — local webServer + STAGING_URL — work),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md §6 (test sketches 3 + 4 — verbatim assertion list)
    </read_first>
    <behavior>
      - cookie-reload.e2e.test.ts: login + assert canvas[data-game-ready=true] → page.reload() → re-assert canvas[data-game-ready=true] within 5s → send a chat message → assert [data-chat-line] appears
      - ws-kill-reconnect.e2e.test.ts: login + assert canvas[data-game-ready=true] → page.evaluate close the WS → wait for SDK auto-reconnect (within Phase 4 D-22 grace = 10s) → re-assert canvas[data-game-ready=true] still truthy → send a chat message + assert round-trip
      - Both tests use existing fixtures; no new account seeding; runs in both local + STAGING_URL modes per playwright.config.ts
      - Tests are timing-tolerant (use waitForSelector with timeout, not arbitrary sleep)
    </behavior>
    <action>
  **1. apps/client/test/e2e/cookie-reload.e2e.test.ts** — Tag `// [int->REQ-CLI-09] [int->REQ-CLI-04]`.

  ```ts
  // [int->REQ-CLI-09] [int->REQ-CLI-04]
  // Regression guard for UAT Finding #2 (D-25): cookie auto-login renders blank.
  // Per 06-12-DEBUG-FINDINGS §6 test #3.

  import { test, expect } from '@playwright/test';
  import { loginAs, waitForGameReady, accountA } from './fixtures';

  test.describe('Cookie auto-login fast-path (regression for UAT Finding #2)', () => {
    test('login → reload → GameScene re-renders within 5s + chat works', async ({ page }) => {
      await loginAs(page, accountA);
      await waitForGameReady(page);

      // Reload — exercises the BootScene → cookie probe → LoginScene fast-path → GameScene chain
      await page.reload();

      // Must re-render within 5s (Option A token threading + Option B self-heal)
      await expect(page.locator('canvas[data-game-ready=true]')).toBeVisible({ timeout: 5000 });

      // Chat must work post-reload (proves bindHandlers fired → onMessage('chat') wired up)
      await page.keyboard.press('Enter');
      await page.keyboard.type('hello-after-reload');
      await page.keyboard.press('Enter');
      await expect(page.locator('[data-chat-line]').filter({ hasText: 'hello-after-reload' })).toBeVisible({ timeout: 5000 });
    });
  });
  ```

  **2. apps/client/test/e2e/ws-kill-reconnect.e2e.test.ts** — Tag `// [int->REQ-CLI-09] [int->REQ-CLI-04]`.

  ```ts
  // [int->REQ-CLI-09] [int->REQ-CLI-04]
  // Regression guard for UAT Finding #2 secondary path + UAT Test 5.
  // Per 06-12-DEBUG-FINDINGS §6 test #4.

  import { test, expect } from '@playwright/test';
  import { loginAs, waitForGameReady, accountA } from './fixtures';

  test.describe('WS-kill within grace → SDK auto-reconnect (regression for UAT Finding #2 + Test 5)', () => {
    test('kill WS → SDK reconnects within grace → canvas still truthy + chat round-trips', async ({ page }) => {
      await loginAs(page, accountA);
      await waitForGameReady(page);

      // Kill the WS — Colyseus SDK should auto-reconnect within 10s grace per Phase 4 D-22
      await page.evaluate(() => {
        const r = (window as any).__rebno?.room;
        if (r?.connection?.transport?.ws?.close) r.connection.transport.ws.close();
        else if (r?.connection?.close) r.connection.close();
      });

      // Reconnect banner appears briefly; then disappears on successful reconnect
      // canvas[data-game-ready=true] must remain truthy throughout the within-grace path
      // (the SDK preserves the same Room object — state callbacks survive)
      // Wait up to 12s for any reconnect-related transient to settle
      await page.waitForTimeout(2000); // allow banner to surface
      await expect(page.locator('canvas[data-game-ready=true]')).toBeVisible({ timeout: 12000 });

      // Verify movement intents still reach the room (closes secondary defect §5)
      // by exercising chat round-trip — chat goes through room.send too
      await page.keyboard.press('Enter');
      await page.keyboard.type('hello-after-reconnect');
      await page.keyboard.press('Enter');
      await expect(page.locator('[data-chat-line]').filter({ hasText: 'hello-after-reconnect' })).toBeVisible({ timeout: 5000 });
    });
  });
  ```

  (Both tests rely on the existing `__rebno.room` dev-hook globals — verify these are exposed in the client bundle for the test environment; they may already be wired per 06-08 SUMMARY for the existing reconnect e2e.)
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test:e2e -- --grep "Cookie auto-login|WS-kill"</automated>
    </verify>
    <done>
      Both new e2e files pass GREEN locally (against the dev-mode webServer per playwright.config.ts). Cookie-reload and WS-kill paths both render the canvas + round-trip chat. Three regression scenarios from 06-12-DEBUG-FINDINGS §6 (tests 3, 4, and the implicit movement-after-reconnect proof) are now machine-checked.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Cookie auto-login → GameScene WS auth | Bearer-only contract preserved — token must transit init data correctly OR be self-recovered via getSession; never trust a missing token to mean "anonymous" |
  | Silent_reauth → InputDispatcher | Old room object is closed; sending to it = silent message loss; setRoom refresh enforces same-room invariant |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-15-01 | Spoofing | Adversary forges init data with someone else's sessionToken | accept | Server-side bearer validation in onAuth (RebnoRoom.ts:267-269) is the trust boundary; client init data is ephemeral process state, no cross-user vector |
  | T-06-15-02 | DoS | getSession() self-heal hangs → GameScene never connects → UX deadlock | mitigate | Option B uses try/catch + eventual fallback to defensive early-return; reconnect banner surfaces if connect() fails after recovery |
  | T-06-15-03 | Tampering | Future PR removes setRoom call → silent_reauth re-introduces stale room ref | mitigate | input-dispatcher.test.ts pins setRoom existence + behavior; ws-kill-reconnect.e2e.test.ts pins the end-to-end behavior; trace:check holds tags |
  | T-06-15-04 | Repudiation | "We don't know which path the bug shipped on" | mitigate | Two distinct e2e files (cookie-reload + ws-kill-reconnect) match UAT reproducer steps verbatim; report logs preserve evidence |
  </threat_model>

  <verification>
  - `pnpm --filter @rebno/client test` exits 0 (LoginScene + GameScene + InputDispatcher unit tests added; nothing else regresses)
  - `pnpm --filter @rebno/client test:e2e` exits 0 (existing cli-08 + login-contrast + logout + new cookie-reload + ws-kill-reconnect)
  - `pnpm --filter @rebno/client typecheck` exits 0
  - Manual smoke (deferred to 06-17): operator logs in, reloads, sees content; operator logs in, kills WS via DevTools, sees content remain
  - `pnpm verify:phase-6` passes end-to-end after Wave 11+12 land
  - `pnpm trace:check` reports `[impl->REQ-CLI-09] [impl->REQ-CLI-04] [unit->REQ-CLI-09] [int->REQ-CLI-09]` tags satisfied
  - ZERO modifications to apps/server/src/RebnoRoom.ts (server bearer-only contract preserved per 06-12 §7)
  </verification>

  <success_criteria>
  - UAT Finding #2 (D-25) closed: cookie auto-login + reload + WS-kill all render the canvas with content + chat works
  - §5 secondary defect (stale InputDispatcher.room after silent_reauth) closed: dispatcher always points at current room
  - Both Option A (token threading — three edits) AND Option B (getSession self-heal) shipped — defence in depth
  - Five regression tests landed (3 vitest + 2 Playwright) — pinning cookie path, fast-path forwarding, dispatcher refresh, reload-render, ws-kill-render
  - ZERO server-side changes — bearer-only WS auth preserved per 06-12-DEBUG-FINDINGS §7
  - Tags `[impl/int->REQ-CLI-09] [impl->REQ-CLI-04]` (per gap-closure addendum line 361)
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-15-SUMMARY.md` recording:
  (a) Three Option A edit locations confirmed (BootScene, LoginScene init data, LoginScene fast-path)
  (b) Option B self-heal path confirmed (GameScene.create + recoverSessionTokenAndConnect helper or equivalent)
  (c) InputDispatcher API change (setRoom signature)
  (d) Test results: 3 vitest GREEN + 2 Playwright GREEN (paste counts)
  (e) Confirmation that apps/server/src/RebnoRoom.ts is untouched
  (f) Trace tag verification snippet for REQ-CLI-09 + REQ-CLI-04 across impl, unit, int stages
  </output>
  </content>
  </invoke>```

  ### Plan 06-16
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 16
  type: execute
  wave: 11
  depends_on: [06-13, 06-14]
  files_modified:
    - apps/client/src/render/SpriteStateMachine.ts
    - apps/client/src/render/PlayerRenderer.ts
    - apps/client/src/render/Nameplate.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/__test__/sprite-state-machine.test.ts
    - apps/client/src/__test__/nameplate.test.ts
    - apps/client/test/e2e/cli-08.e2e.test.ts
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SPRITE-STATE-DERIVATION.md
  autonomous: true
  requirements: [CLI-04, CLI-07]
  must_haves:
    truths:
      - "8-direction facing is derived deterministically from the velocity vector (x, y) — pure function vec2 → ('D' | 'U' | 'L' | 'R' | 'DR' | 'DL' | 'UR' | 'UL'); zero-velocity preserves the last facing direction (no flicker on stop)"
      - "Walk-cycle phase advances by step counter at TICK_RATE_HZ (from 06-13 constants); cycle length matches BNO frame_count for the relevant Run sprite (NaviRunD = 6 frames per 06-14 manifest)"
      - "Stand vs Run sprite selection: when |velocity| > 0 → Run<DIR> with cycle phase; when |velocity| === 0 → Stand<DIR> single frame"
      - "All 16 player atlas frames from 06-14 are addressable (Stand × 8: 0000-StandD, 0029-StandR, 0030-StandU, 0033-StandL, 0036-StandDR, 0038-StandUR, 0040-StandUL, 0042-StandDL; Run × 8: 0027-RunD, 0028-RunR, 0031-RunU, 0032-RunL, 0037-RunDR, 0039-RunUR, 0041-RunUL, 0043-RunDL)"
      - "Cyan nametag (D-27a) renders above player sprite head — anchored top-center, follows movement, visible for self + remotes; cyan color from extracted 0000-server Draw.gml (verify before locking; UI-SPEC §Color #22D3EE accent is the fallback if no exact value extractable)"
      - "Frame-perfect to BNO: 0000-server Step + KeyPress events are the canonical reference for facing-on-input semantics (do they update facing on keydown OR on next Step?); the SpriteStateMachine matches this timing"
      - "Updated cli-08.e2e.test.ts asserts: when player moves in direction X, the sprite frame is the corresponding RunX_<phase> frame (not StandD); when stopped, it's the StandX (last-direction) frame; nameplate text is visible above player at all times"
    artifacts:
      - path: apps/client/src/render/SpriteStateMachine.ts
        provides: "Pure function: deriveFrame(velX, velY, lastFacing, cyclePhase) → { frameKey: string, newFacing: Direction }; consumed by PlayerRenderer per tick"
        contains: "deriveFrame"
      - path: apps/client/src/render/PlayerRenderer.ts
        provides: "Per-tick: read velocity → call SpriteStateMachine.deriveFrame → sprite.setFrame(frameKey); applies to self + remotes (remotes use input_axes from PlayerState per Phase 6 D-08)"
        contains: "deriveFrame"
      - path: apps/client/src/render/Nameplate.ts
        provides: "Phaser Text per remote (and self) above sprite head; cyan color; follows position each frame; mirror DOM `<div data-nameplate>` for Playwright per UI-SPEC surface inventory"
        contains: "Nameplate"
      - path: apps/client/src/__test__/sprite-state-machine.test.ts
        provides: "Pure-function tests: 8 direction quadrants, zero-velocity preserves last facing, cycle phase increments correctly, Stand vs Run switch on velocity threshold"
        contains: "deriveFrame"
      - path: apps/client/src/__test__/nameplate.test.ts
        provides: "Nameplate tests: cyan color (#22D3EE or extracted exact value), top-center anchor, follows position, DOM mirror node carries username"
        contains: "Nameplate"
      - path: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SPRITE-STATE-DERIVATION.md
        provides: "Doc-only: maps each of 8 facing directions to the atlas frame; documents BNO timing semantics (facing-on-keydown vs on-next-step); records cyan exact value derivation from 0000-server Draw.gml or fallback"
        contains: "0000-server"
    key_links:
      - from: apps/client/src/render/SpriteStateMachine.ts
        to: apps/client/src/render/PlayerRenderer.ts
        via: "PlayerRenderer.update(dt) calls deriveFrame(...) each tick → sprite.setFrame(frameKey)"
        pattern: "deriveFrame|setFrame"
      - from: apps/client/src/render/PlayerRenderer.ts
        to: packages/game-logic constants (TICK_RATE_HZ from 06-13)
        via: "cycle phase advance uses TICK_RATE_HZ for deterministic timing"
        pattern: "TICK_RATE_HZ"
      - from: apps/client/src/render/PlayerRenderer.ts
        to: PlayerState.input_axes (Phase 6 D-08 schema field)
        via: "remote players use input_axes (broadcast from server per D-08) as the velocity vector input to deriveFrame"
        pattern: "input_axes"
      - from: apps/client/src/render/Nameplate.ts
        to: PlayerState.username
        via: "Nameplate.setText(player.username); positioned above sprite each frame"
        pattern: "username"
      - from: apps/client/test/e2e/cli-08.e2e.test.ts
        to: data-player-frame attribute (or similar dev-hook)
        via: "Phaser sprite frame name surfaced via dev-hook OR canvas pixel-sample comparison; e2e asserts Run<DIR> frame after move"
        pattern: "data-player-frame|RunD"
  ---

  <objective>
  Close UAT Finding #3b (D-26 Plan B) and ship D-27a nametag: build the 8-direction sprite-state machine that drives PlayerRenderer's frame selection from the velocity vector + walk-cycle phase, mapped onto the player atlas frames bootstrapped in 06-14, using the deterministic numeric constants (TICK_RATE_HZ etc.) from 06-13. Add the cyan nametag rendered above each player sprite (self + remotes), anchored top-center, following movement.

  Why this combines two findings into one plan: D-27a explicitly says the nametag belongs to the per-player render path (the natural home is wherever 8-direction sprite selection lives — same module). 06-16 owns that module; bundling cuts a useless plan-boundary in half. CONTEXT addendum line 339 lock-in confirms this scoping.

  Frame-perfect to BNO: read `extracted/client-5-8/objects/0000-server/events/Step.gml` + `Keyboard-37/38/39/40.gml` + `KeyRelease-*.gml` + `Draw.gml` to confirm facing-on-input timing semantics (does facing update on keydown OR on next Step tick? does Run start on tick 0 or tick 1?) and extract the exact cyan color used by the original draw call (the UI-SPEC §Color #22D3EE accent is the fallback if 0000-server Draw.gml does not specify an exact text-color value).

  Per CONTEXT addendum line 362 — `[impl/unit->REQ-CLI-04]` (movement prediction; sprite-state); D-27a nametag also touches CLI-07 (room rendering, since nameplate sits in the room space).

  Output: 1 new pure-function module (SpriteStateMachine) + 1 modified renderer (PlayerRenderer) + 1 new UI module (Nameplate) + GameScene wiring + 2 vitest unit files + cli-08.e2e.test.ts assertion update + derivation doc.
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-PATTERNS.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md
  @apps/client/src/render/PlayerRenderer.ts
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/__test__/player-renderer.test.ts
  @packages/game-logic/src/constants.ts
  @packages/protocol/src/state.ts
  @extracted/client-5-8/objects/0000-server/events/Step.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-37.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-38.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-39.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-40.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-37.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-38.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-39.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-40.gml
  @extracted/client-5-8/objects/0000-server/events/Draw.gml

  <interfaces>
  <!-- Atlas frame keys produced by 06-14 (15 player sprite IDs × frame_count) -->

  Standing frames (1 frame each — frameKey suffix _0):
    '0000-NaviStandD_0'  — facing DOWN
    '0029-NaviStandR_0'  — facing RIGHT
    '0030-NaviStandU_0'  — facing UP
    '0033-NaviStandL_0'  — facing LEFT
    '0036-NaviStandDR_0' — facing DOWN-RIGHT (SE)
    '0038-NaviStandUR_0' — facing UP-RIGHT (NE)
    '0040-NaviStandUL_0' — facing UP-LEFT (NW)
    '0042-NaviStandDL_0' — facing DOWN-LEFT (SW)

  Running frames (frame_count varies — typically 6; verify against 06-14 manifest):
    '0027-NaviRunD_<n>'  — n in [0..frame_count-1]
    '0028-NaviRunR_<n>'  — R-facing run (sprite exists in extracted/; restored 2026-05-10)
    '0031-NaviRunU_<n>'
    '0032-NaviRunL_<n>'
    '0037-NaviRunDR_<n>'
    '0039-NaviRunUR_<n>'
    '0041-NaviRunUL_<n>'
    '0043-NaviRunDL_<n>'

  Direction enum (lock at top of SpriteStateMachine.ts):
    type Direction = 'D' | 'U' | 'L' | 'R' | 'DR' | 'DL' | 'UR' | 'UL';

  Velocity → direction mapping (8-quadrant) — pure function:
    vec2 (vx, vy) →
      if (vx, vy) ≈ (0, 0) → preserve lastFacing
      else: angle = atan2(-vy, vx);  // -vy because screen y grows down
            map to nearest of 8 octants (each 45° wide):
              R   = [-22.5°, +22.5°]
              UR  = [+22.5°, +67.5°]
              U   = [+67.5°, +112.5°]
              UL  = [+112.5°, +157.5°]
              L   = [+157.5°, +180°] ∪ [-180°, -157.5°]
              DL  = [-157.5°, -112.5°]
              D   = [-112.5°, -67.5°]
              DR  = [-67.5°, -22.5°]
    (BNO 0000-server may use a different convention — verify against KeyPress events; e.g. some games use direction = `point_direction(0,0,hsp,vsp)` and quadrant via floor(direction/45). Match BNO if the implementation differs from this textbook formula.)

  Cycle phase advance:
    isRunning = |vec2| > 0
    if isRunning: cyclePhase = (cyclePhase + 1) mod frame_count_for(direction)
    if !isRunning: cyclePhase = 0  (or preserve — BNO behavior: most likely reset to 0 on stand)

  State output:
    if isRunning:  frameKey = `${runSpriteId(facing)}_${cyclePhase}`
    if !isRunning: frameKey = `${standSpriteId(facing)}_0`

  D-27a Nametag (UI-SPEC §Nameplate verbatim):
    - Phaser canvas Text per player (PERFORMANCE: cheaper than DOM at 50+ players, fine here)
    - Hidden DOM `<div data-nameplate data-player-id="<sessionId>">` mirror for Playwright selectors per UI-SPEC surface inventory line 23
    - Color: cyan — derive from 0000-server Draw.gml `draw_set_color(...)` call if present;
             fallback: UI-SPEC §Color accent #22D3EE
    - Font: monospace, 16px, weight 400 per UI-SPEC §Typography (or whatever Phaser's atlas font supports; if no font extracted in Phase 6, use the system monospace stack)
    - Anchor: top-center, 4px above sprite head (UI-SPEC spacing exception line 63: "Nameplate-to-sprite gap = 4px in canvas pixels")
    - Updates: each frame in PlayerRenderer.update(); positions follow sprite

  Per Phase 6 D-08: PlayerState carries `input_axes` for remote players → SpriteStateMachine consumes input_axes for remotes (so their sprite-state is derived deterministically, even during snapshot interpolation per D-11).

  Tagging:
    apps/client/src/render/SpriteStateMachine.ts → // [impl->REQ-CLI-04]
    apps/client/src/render/Nameplate.ts → // [impl->REQ-CLI-07] (nametag in room rendering)
    apps/client/src/render/PlayerRenderer.ts (header) → // [impl->REQ-CLI-04] [impl->REQ-CLI-07]
    apps/client/src/__test__/sprite-state-machine.test.ts → // [unit->REQ-CLI-04]
    apps/client/src/__test__/nameplate.test.ts → // [unit->REQ-CLI-07]
    apps/client/test/e2e/cli-08.e2e.test.ts (new assertions) → // [int->REQ-CLI-04]
    06-16-SPRITE-STATE-DERIVATION.md → [doc->REQ-CLI-04]
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: SpriteStateMachine pure module + 8-direction derivation doc + comprehensive unit tests</name>
    <files>
      apps/client/src/render/SpriteStateMachine.ts,
      apps/client/src/__test__/sprite-state-machine.test.ts,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SPRITE-STATE-DERIVATION.md
    </files>
    <read_first>
      extracted/client-5-8/objects/0000-server/events/Step.gml (canonical mover — find: how does it set sprite_index based on hsp/vsp? what variable carries facing?),
      extracted/client-5-8/objects/0000-server/events/Keyboard-37.gml + 38.gml + 39.gml + 40.gml (held-direction handlers — when does facing update? on each tick? once on keydown?),
      extracted/client-5-8/objects/0000-server/events/KeyRelease-37.gml + 38.gml + 39.gml + 40.gml (key-up — does facing reset to D? preserve last? other?),
      extracted/client-5-8/objects/0000-server/events/Draw.gml (sprite drawing call — does the original use draw_sprite_ext with a specific sprite_index? does it use image_speed for animation? this informs cycle-advance semantics),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md (which sprite IDs exist + frame counts; all 16 player sprites present including 0028-NaviRunR),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md (TICK_RATE_HZ + SUB_PIXEL_ACCUMULATOR_MODE — informs cycle phase tick advance),
      packages/game-logic/src/constants.ts (TICK_RATE_HZ value to import for tests)
    </read_first>
    <behavior>
      - SpriteStateMachine.deriveFrame is pure: same input → same output; no Date.now, no Math.random, no I/O
      - Test 1: each of 8 cardinal/diagonal velocity directions produces the corresponding facing
      - Test 2: zero-velocity preserves lastFacing (no flicker)
      - Test 3: |velocity| > 0 returns Run<DIR>_<phase>; |velocity| === 0 returns Stand<DIR>_0
      - Test 4: cycle phase wraps mod frame_count after frame_count ticks
      - Test 5: BNO timing semantics — facing updates on the same tick as keydown OR on next Step (whatever 0000-server does — derivation doc records the truth)
      - Test 6: R-direction running maps to 0028-NaviRunR (sprite exists; verifies the full 8-direction Run coverage with no fallbacks)
    </behavior>
    <action>
  **1. Read the source GML.** Note observations. Common patterns to look for:

  | GML pattern | Meaning |
  |-------------|---------|
  | `sprite_index = NaviStandR;` (in Keyboard-39 = right) | Facing updates on keydown directly |
  | `direction = 0; sprite_index = sNaviRunR; image_speed = 0.5;` | Facing + animation speed set on input |
  | `if hsp != 0 || vsp != 0 { ... animate ... } else { sprite_index = sNaviStand<dir>; }` (in Step) | Stand vs Run derived from velocity in Step event |
  | `image_index += image_speed;` | GM5 auto-advances animation; image_speed is per-tick |

  **2. Write 06-16-SPRITE-STATE-DERIVATION.md** documenting:

  ```md
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 16
  type: doc-only-derivation
  status: complete
  ---

  # 8-Direction Sprite-State Machine — Derivation from Extracted Client 5-8 GML

  [doc->REQ-CLI-04]

  ## Source GML

  | File | What it informs |
  |------|-----------------|
  | `extracted/client-5-8/objects/0000-server/events/Step.gml` | Per-tick sprite_index assignment based on hsp/vsp |
  | `extracted/client-5-8/objects/0000-server/events/Keyboard-37/38/39/40.gml` | Held-direction → does sprite_index update HERE or in Step? |
  | `extracted/client-5-8/objects/0000-server/events/KeyRelease-*.gml` | On key-up: does sprite snap to Stand<lastDir>? |
  | `extracted/client-5-8/objects/0000-server/events/Draw.gml` | draw_sprite_ext call; cyan text-color reference (D-27a nametag) |

  ## Direction mapping (8 quadrants)

  | Velocity | atan2(-vy, vx) range | Facing | Stand sprite | Run sprite | Frame count |
  |----------|----------------------|--------|--------------|------------|-------------|
  | (+, 0)   | -22.5° to +22.5°     | R      | 0029-NaviStandR | 0028-NaviRunR | <n> |
  | (+, -)   | +22.5° to +67.5°     | UR     | 0038-NaviStandUR | 0039-NaviRunUR | <n> |
  | (0, -)   | +67.5° to +112.5°    | U      | 0030-NaviStandU | 0031-NaviRunU | <n> |
  | (-, -)   | +112.5° to +157.5°   | UL     | 0040-NaviStandUL | 0041-NaviRunUL | <n> |
  | (-, 0)   | ±157.5° to ±180°     | L      | 0033-NaviStandL | 0032-NaviRunL | <n> |
  | (-, +)   | -157.5° to -112.5°   | DL     | 0042-NaviStandDL | 0043-NaviRunDL | <n> |
  | (0, +)   | -112.5° to -67.5°    | D      | 0000-NaviStandD | 0027-NaviRunD | <n> |
  | (+, +)   | -67.5° to -22.5°     | DR     | 0036-NaviStandDR | 0037-NaviRunDR | <n> |

  ## Decisions

  ### Q1: ~~Missing 0028-NaviRunR~~ — RESOLVED 2026-05-10

  Sprite verified present at `extracted/client-5-8/sprites/0028-NaviRunR/` (has `meta.json` + `frames/`). Originally omitted from CONTEXT addendum line 336; plan-checker caught the gap. CONTEXT + 06-14 + this plan all updated to include 0028 across the full 8-direction Run set. No fallback strategy needed.

  ### Q2: Facing update timing — on keydown or on next Step?

  _(Read the Keyboard-* events. If they set `sprite_index` directly → facing-on-keydown. If they only set hsp/vsp and Step does sprite_index from velocity → facing-on-step.)_

  Decision: _<select + cite GML line>_

  ### Q3: Cycle phase advance rate

  _(Read Draw.gml or Step.gml for `image_speed = N`. GM5 image_speed is "frames per game tick" — image_speed=0.5 means advance one frame every 2 ticks.)_

  Cycle advance rule: cyclePhase += <derived> per tick. At TICK_RATE_HZ=<from 06-13> Hz, this means a full cycle takes <n> ms.

  ### Q4: KeyRelease facing behavior

  _(Does it preserve lastFacing? Reset to D? Other?)_

  Decision: _<derived>_

  ## Cyan nametag derivation (D-27a)

  _(Read Draw.gml — does it have a `draw_set_color(...)` call? Does it draw text? GM5 colors: c_aqua = 0xFFFF00, c_cyan = same. Hex BGR → RGB conversion required.)_

  Extracted color: _<value or "no draw_text call in 0000-server Draw.gml — using UI-SPEC §Color accent #22D3EE per fallback (CONTEXT addendum line 339)">_

  ## Out of scope (Phase 7)

  - Custom emote sprites (likely Phase 7 PAR-04)
  - Death / damage animations (Phase 7)
  - Per-character color schemes (Phase 7 PAR-06)
  ```

  **3. apps/client/src/render/SpriteStateMachine.ts** — Tag `// [impl->REQ-CLI-04]`. Pure module.

  ```ts
  // [impl->REQ-CLI-04]
  // 8-direction sprite-state machine. Pure function: vec2 + lastFacing + cyclePhase
  // → next frame key. No I/O, no Date.now, no Math.random — safe inside game-logic
  // purity rules and trivially testable.
  //
  // Derivation: see .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SPRITE-STATE-DERIVATION.md

  export type Direction = 'D' | 'U' | 'L' | 'R' | 'DR' | 'DL' | 'UR' | 'UL';

  const STAND_FRAME: Record<Direction, string> = {
    D:  '0000-NaviStandD_0',
    U:  '0030-NaviStandU_0',
    L:  '0033-NaviStandL_0',
    R:  '0029-NaviStandR_0',
    DR: '0036-NaviStandDR_0',
    DL: '0042-NaviStandDL_0',
    UR: '0038-NaviStandUR_0',
    UL: '0040-NaviStandUL_0',
  };

  const RUN_SPRITE_ID: Record<Direction, string> = {
    D:  '0027-NaviRunD',
    U:  '0031-NaviRunU',
    L:  '0032-NaviRunL',
    R:  '0028-NaviRunR',
    DR: '0037-NaviRunDR',
    DL: '0043-NaviRunDL',
    UR: '0039-NaviRunUR',
    UL: '0041-NaviRunUL',
  };

  // Frame counts from 06-14-ASSET-MANIFEST.md (verified at executor time)
  const RUN_FRAME_COUNT: Record<Direction, number> = {
    D: 6, U: <n>, L: <n>, R: <n>, DR: <n>, DL: <n>, UR: <n>, UL: <n>,
  };

  const VELOCITY_THRESHOLD = 0.001; // |v| ≤ this = stationary; tune if needed (sub-pixel accumulator may produce tiny non-zero velocities while standing)

  export function deriveFacing(vx: number, vy: number, lastFacing: Direction): Direction {
    if (Math.hypot(vx, vy) <= VELOCITY_THRESHOLD) return lastFacing;
    // Screen y grows down; angle in degrees, +x = right = 0°, +y_screen = down = -90°
    const angle = Math.atan2(-vy, vx) * (180 / Math.PI);
    // Normalize to [-180, 180]
    // Octant mapping (each 45° wide, centered on cardinal/diagonal axes)
    if (angle >= -22.5 && angle < 22.5)   return 'R';
    if (angle >=  22.5 && angle < 67.5)   return 'UR';
    if (angle >=  67.5 && angle < 112.5)  return 'U';
    if (angle >= 112.5 && angle < 157.5)  return 'UL';
    if (angle >= 157.5 || angle < -157.5) return 'L';
    if (angle >= -157.5 && angle < -112.5) return 'DL';
    if (angle >= -112.5 && angle < -67.5)  return 'D';
    return 'DR'; // angle >= -67.5 && angle < -22.5
  }

  export interface SpriteState {
    facing: Direction;
    cyclePhase: number;
    isRunning: boolean;
    frameKey: string;
  }

  export function deriveFrame(
    vx: number, vy: number,
    lastFacing: Direction,
    cyclePhase: number,
  ): SpriteState {
    const isRunning = Math.hypot(vx, vy) > VELOCITY_THRESHOLD;
    const facing = deriveFacing(vx, vy, lastFacing);
    if (!isRunning) {
      return { facing, cyclePhase: 0, isRunning: false, frameKey: STAND_FRAME[facing] };
    }
    const fc = RUN_FRAME_COUNT[facing] ?? 1;
    const newPhase = (cyclePhase + 1) % fc;
    return { facing, cyclePhase: newPhase, isRunning: true, frameKey: `${RUN_SPRITE_ID[facing]}_${newPhase}` };
  }
  ```

  **4. apps/client/src/__test__/sprite-state-machine.test.ts** — Tag `// [unit->REQ-CLI-04]`.

  ```ts
  // [unit->REQ-CLI-04]
  import { describe, it, expect } from 'vitest';
  import { deriveFacing, deriveFrame, type Direction } from '../render/SpriteStateMachine';

  describe('deriveFacing — 8 quadrants', () => {
    it.each([
      [1, 0, 'R'], [1, -1, 'UR'], [0, -1, 'U'], [-1, -1, 'UL'],
      [-1, 0, 'L'], [-1, 1, 'DL'], [0, 1, 'D'], [1, 1, 'DR'],
    ] as Array<[number, number, Direction]>)('vec(%d, %d) → %s', (vx, vy, expected) => {
      expect(deriveFacing(vx, vy, 'D')).toBe(expected);
    });

    it('zero velocity preserves lastFacing (no flicker)', () => {
      expect(deriveFacing(0, 0, 'UR')).toBe('UR');
      expect(deriveFacing(0, 0, 'L')).toBe('L');
    });

    it('sub-threshold velocity also preserves lastFacing', () => {
      expect(deriveFacing(0.0001, 0.0001, 'D')).toBe('D');
    });
  });

  describe('deriveFrame — Stand vs Run', () => {
    it('stationary → Stand<DIR>_0 frame, cycle reset to 0', () => {
      const r = deriveFrame(0, 0, 'D', 5);
      expect(r.isRunning).toBe(false);
      expect(r.frameKey).toBe('0000-NaviStandD_0');
      expect(r.cyclePhase).toBe(0);
    });

    it('moving → Run<DIR>_<phase> frame, cycle advances', () => {
      const r = deriveFrame(0, 1, 'D', 0);  // moving down
      expect(r.isRunning).toBe(true);
      expect(r.facing).toBe('D');
      expect(r.frameKey).toMatch(/^0027-NaviRunD_\d+$/);
      expect(r.cyclePhase).toBe(1);
    });

    it('cycle wraps after frame_count ticks', () => {
      let phase = 0;
      let last: Direction = 'D';
      for (let i = 0; i < 12; i++) {
        const r = deriveFrame(0, 1, last, phase);
        phase = r.cyclePhase;
        last = r.facing;
      }
      // After 12 ticks (assuming frame_count=6), we've wrapped twice
      expect(phase).toBe(0);
    });
  });
  ```

  (Update the cycle-wrap test's expected count once you confirm the frame_count from 06-14.)
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test -- src/__test__/sprite-state-machine.test.ts</automated>
    </verify>
    <done>
      SpriteStateMachine.ts shipped with pure deriveFacing + deriveFrame; 11+ vitest tests GREEN covering 8 directions + zero-velocity preservation + Stand-Run switch + cycle wrap. 06-16-SPRITE-STATE-DERIVATION.md fully populated with all 4 decision points (Q1 missing RunR, Q2 facing timing, Q3 cycle advance, Q4 KeyRelease behavior) + cyan derivation. No GameScene wiring yet (Task 2).
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: PlayerRenderer wires SpriteStateMachine + Nameplate (cyan, top-center) + GameScene wiring</name>
    <files>
      apps/client/src/render/PlayerRenderer.ts,
      apps/client/src/render/Nameplate.ts,
      apps/client/src/scenes/GameScene.ts,
      apps/client/src/__test__/nameplate.test.ts
    </files>
    <read_first>
      apps/client/src/render/PlayerRenderer.ts (current state — should now render 0000-NaviStandD per 06-14; this plan adds dynamic frame selection),
      apps/client/src/scenes/GameScene.ts (find: per-tick update loop where local + remote sprites are positioned; PlayerRenderer.update(dt) call site),
      packages/protocol/src/state.ts (PlayerState shape — confirm input_axes is on the schema per Phase 6 D-08; that's the velocity input for remotes),
      packages/game-logic/src/constants.ts (import TICK_RATE_HZ for cycle-phase advance),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md §Color (line 91 — accent #22D3EE) + §Spacing (line 63 — Nameplate-to-sprite gap = 4px) + §Typography (16px / 400 / monospace),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SPRITE-STATE-DERIVATION.md (cyan exact value derivation)
    </read_first>
    <behavior>
      - PlayerRenderer carries per-player state: { sprite, facing, cyclePhase, nameplate }
      - Each tick: read velocity (local: from PredictionEngine; remote: from PlayerState.input_axes per Phase 6 D-08); call deriveFrame(vx, vy, facing, cyclePhase); update sprite.setFrame; update local facing + cyclePhase
      - Nameplate is a Phaser.GameObjects.Text per player, color = derived cyan (06-16-SPRITE-STATE-DERIVATION.md), font = 'monospace' 16px, anchored top-center 4px above sprite head
      - Hidden DOM mirror `<div data-nameplate data-player-id="<sessionId>">username</div>` per UI-SPEC surface inventory line 23 — for Playwright selectors
      - Nameplate position updates each frame to follow sprite
      - Self also gets a nameplate (D-27a says self + remotes both)
    </behavior>
    <action>
  **1. apps/client/src/render/Nameplate.ts** — New file. Tag `// [impl->REQ-CLI-07]`.

  ```ts
  // [impl->REQ-CLI-07]
  // D-27a: cyan nametag above each player sprite (self + remotes).
  // Phaser canvas Text + hidden DOM mirror for Playwright selectors (UI-SPEC line 23).

  const NAMETAG_COLOR = '#22D3EE';  // UI-SPEC §Color accent fallback; if 06-16-SPRITE-STATE-DERIVATION.md extracted exact BNO cyan, use that instead
  const NAMETAG_FONT_SIZE = '16px';
  const NAMETAG_FONT_FAMILY = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace';
  const NAMETAG_OFFSET_Y = 4; // px above sprite head per UI-SPEC §Spacing line 63

  export class Nameplate {
    private text: Phaser.GameObjects.Text;
    private domMirror: HTMLDivElement | null = null;

    constructor(scene: Phaser.Scene, sessionId: string, username: string) {
      this.text = scene.add.text(0, 0, username, {
        fontFamily: NAMETAG_FONT_FAMILY,
        fontSize: NAMETAG_FONT_SIZE,
        color: NAMETAG_COLOR,
      }).setOrigin(0.5, 1);  // top-center anchor (origin x=0.5 center, y=1 bottom-of-text-box → text sits above the anchor point)

      // DOM mirror for Playwright selectors
      const overlay = document.getElementById('dom-overlay');
      if (overlay) {
        const mirror = document.createElement('div');
        mirror.setAttribute('data-nameplate', '');
        mirror.setAttribute('data-player-id', sessionId);
        mirror.textContent = username;
        mirror.style.display = 'none'; // selector-only; not visible UI
        overlay.appendChild(mirror);
        this.domMirror = mirror;
      }
    }

    /** Update position to follow the sprite. Call each frame. */
    follow(spriteX: number, spriteY: number, spriteHeight: number): void {
      this.text.setPosition(spriteX, spriteY - spriteHeight / 2 - NAMETAG_OFFSET_Y);
    }

    setText(username: string): void {
      this.text.setText(username);
      if (this.domMirror) this.domMirror.textContent = username;
    }

    destroy(): void {
      this.text.destroy();
      this.domMirror?.remove();
      this.domMirror = null;
    }
  }
  ```

  **2. apps/client/src/render/PlayerRenderer.ts** — Tag `// [impl->REQ-CLI-04] [impl->REQ-CLI-07]` near the new state-machine + nameplate wiring.

  Augment the per-player record:
  ```ts
  import { deriveFrame, type Direction } from './SpriteStateMachine';
  import { Nameplate } from './Nameplate';

  interface PlayerRenderState {
    sprite: Phaser.GameObjects.Sprite;
    nameplate: Nameplate;
    facing: Direction;
    cyclePhase: number;
  }

  private local: PlayerRenderState | null = null;
  private remotes = new Map<string, PlayerRenderState>();

  ensureLocal(scene: Phaser.Scene, x: number, y: number, sessionId: string, username: string): PlayerRenderState {
    if (this.local) return this.local;
    const sprite = scene.add.sprite(x, y, 'mvp', '0000-NaviStandD_0');
    const nameplate = new Nameplate(scene, sessionId, username);
    this.local = { sprite, nameplate, facing: 'D', cyclePhase: 0 };
    return this.local;
  }

  addRemote(scene: Phaser.Scene, x: number, y: number, sessionId: string, username: string): void {
    const sprite = scene.add.sprite(x, y, 'mvp', '0000-NaviStandD_0');
    const nameplate = new Nameplate(scene, sessionId, username);
    this.remotes.set(sessionId, { sprite, nameplate, facing: 'D', cyclePhase: 0 });
  }

  /** Per-tick update — call from GameScene.update(). */
  updateLocal(vx: number, vy: number, x: number, y: number): void {
    if (!this.local) return;
    const state = deriveFrame(vx, vy, this.local.facing, this.local.cyclePhase);
    this.local.sprite.setFrame(state.frameKey);
    this.local.sprite.setPosition(x, y);
    this.local.facing = state.facing;
    this.local.cyclePhase = state.cyclePhase;
    this.local.nameplate.follow(x, y, this.local.sprite.height);
  }

  updateRemote(sessionId: string, vx: number, vy: number, x: number, y: number): void {
    const r = this.remotes.get(sessionId);
    if (!r) return;
    const state = deriveFrame(vx, vy, r.facing, r.cyclePhase);
    r.sprite.setFrame(state.frameKey);
    r.sprite.setPosition(x, y);
    r.facing = state.facing;
    r.cyclePhase = state.cyclePhase;
    r.nameplate.follow(x, y, r.sprite.height);
  }
  ```

  **3. apps/client/src/scenes/GameScene.ts** — Tag `// [impl->REQ-CLI-04] [impl->REQ-CLI-07]` near the update loop.

  Wire the per-tick calls. The local-player velocity comes from PredictionEngine (Phase 6 06-XX); the remote-player velocity comes from PlayerState.input_axes per D-08:

  ```ts
  // [impl->REQ-CLI-04] [impl->REQ-CLI-07]
  update(time: number, dt: number): void {
    // ... existing prediction step ...

    // Local player render
    if (this.predictedState) {
      const { x, y, vx, vy } = this.predictedState.players.local;
      this.playerRenderer.updateLocal(vx, vy, x, y);
    }

    // Remote players render — read input_axes from server-broadcast PlayerState
    for (const [sessionId, p] of this.room?.state.players ?? []) {
      if (sessionId === this.room?.sessionId) continue; // skip local
      this.playerRenderer.updateRemote(sessionId, p.input_axes?.x ?? 0, p.input_axes?.y ?? 0, p.x, p.y);
    }
  }
  ```

  (Adapt to the actual PredictionEngine + PlayerState API shapes — the executor reads them and wires accordingly.)

  **4. apps/client/src/__test__/nameplate.test.ts** — Tag `// [unit->REQ-CLI-07]`.

  ```ts
  // [unit->REQ-CLI-07]
  import { describe, it, expect, beforeEach, vi } from 'vitest';
  import { Nameplate } from '../render/Nameplate';

  describe('Nameplate (D-27a)', () => {
    let scene: any;
    beforeEach(() => {
      document.body.innerHTML = '<div id="dom-overlay"></div>';
      scene = {
        add: {
          text: vi.fn().mockReturnValue({
            setOrigin: vi.fn().mockReturnThis(),
            setPosition: vi.fn().mockReturnThis(),
            setText: vi.fn().mockReturnThis(),
            destroy: vi.fn(),
          }),
        },
      };
    });

    it('uses cyan color (#22D3EE accent or extracted exact value)', () => {
      new Nameplate(scene, 'abc', 'me');
      const args = (scene.add.text as any).mock.calls[0][3];
      expect(args.color).toMatch(/#22D3EE|<extracted-exact-value-from-06-16-derivation>/i);
    });

    it('uses monospace font at 16px (UI-SPEC §Typography)', () => {
      new Nameplate(scene, 'abc', 'me');
      const args = (scene.add.text as any).mock.calls[0][3];
      expect(args.fontSize).toBe('16px');
      expect(args.fontFamily).toMatch(/monospace/i);
    });

    it('top-center anchor (setOrigin(0.5, 1))', () => {
      new Nameplate(scene, 'abc', 'me');
      const textObj = (scene.add.text as any).mock.results[0].value;
      expect(textObj.setOrigin).toHaveBeenCalledWith(0.5, 1);
    });

    it('creates DOM mirror with data-nameplate + data-player-id', () => {
      new Nameplate(scene, 'session-xyz', 'alice');
      const mirror = document.querySelector('[data-nameplate][data-player-id="session-xyz"]');
      expect(mirror).not.toBeNull();
      expect(mirror!.textContent).toBe('alice');
    });

    it('follow() positions nameplate above sprite', () => {
      const np = new Nameplate(scene, 'abc', 'me');
      np.follow(100, 200, 32);
      const textObj = (scene.add.text as any).mock.results[0].value;
      // Expected: x=100, y=200 - 32/2 - 4 = 180
      expect(textObj.setPosition).toHaveBeenCalledWith(100, 180);
    });

    it('destroy() cleans up DOM mirror + Phaser text', () => {
      const np = new Nameplate(scene, 'abc', 'me');
      np.destroy();
      expect(document.querySelectorAll('[data-nameplate]').length).toBe(0);
    });
  });
  ```

  **5. apps/client/test/e2e/cli-08.e2e.test.ts** — Add (don't replace) frame-selection + nameplate assertions:

  ```ts
  // [int->REQ-CLI-04] [int->REQ-CLI-07]
  test('player sprite frame switches to Run<DIR> on movement, back to Stand<DIR> on stop', async ({ page }) => {
    await loginAs(page, accountA);
    await waitForGameReady(page);
    // Press right (D key in WASD or ArrowRight) for 1s
    await page.keyboard.down('d');
    await page.waitForTimeout(500);
    // Read the current frame name via dev hook (executor exposes window.__rebno.localFrame for tests)
    const frameWhileRunning = await page.evaluate(() => (window as any).__rebno?.localFrame);
    expect(frameWhileRunning).toMatch(/^0028-NaviRunR_\d+$/); // R-facing run frame (cycle phase varies)
    await page.keyboard.up('d');
    await page.waitForTimeout(200);
    const frameAfterStop = await page.evaluate(() => (window as any).__rebno?.localFrame);
    expect(frameAfterStop).toMatch(/^(0029-NaviStandR|0000-NaviStandD)/); // Stand frame after stop
  });

  test('nameplate visible above each player sprite (DOM mirror)', async ({ page }) => {
    await loginAs(page, accountA);
    await waitForGameReady(page);
    const nameplates = await page.locator('[data-nameplate]').count();
    expect(nameplates).toBeGreaterThan(0);
  });
  ```

  (executor adds the dev-hook `window.__rebno.localFrame = state.frameKey` inside PlayerRenderer.updateLocal for testability — gated by import.meta.env.DEV or DEV_HOOKS=1 to avoid prod surface.)
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test -- src/__test__/nameplate.test.ts &amp;&amp; pnpm --filter @rebno/client typecheck &amp;&amp; pnpm --filter @rebno/client test:e2e -- --grep "Run<DIR>|nameplate"</automated>
    </verify>
    <done>
      Nameplate.ts shipped with cyan color + top-center anchor + DOM mirror; 6 vitest tests GREEN. PlayerRenderer wires SpriteStateMachine.deriveFrame for both local + remote players using PlayerState.input_axes for remotes. GameScene update() loop calls updateLocal + updateRemote each tick. cli-08.e2e.test.ts asserts frame switches Run↔Stand + nameplate DOM mirror present. typecheck green.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Server-broadcast input_axes → client deriveFrame | Phase 6 D-08 schema field is server-authoritative; client uses verbatim for remote sprite-state — no spoofing vector |
  | GML-derived sprite IDs → atlas frame keys | If 06-14 manifest disagrees with SpriteStateMachine sprite-ID table, sprite.setFrame throws → caught in vitest frame-key test |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-16-01 | Tampering | Adversary spoofs nameplate text via DOM injection | accept | DOM mirror is dev-tool-only (display:none); even if visible, server-broadcast username is the source of truth and re-render overwrites |
  | T-06-16-02 | DoS | Cycle-phase advance per frame at 60Hz creates GC pressure with many remotes | mitigate | Pure function returns small object; no allocations per tick beyond the SpriteState record (executor uses object pooling if profiler shows it matters; not premature) |
  | T-06-16-03 | Repudiation | "Sprite frame doesn't match BNO" without evidence | mitigate | 06-16-SPRITE-STATE-DERIVATION.md cites GML lines + UAT video evidence at 06-17 confirms |
  | T-06-16-04 | Information disclosure | Username DOM mirror exposes other players' usernames in DevTools | accept | Usernames are public per server broadcast already; mirror is parity, not new exposure |
  </threat_model>

  <verification>
  - `pnpm --filter @rebno/client test -- src/__test__/sprite-state-machine.test.ts src/__test__/nameplate.test.ts` exits 0 (17+ tests)
  - `pnpm --filter @rebno/client typecheck` exits 0
  - `pnpm --filter @rebno/client test:e2e` exits 0 (existing + new frame-switch + nameplate assertions)
  - 06-16-SPRITE-STATE-DERIVATION.md exists with all 4 decisions + cyan derivation populated
  - `pnpm verify:phase-6` passes after Wave 12 lands
  - `pnpm trace:check` reports `[impl/unit->REQ-CLI-04]` + `[impl->REQ-CLI-07]` tags satisfied
  - Manual smoke (deferred to 06-17 UAT video): sprites face correct direction; nameplate visible cyan above heads
  </verification>

  <success_criteria>
  - UAT Finding #3b (D-26 Plan B) closed: 8-direction sprite-state machine derived from extracted GML + atlas frames from 06-14
  - D-27a nametag (cyan, top-center, follows movement, self + remotes) shipped per CONTEXT addendum line 339
  - Pure SpriteStateMachine module — testable + reusable
  - Frame timing matches BNO (Q2 facing-on-keydown vs on-step decision recorded + implemented)
  - Tags `[impl/unit->REQ-CLI-04]` (per gap-closure addendum line 362) + `[impl->REQ-CLI-07]` for nametag in room-render
  - All 8 directions reachable via player movement; full 8-direction Run coverage including 0028-NaviRunR
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SUMMARY.md` recording:
  (a) Q1-Q4 decisions from 06-16-SPRITE-STATE-DERIVATION.md (one-line each)
  (b) Final RUN_FRAME_COUNT table per direction (paste from SpriteStateMachine.ts)
  (c) Cyan nametag color used (extracted exact value OR UI-SPEC accent fallback)
  (d) DOM mirror selector strategy verified working in Playwright
  (e) Confirmation 06-17 UAT can capture an authentic BNO-fidelity video on first take
  (f) Trace tag verification snippet for REQ-CLI-04 + REQ-CLI-07 across impl + unit + int stages
  </output>
  </content>
  </invoke>```

  ### Plan 06-17
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 17
  type: execute
  wave: 12
  depends_on: [06-10, 06-11, 06-12, 06-13, 06-14, 06-15, 06-16]
  files_modified:
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CLI-08-milestone.mp4
  autonomous: false
  requirements: [CLI-08, CLI-01, CLI-02, CLI-03, CLI-04, CLI-05, CLI-06, CLI-07, CLI-09, AST-01]
  must_haves:
    truths:
      - "06-HUMAN-UAT.md is re-run verbatim — same 5 tests, same instructions — by operator decidel against the freshly-deployed staging build that includes all gap-closure plans 06-10..06-16"
      - "Test 1 (HiDPI nearest-neighbor): operator can READ the login form (06-10 fix) AND the GameScene shows real BNO sprites (06-14) — pixel-correctness sweep is now meaningful and assessed across 4K Retina + 1080p + Windows 125%/150%"
      - "Test 2 (CLI-08 milestone): operator captures real `06-CLI-08-milestone.mp4` showing two players in BN Central, walking with frame-perfect 8-direction sprites (06-13 + 06-16), exchanging chat — the 'we shipped CLI-08' artifact"
      - "Test 3 (Aseprite round-trip): operator exercises the bootstrap → build → render dev-loop with one of the real bootstrapped sprites from 06-14 (e.g. modify 0000-NaviStandD.png in-place, run pnpm asset-pipeline:build, reload, see the change) — no need for .aseprite source if D-15 deferred those"
      - "Test 4 (Browser password manager + autofill): operator can sign out via the new Esc menu (06-11) → Chrome 'Save password?' fires → reload → autofill works"
      - "Test 5 (Reconnect grace banner UX): operator kills WS via DevTools (06-15 fixes the post-reconnect blank canvas) → banner appears + clears + content remains rendered + position preserved within grace + home portal spawn past grace"
      - "All 5 Status fields show ✅ Pass; the 06-HUMAN-UAT.md ## Findings table from 2026-05-10 is RESOLVED (each finding row updated with closure evidence + gap-closure plan number)"
      - "06-CLI-08-milestone.mp4 committed to .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/ (Git LFS or external link if file > 50 MB; either is per CLAUDE.md hard rule #8 acceptable)"
      - "Phase 6 status flips from IN_PROGRESS to APPROVED in 06-HUMAN-UAT.md front-matter; CLI-08 is officially shipped"
    artifacts:
      - path: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
        provides: "Re-executed UAT — all 5 tests Pass; ## Findings table updated with closure evidence per row; sign-off table green; status APPROVED"
        contains: "APPROVED"
      - path: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CLI-08-milestone.mp4
        provides: "The CLI-08 milestone artifact — Capcom-derived BNO has had ~25 years between original and rebuild; this video is the proof"
        contains: ""
    key_links:
      - from: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md (post-update)
        to: 06-10..06-16 SUMMARY files
        via: "## Findings table closure column references each gap-closure plan + summary doc"
        pattern: "06-1[0-6]-SUMMARY"
      - from: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
        to: 06-CLI-08-milestone.mp4
        via: "Test 2 Video field references the committed file path"
        pattern: "06-CLI-08-milestone"
      - from: 06-HUMAN-UAT.md status APPROVED
        to: /gsd-verify-work 06
        via: "Phase 6 close-out: /gsd-verify-work consumes both the automated verify-phase-6 (06-09) and the manual UAT artifact"
        pattern: "APPROVED"
  ---

  <objective>
  Close the gap-closure cycle: re-run `06-HUMAN-UAT.md` verbatim against the freshly-deployed staging build that includes all gap-closure plans 06-10 through 06-16; capture the real `06-CLI-08-milestone.mp4` (the milestone artifact for "we shipped CLI-08"); update the UAT doc's ## Findings table with closure evidence for each of the 5 findings from the 2026-05-10 run; flip Phase 6 status to APPROVED.

  Purpose: per CONTEXT addendum line 353 — Wave 12, sequential, all 7 prior gap-closure plans must ship before this runs. This is the final acceptance test for CLI-08, the rebuild's go/no-go milestone (CLAUDE.md hard rule #8 + ROADMAP). The original UAT video at `uat-test-1-2.mp4` was captured on a synthetic mvp-lobby with blue rectangles; this plan replaces it with a real-asset, real-room, real-sprite, real-reconnect-recovery video.

  Per gap-closure addendum line 364 — `[doc->REQ-CLI-08]` (final milestone artifact).

  Output: updated 06-HUMAN-UAT.md (5 tests Pass + closure table + APPROVED status) + committed 06-CLI-08-milestone.mp4. After this plan ships and the checkpoint resolves green, Phase 6 closes and Phase 7 unblocks.
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-09-PLAN.md
  @.planning/phases/05-deploy/05-HUMAN-UAT.md

  <interfaces>
  <!-- This plan deliberately consumes ZERO source-file interfaces. -->
  <!-- It re-executes a 2026-05-10 UAT script against the gap-closure deployment. -->

  The 5 UAT tests are LOCKED verbatim by 06-HUMAN-UAT.md (do not change instructions; only update Operator Notes + Status + ## Findings closure column).

  Pre-flight checks (operator + Claude before re-running UAT):
  1. All gap-closure plans 06-10..06-16 SUMMARY docs exist and report green
  2. `pnpm verify:phase-6` (06-09 composite gate) exits 0 end-to-end
  3. Staging deploy is green: `https://staging.rebno.decidel.com/?invite=$STAGING_INVITE_TOKEN` returns the new client bundle
  4. Two seeded UAT accounts (uat_a + uat_b) work — verified by 06-08's Playwright e2e in CI

  Deployment ritual (Phase 6 D-18 + Phase 5 D-11):
  - `git push` to a branch → CI runs deploy-staging.yml → builds client + server image → flyctl deploy → Playwright post-deploy gate → if green, merge to main → prod deploy on main push (gated by Phase 5 ritual)
  - Operator verifies the deployed bundle includes all gap-closure changes (check the JS bundle hash in DevTools Network panel matches the latest commit's expected hash)

  Closure evidence per finding (to be filled in 06-HUMAN-UAT.md ## Findings table):
  - Finding #1 (login text invisible) → 06-10-PLAN.md / 06-10-SUMMARY.md + 06-10-PLAN.md's contrast Playwright assertion
  - Finding #2 (reconnect blank GameScene) → 06-12-DEBUG-FINDINGS.md + 06-15-PLAN.md / 06-15-SUMMARY.md + cookie-reload + ws-kill-reconnect e2e tests
  - Finding #3 (movement doesn't match BNO) → 06-13-PLAN.md / 06-13-SUMMARY.md (numerics) + 06-16-PLAN.md / 06-16-SUMMARY.md (sprite-state)
  - Finding #4 (no real BNO assets) → 06-14-PLAN.md / 06-14-SUMMARY.md + 06-14-ASSET-MANIFEST.md
  - Finding #5 (no logout) → 06-11-PLAN.md / 06-11-SUMMARY.md

  Video file size handling (CLAUDE.md hard rule #8 — repo private through Phase 7):
  - Up to 50 MB: commit directly
  - 50-100 MB: Git LFS via `.gitattributes` track for *.mp4
  - > 100 MB: external private location + reference in UAT doc
  </interfaces>
  </context>

  <tasks>

  <task type="auto">
    <name>Task 1: Pre-UAT verification — confirm all gap-closure plans landed and verify-phase-6 is green</name>
    <files>
      (read-only verification — no file edits)
    </files>
    <read_first>
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-SUMMARY.md,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-11-SUMMARY.md,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-SUMMARY.md,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-SUMMARY.md,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-SUMMARY.md,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-15-SUMMARY.md,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SUMMARY.md
    </read_first>
    <action>
  **1. Confirm all 7 prior SUMMARY docs exist and report green** (each summary's "What was built" + "verification" sections):

  ```bash
  for n in 10 11 12 13 14 15 16; do
    if [ ! -f ".planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-${n}-SUMMARY.md" ]; then
      echo "MISSING SUMMARY: 06-${n}"; exit 1
    fi
  done
  echo "All 7 SUMMARYs present"
  ```

  **2. Run the composite verify gate end-to-end**:
  ```bash
  pnpm verify:phase-6
  ```

  Must exit 0. If any step fails, STOP and route the failure back to the responsible plan (use the failure label to identify which plan owns the broken step):
  - Step 4 (Asset-pipeline: build) → 06-14
  - Step 7 (Lint: asset-pipeline) → 06-14
  - Step 8 (Workspace: test) → unit test failure; trace to source plan via test name
  - Step 9 (Client: e2e cli-08 smoke) → 06-15 or 06-16 depending on failing assertion
  - Step 10 (Traceable-reqs: check) → tagging gap; identify missing tag

  **3. Verify staging deployment is current**:
  ```bash
  # Check the deployed bundle matches the latest commit
  curl -sI https://staging.rebno.decidel.com/ | head
  # Visual confirmation: open https://staging.rebno.decidel.com/?invite=$STAGING_INVITE_TOKEN
  # in a fresh Chrome incognito window; confirm the login form is readable (06-10 fix landed),
  # the Esc menu opens (06-11 landed), the cookie reload doesn't blank (06-15 landed),
  # and the GameScene shows BNCentral with real sprites (06-14 + 06-16 landed)
  ```

  **4. Confirm UAT seed accounts are usable**:
  ```bash
  # uat_a + uat_b should already exist (per 06-08 Playwright e2e using these accounts daily)
  # If they're stale (e.g. password rotation), seed via the legacy migrator + change-password rituals
  ```

  If anything red, STOP — file a follow-up ticket / route back to the responsible plan. If all green, proceed to Task 2 checkpoint.
    </action>
    <verify>
      <automated>node -e "const fs=require('fs'); const ns=[10,11,12,13,14,15,16]; for (const n of ns) { const p='.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-'+n+'-SUMMARY.md'; if (!fs.existsSync(p)) { console.error('MISSING:', p); process.exit(1); } } console.log('all 7 SUMMARY docs present'); &amp;&amp; pnpm verify:phase-6"</automated>
    </verify>
    <done>
      All 7 prior SUMMARY docs present + green; pnpm verify:phase-6 exits 0; staging deployment confirmed current with all gap-closure changes; UAT seed accounts usable. Operator + Claude both confident the build is ready for human UAT re-run.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <name>Task 2: Operator re-runs all 5 UAT tests against staging; captures 06-CLI-08-milestone.mp4; updates 06-HUMAN-UAT.md to APPROVED</name>
    <what-built>
  All 7 gap-closure plans (06-10..06-16) shipped. Staging deployed with the gap-closure bundle. Operator can now re-execute the 5 manual UAT tests verbatim from `06-HUMAN-UAT.md` (the same script that failed on 2026-05-10) and capture the milestone video. This task is fundamentally manual — videos, real-DPI hardware sweeps, and password-manager integration cannot be headless-driven.

  **Pre-checkpoint: Claude pre-edits 06-HUMAN-UAT.md to clear the prior FAIL state**

  (see how-to-verify for the exact pre-edit shape)
    </what-built>
    <how-to-verify>
  **Step 0 (Claude — pre-checkpoint pre-edit):**

  Update `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md` BEFORE the operator runs UAT:

  a) Front-matter: `status: pending` (was `failed`); `last_updated: <today YYYY-MM-DD>`; add `gap_closure_plans: [06-10, 06-11, 06-12, 06-13, 06-14, 06-15, 06-16]`

  b) Inside each Test (1-5): clear the prior **Operator Notes**, **Screenshots / Evidence**, **Status** to a fresh `⬜ Pending` state — preserve the original instructions verbatim. The 2026-05-10 failure context belongs in the ## Findings table (see (c)) for historical record, not in the per-test fields.

  c) Update the ## Findings table — KEEP the original 2026-05-10 finding rows for traceability but ADD a "Closed by" column:

  ```md
  | # | Finding | Severity | Closed by |
  |---|---------|----------|-----------|
  | 1 | Login screen text invisible | high | 06-10-PLAN.md (LoginScene contrast tokens + Playwright contrast assertion) — see 06-10-SUMMARY.md |
  | 2 | Reconnect / cookie auto-login renders blank | critical | 06-12-DEBUG-FINDINGS.md root-cause + 06-15-PLAN.md (Option A token threading + Option B getSession self-heal + InputDispatcher.setRoom + 2 e2e tests) — see 06-15-SUMMARY.md |
  | 3 | Movement doesn't match BNO | critical | 06-13-PLAN.md (constants from extracted GML + pinning tests) + 06-16-PLAN.md (8-direction sprite-state machine + cyan nametag D-27a) — see 06-13-SUMMARY.md + 06-16-SUMMARY.md |
  | 4 | No real BNO assets | critical | 06-14-PLAN.md (asset-pipeline bootstrap + 15 player sprites + BNCentral room layout from 0058-BNCentral, replaces synthetic mvp-lobby) — see 06-14-SUMMARY.md + 06-14-ASSET-MANIFEST.md |
  | 5 | No logout | medium | 06-11-PLAN.md (Esc menu — Esc + game-view click triggers; Logout/Resume/Settings; cookie + Colyseus room cleared) — see 06-11-SUMMARY.md |
  ```

  d) Update the Sign-Off table to fresh ⬜ for all 5 rows; clear the bottom approval line.

  e) Append a `## Re-run history` section:
  ```md
  ## Re-run history

  | Date | Operator | Outcome | Notes |
  |------|----------|---------|-------|
  | 2026-05-10 | decidel | FAIL (5 findings) | Original UAT — see Sign-Off + ## Findings |
  | <YYYY-MM-DD> | decidel | _<pending>_ | Re-run after gap-closure plans 06-10..06-16 |
  ```

  **Step 1 (operator — verbatim re-run):**

  Operator opens `06-HUMAN-UAT.md` and executes Test 1 → Test 2 → Test 3 → Test 4 → Test 5 in order against `https://staging.rebno.decidel.com/?invite=$STAGING_INVITE_TOKEN` exactly as the doc specifies. For each:
  - Fill in **Operator Notes** with what was observed
  - Capture **Screenshots / Evidence** (paths in the phase directory)
  - Mark **Status** ✅ Pass or ⬛ Fail (with reason)

  **Critical: Test 2 captures the milestone video.**

  Operator records both Chrome browser windows during the alice-bob (uat_a-uat_b) movement + chat scenario. Save as `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CLI-08-milestone.mp4`. File size handling:
  - ≤ 50 MB: commit directly via `git add .planning/.../06-CLI-08-milestone.mp4`
  - 50-100 MB: enable Git LFS first (`git lfs install && git lfs track "*.mp4"`); commit `.gitattributes` + the file
  - > 100 MB: trim to ≤ 100 MB OR upload to a private location (Drive / S3) and put the link in the Test 2 Video field

  The video should clearly show:
  - Both windows side-by-side
  - BN Central room with real BNO sprites + tiles + background (NOT blue rectangles)
  - 8-direction sprite-state animation (not just StandD throughout)
  - Cyan nametags above each player's head
  - Bidirectional chat round-trip (alice's message appears in bob's window + vice versa)
  - Total duration: 30 seconds to 2 minutes is sufficient

  **Step 2 (operator — re-run history + sign-off):**

  After all 5 tests pass:
  - Fill the `## Re-run history` row for today with `outcome: PASS (5/5 tests green)` + brief notes
  - Update the Sign-Off table: each row → ✅
  - Update the bottom: `**Operator approval:** decidel, <YYYY-MM-DD> — APPROVED — CLI-08 milestone shipped.`
  - Update front-matter: `status: approved`

  If ANY test fails:
  - Mark the failing test ⬛ with reason in Operator Notes
  - DO NOT mark Phase 6 approved
  - Route back to the responsible gap-closure plan owner; file a follow-up plan or fix in place
  - The checkpoint resume signal becomes `partial` (see resume-signal section)
    </how-to-verify>
    <resume-signal>
  Type `approved — CLI-08 shipped, UAT 5/5 green, milestone video committed at <path-or-link>` when:
  - All 5 tests in 06-HUMAN-UAT.md show ✅ Pass with operator notes + evidence
  - 06-CLI-08-milestone.mp4 is committed (or externally linked + referenced in Test 2 Video field)
  - Front-matter status: approved
  - Sign-Off table all green
  - Re-run history row populated for today

  Or type `partial — UAT N/5 green, blocked on <test number(s)> due to <reason>` if any test fails. Detail the failure mode in the resume message; this triggers a follow-up gap-closure plan (06-18+) or a re-deploy. Phase 6 status remains IN_PROGRESS.

  Or type `pending — UAT scheduled for <date>` if operator is not ready to run UAT immediately; the checkpoint stays open until the operator returns with a result.
    </resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Operator's UAT execution → committed evidence | Operator could falsely claim a green run; mitigated by required screenshot + video evidence + Re-run history audit trail |
  | Milestone video → public exposure if repo flips public | CLAUDE.md hard rule #8 — repo private through Phase 7; video carries no PII (test accounts only); legal-prep checklist (Phase 7 PAR-08) reviews ALL committed media before any public flip |
  | Phase 6 close-out → unblocks Phase 7 | If UAT is approved without genuinely meeting CLI-08 ("two players walk + chat over deployed server, bug-free"), Phase 7 starts on rotten foundation — mitigated by Sign-Off table requiring all 5 ticked + video evidence reviewable |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-17-01 | Repudiation | Operator approves UAT but evidence is missing/insufficient | mitigate | Sign-Off table requires the Test 2 video file path; ## Findings closure column requires per-finding plan reference; /gsd-verify-work 06 reviews the file at close-out |
  | T-06-17-02 | Disclosure | Milestone video accidentally leaked publicly (e.g. dropped on a pastebin) | mitigate | Operator instructed to keep video in private location only; CLAUDE.md hard rule #8 enforced by Phase 7 PAR-08 legal-prep checklist before any public flip |
  | T-06-17-03 | DoS | Operator unavailable for UAT for an extended period | accept | Phase 6 close-out is the operator's responsibility; the orchestrator will surface the pending checkpoint indefinitely; this is a planning-cycle delay, not a security DoS |
  | T-06-17-04 | Tampering | Someone edits 06-HUMAN-UAT.md to mark APPROVED without running the tests | accept | Solo-developer workflow; trust model is operator integrity; Re-run history audit trail makes drift visible |
  </threat_model>

  <verification>
  - All 7 SUMMARY docs (06-10..06-16) present and green
  - `pnpm verify:phase-6` exits 0 end-to-end
  - Staging deployment current with gap-closure bundle
  - After checkpoint: 06-HUMAN-UAT.md status = approved + Sign-Off all ✅ + Test 2 video exists at the documented path + ## Findings closure column populated for all 5 findings
  - After checkpoint: `git log --oneline -- .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md` shows the re-run commit
  - `pnpm trace:check` reports `[doc->REQ-CLI-08]` doc-stage tag satisfied; all 10 Phase 6 REQ-IDs at expected stages
  - /gsd-verify-work 06 ready to run as the formal phase close-out
  </verification>

  <success_criteria>
  - UAT failure cycle from 2026-05-10 closed: 5/5 tests Pass on the gap-closure deployment
  - 06-CLI-08-milestone.mp4 captures the rebuild's go/no-go evidence (~25 years from original to rebuild — this is the artifact)
  - Phase 6 status APPROVED — CLI-08 hard milestone shipped
  - All 5 findings from the original UAT have closure-evidence rows in the ## Findings table pointing to the gap-closure plans + summaries
  - Phase 7 unblocked: Phase 6 → Phase 7 PAR-* gate is now passable (subject to /gsd-verify-work 06 formal close-out)
  - Tags `[doc->REQ-CLI-08]` (per gap-closure addendum line 364) satisfied
  - All 10 Phase 6 REQ-IDs (CLI-01..09 + AST-01) hit their required stages per traceable-reqs
  </success_criteria>

  <output>
  After completion (checkpoint resolved green), create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-17-SUMMARY.md` recording:
  (a) UAT re-run date + operator + outcome (5/5 PASS)
  (b) Path/link to 06-CLI-08-milestone.mp4 + file size + storage method (direct commit / Git LFS / external link)
  (c) Per-test brief outcome notes (1 sentence each, or paste the operator's notes verbatim from 06-HUMAN-UAT.md)
  (d) Closure mapping confirmed: Finding 1 → 06-10, Finding 2 → 06-12 + 06-15, Finding 3 → 06-13 + 06-16, Finding 4 → 06-14, Finding 5 → 06-11
  (e) Phase 6 status APPROVED; CLI-08 milestone shipped; Phase 7 unblocked
  (f) Pointer to next: run `/gsd-verify-work 06` for formal phase close-out → then `/gsd-discuss-phase 7` to begin PAR-*

  After 06-17 ships, the gap-closure cycle for Phase 6 is complete. The repo carries:
  - The original 2026-05-10 UAT failure (preserved in ## Findings + ## Re-run history)
  - The 7 gap-closure plans (06-10..06-16) that closed each finding
  - The re-executed UAT with all 5 tests Pass
  - The milestone video — the proof
  </output>
  </content>
  </invoke>```

  ## Review Instructions

  For the **set of 8 gap-closure plans**, provide:
  1. **Summary** — one paragraph overall assessment
  2. **Strengths** — what's well-designed
  3. **Concerns** — HIGH/MEDIUM/LOW severity, per plan or cross-plan
  4. **Suggestions** — concrete improvements
  5. **Risk Assessment** — LOW/MEDIUM/HIGH for closing CLI-08, with justification

  Pay particular attention to: wave sequencing (Wave 8: 06-10||06-11 → Wave 9: 06-12||06-13 → Wave 10: 06-14 → Wave 11: 06-15||06-16 → Wave 12: 06-17), whether the planned fixes actually root-cause the UAT defects, scope discipline (gap-closure is meant to be narrow), and security/correctness regressions versus the shipped Wave 1-7 work.

  Output markdown.

preview:
  # Cross-AI Plan Review Request — REBNO Phase 6 Gap-Closure

  You are reviewing 8 gap-closure plans (06-10..06-17) for Phase 6 of the REBNO project. Wave 7 (plans 06-01..06-09) shipped but HUMAN-UAT 2026-05-10 exposed 5 blocking findings (D-25, D-26, D-27, D-27a, plus secondary stale-dispatcher-room defect). The 8 plans below close those gaps and re-aim for CLI-08 (the hard milestone — two players move + chat over deployed server). CLI-08 is the go/no-go for the entire rebuild.

  Provide structured feedback on plan quality, completeness, and risks. Focus on missing edge cases, dependency ordering, scope creep, security, performance, and whether the plans actually achieve the phase goal.

  ## Project Context (PROJECT.md, first 80 lines)
  ```markdown
  # REBNO — Rebuild BN Online

  ## What This Is

  REBNO is a ground-up modernization of *BN Online*, a late-1990s/early-2000s multiplayer game shipped as a GameMaker 5.3a `.gmd` project. The work extracts the original client and server source from `BN Online Client 5-8.gmd` and `BN Online Master 5-4.gmd`, documents engine + server features, then rebuilds both halves on a modern web stack (TypeScript everywhere, Node server on Fly.io, Phaser 3 *or* PixiJS client running in Chrome) at full feature parity with a modernized UX.

  ## Core Value

  **Original BN Online players can open Chrome, log in with their migrated account, walk around, and chat with each other in real time — bug-free.** Everything else (graphics polish, full feature parity, scaling) flows from that working multiplayer baseline.

  ## Requirements

  ### Validated

  <!-- Brownfield: existing artifacts in repo. Code/wiki present, but nothing new shipped yet. -->

  - ✓ GameMaker 5.3a reverse-engineering wiki (17 docs in `decomp/wiki/`) — existing
  - ✓ Legacy archive of all `.gmd` revisions, server source, Unity remake attempt, Maya assets, audio — existing
  - ✓ Codebase map (`.planning/codebase/`, 7 docs) — existing
  - ✓ Stage 1 (Extraction) complete — Validated in Phase 1: extraction (2026-05-03)
    - `tools/extract-gmd/` TS Node CLI ports `LateralGM GmFileReader` semantics; 157/157 tests
    - `extracted/client-5-8/` (8307 files) + `extracted/server-5-4/` (1144 files) committed; reproducibility byte-identical
    - 13 wire-format bugs uncovered + documented in `decomp/wiki/03-gmd-format.md` errata
    - Sprite/background image data carried as opaque `imageBytes` (Option A pivot — BMP→PNG decode deferred to Phase 6/7 asset-pipeline; EXT-04 acceptance relaxed accordingly)

  ### Active

  #### Stage 1 — Extraction (DONE — Phase 1, 2026-05-03)

  #### Stage 2 — Client analysis (DONE — Phase 2, 2026-05-04)

  #### Stage 3 — Server analysis (DONE — Phase 3, 2026-05-05; status: human_needed — Linux CI verify-phase-3 deferred per HUMAN-UAT.md)

  #### Stage 4 — Server rebuild (NEXT)
  - [ ] Reimplement server in Node + TypeScript with binary WebSocket transport
  - [ ] Refactor as code is ported: identify and eliminate bugs from the original
  - [ ] Migrate original user accounts to a modern hash (bcrypt/argon2) — preserve usernames, force password reset on first login OR transparent re-hash on first successful auth
  - [ ] Authoritative server model — clients never trust each other for game state
  - [ ] Room/level layouts stored server-side and transmitted to client over the wire on room entry, integrity-verified; client bundle ships no static layout data (SRV-13)
  - [ ] Moving-platform live positions deterministic via `packages/game-logic` `step()` and broadcast authoritatively to all clients in the same state-diff envelope as player positions (SRV-14)

  #### Stage 5 — Deploy
  - [ ] Deploy new server to Fly.io with persistent volume + Litestream backup (or Postgres)
  - [ ] CI/CD pipeline: push to main → deploy
  - [ ] Health checks, structured logging, basic ops dashboard

  #### Stage 6 — Client rebuild (MVP)
  - [ ] Web client (Phaser 3 or PixiJS, TypeScript, Vite) running in Chrome
  - [ ] Modernized UX (HiDPI, responsive, keyboard + gamepad input)
  - [ ] **MVP gate: two players join the same room, see each other move, exchange chat messages — multiplayer over the new server**

  #### Stage 7 — Full parity
  - [ ] All client features from Stage 2 documentation reimplemented
  - [ ] All server features from Stage 3 documentation reimplemented
  - [ ] Original-game test cases pass against new client + server

  ### Out of Scope

  - **Mobile / native ports** — Chrome-first; no iOS, Android, Steam binaries in v1
  - **Engine-level emulation of GameMaker 5.3a** — we reimplement game *logic*, not the GM5 runtime
  - **Re-running the original Windows binary** — preservation belongs in `legacy/`, not the rebuild
  - **Server-side anti-cheat beyond authoritative model** — no behavioral detection in v1
  - **Modding API / user-generated content** — out of scope until parity ships
  - **3D / VR / new gameplay features** — faithful rebuild first, then iterate
  - **Public marketing / asset rights clearance** — license + distribution decision deferred until rebuild is functional (see Key Decisions)

  ## Context

  - **Repo layout** (see `.planning/codebase/STRUCTURE.md`): `decomp/wiki/` holds RE knowledge, `legacy/` holds all original artifacts (audio, maya-project, open-source-release, servers, source-archive, unity-project). Two prior remake attempts exist (`legacy/unity-project`, `legacy/open-source-release`); both can inform the new rebuild but are not the authoritative source — the `.gmd` files are.
  - **Source-of-truth files**: `legacy/open-source-release/BN Online Client 5-8.gmd` (client) and `legacy/open-source-release/BN Online Master 5-4.gmd` (server). Older revisions in `legacy/source-archive/` and `legacy/servers/` are reference-only.
  - **Engine constraints to overcome** (from wiki): GM5.3a is Delphi 5 / Win32, modern decompilers don't read it, network code is 39dll-only (TCP, no native sockets), assets are ZLIB-compressed inside a monolithic XOR-obfuscated payload appended to the runner stub. Extraction pipeline is documented in `decomp/wiki/15-extraction-pipeline.md`.
  - **Proprietary formats**: `.bno` / `.bnb` save data is bespoke — only readable by walking the extracted GML's `file_bin_*` call order, not via external tools.
  - **Bit-rot / IP risks** flagged in `.planning/codebase/CONCERNS.md` (229 lines) — read before committing scope.

  ## Constraints

  - **Tech stack — server**: Node + TypeScript. Mature ecosystem, best WS tooling (`ws`, `uWebSockets.js`), strong type story for shared client/server packet definitions.
  - **Tech stack — client**: TypeScript + Phaser 3 (preferred) or PixiJS (fallback). Decision deferred to end of Stage 2 once feature surface is known. Must run in Google Chrome, no plugins.
  - **Tech stack — transport**: WebSocket binary frames. Authoritative server, clients send intent, server emits state.
  - **Tech stack — persistence**: TBD in Stage 3 (likely SQLite + Litestream for <50 player target; Postgres if data model demands relational queries).
  ```

  ## Phase 6 — Roadmap Section
  ```markdown
  ### Phase 6: Client Rebuild — MVP Gate (CLI-08 HARD MILESTONE)
  **Goal**: A Vite-built TypeScript Phaser 3 (or PixiJS, per Phase 2 ADR) client runs in Chrome, connects to the deployed Fly.io server over WSS, authenticates, predicts movement against `packages/game-logic`, and lets two players in the same room see each other move and exchange chat — multiplayer over the deployed server, end-to-end. **CLI-08 is THE go/no-go for the rebuild.**
  **Depends on**: Phase 2 (engine ADR), Phase 5 (deployed server)
  **Requirements**: CLI-01, CLI-02, CLI-03, CLI-04, CLI-05, CLI-06, CLI-07, CLI-08, CLI-09, AST-01
  **Success Criteria** (what must be TRUE):
    1. A user opens the deployed client URL in Chrome desktop, sees a login screen, enters username + password, and is authenticated against the Fly.io server with PROTOCOL_VERSION verified during handshake.
    2. After login, the user sees their character in a single MVP room rendered with HiDPI nearest-neighbor + integer scaling (no blur on retro pixel art); movement uses client-side prediction (wrapping `packages/game-logic`) with server reconciliation when prediction diverges.
    3. The chat HUD accepts input — pressing Enter sends a message; messages from other players appear in the same HUD with sender nameplate.
    4. The asset-pipeline MVP slice (AST-01: BMP→PNG sprite atlas via sharp; one sprite atlas + one background) loads via a content-hashed manifest at client boot.
    5. **MVP GATE (CLI-08)**: Two players in the same Chrome window pair (or two separate machines) join the same room over the deployed server, see each other move smoothly, and exchange chat messages — demonstrated end-to-end. **This is THE go/no-go milestone for the entire rebuild.**
    6. Disconnect + reconnect within the grace window (closing the tab and re-opening) restores the player to the same room with the same character.
  **Plans**: 9 plans (0/9 complete)
  - [x] 06-01-PLAN.md - Wave 1 — apps/client + tools/asset-pipeline scaffold + Wave 0 RED test stubs (CLI-01, CLI-09)
  - [x] 06-02-PLAN.md - Wave 2 — tools/asset-pipeline bootstrap + build implementation; pipeline-manifest.json deterministic emit (AST-01, CLI-07)
  - [x] 06-03-PLAN.md - Wave 2 — packages/protocol PROTOCOL_VERSION 2 bump + apps/server D-07/D-08/D-09 patches (CLI-02, CLI-04, CLI-09)
  - [x] 06-04-PLAN.md - Wave 3 — BootScene + LoginScene + auth client SDK + atlas loader + GameScene placeholder (CLI-01, CLI-03, CLI-06, CLI-07)
  - [x] 06-05-PLAN.md - Wave 4 — apps/client/src/net colyseus-client + ReconnectBanner + reconnect state machine (CLI-02, CLI-09)
  - [x] 06-06-PLAN.md - Wave 4 — Prediction + Reconcile + Extrapolator + InputDispatcher; advanceAccumulator hoist (CLI-04)
  - [x] 06-07-PLAN.md - Wave 5 — Full GameScene wiring: ChatHUD + ForceResetOverlay + RoomRenderer + PlayerRenderer + Nameplate + roomLayoutVerify (CLI-04, CLI-05, CLI-06, CLI-07)
  - [x] 06-08-PLAN.md - Wave 6 — Playwright cli-08 GREEN + lint-asset-pipeline + lint-vite-env + deploy-staging.yml extension (CLI-08, CLI-07, AST-01)
  - [ ] 06-09-PLAN.md - Wave 7 — verify-phase-6.mjs composite gate + 06-HUMAN-UAT.md milestone artifact (cross-cutting CLI-01..09 + AST-01)
  **UI hint**: yes
  **Hard milestone**: CLI-08 — entire rebuild gates on this. Do not advance to Phase 7 until demonstrated.
  **Research flag**: Standard pattern. NEEDS DEEPER RESEARCH only if the Phase 2 ADR flips to PixiJS (then `@pixi/tilemap` + `howler` + scene wiring per SUMMARY.md).

  ```

  ## Requirements In Scope (Phase 6 CLI-* + AST-01)
  ```markdown
  - [x] **CLI-01**: `apps/client` is a Vite-built, TypeScript Phaser 3 (or PixiJS per CDOC-04 ADR) bundle running in Google Chrome desktop
  - [x] **CLI-02**: Client connects to deployed Fly.io server over WSS binary frames, completes handshake including PROTOCOL_VERSION check
  - [ ] **CLI-03**: Login screen accepts username + password, authenticates via server, transitions to game on success
  - [x] **CLI-04**: Movement uses client-side prediction (wrapping `packages/game-logic`) + entity interpolation (remote players) + server reconciliation when prediction diverges
  - [x] **CLI-05**: Chat HUD lets player type a message; pressing Enter sends it; messages from other players appear in the same HUD with sender nameplate
  - [x] **CLI-06**: HiDPI rendering uses nearest-neighbor + integer scale (no blur on retro pixel art)
  - [x] **CLI-07**: Asset pipeline produces at least one sprite atlas + one background for the MVP room; client loads via content-hashed manifest
  - [x] **CLI-08**: **MVP GATE**: Two players in the same Chrome window pair (or two separate machines) join the same room, see each other move smoothly, exchange chat messages — multiplayer over the deployed server, end-to-end. Demonstrable.
  - [x] **CLI-09**: Disconnect/reconnect: closing the tab and re-opening within the grace window restores the player to the same room with the same character

  ### Asset Pipeline (cross-stage, completed by Stage 7)

  - [x] **AST-01**: `tools/asset-pipeline` converts extracted BMP sprites to PNG atlases (sharp library); per-sprite metadata preserved
  ```

  ## CONTEXT.md (user decisions)
  ```markdown
  # Phase 6: Client Rebuild — MVP Gate (CLI-08 HARD MILESTONE) - Context

  **Gathered:** 2026-05-09
  **Status:** Ready for planning

  <domain>
  ## Phase Boundary

  Stand up `apps/client` — a Vite-built TypeScript Phaser 3.90 bundle running in Chrome desktop — that connects to the deployed Fly.io server (`wss://staging.rebno.decidel.com/colyseus`, then `wss://rebno.decidel.com/colyseus`) over WSS, authenticates via Better-Auth, runs `packages/game-logic` `step()` for client-side prediction with server reconciliation, renders the `mvp-lobby` room with HiDPI nearest-neighbor + integer scaling, and lets two players in the same room see each other move and exchange chat — demonstrated end-to-end. **CLI-08 is THE go/no-go for the rebuild.**

  In scope:
  - **`apps/client/`** workspace package (Vite 8 + Phaser 3.90 + TypeScript strict). Per-env builds via `pnpm --filter @rebno/client build:staging|build:prod` (carries forward Phase 5 D-19); env files (`.env.staging`, `.env.prod`) hold `VITE_WSS_URL`, `VITE_HTTP_BASE`, `VITE_ROOM_SIGNING_PUBKEY`. **Same-Fly-app hosting** — Vite build outputs to `apps/server/public/` (or equivalent path served by Express); single domain per env eliminates CORS, allows `SameSite=Strict` cookies (CLI-01).
  - **Login + session flow (CLI-02, CLI-03)** — hybrid Phaser scene + DOM-overlay form (DOM `<form>` rendered via `Phaser.GameObjects.DOMElement` over a retro Phaser backdrop). On boot, a `BootScene` calls `GET /api/auth/me`; valid session → ~500 ms spinner → transition to `GameScene`; missing/expired → focused login form. Errors surfaced inline (bad pw, `PROTOCOL_VERSION_MISMATCH`, `SERVER_DRAINING`). Force-reset users: server emits `s2c.force_password_change` after `onJoin`; client opens an in-room password-change overlay (movement frozen, no disconnect-reconnect cycle — Phase 4 D-07 carryover).
  - **Movement + prediction (CLI-04, CLI-06)** — `GameScene` wraps `packages/game-logic` `step()` for client-side prediction. Reconciliation is **threshold-gated** — small divergence (< ~half-tile, planner picks concrete number from 44 × 40 px tile) lerps over ~100 ms while replaying unacked inputs; large divergence hard-snaps. Remote-player rendering uses **snapshot interpolation with extrapolation on stale (>200 ms) snapshots**, extrapolated by running deterministic `step()` locally for remote players using their broadcast input axes (see D-08 — wire schema expands to carry remote inputs). HiDPI: Phaser `Scale.FIT` with `autoRound: true` + `Phaser.Textures.FilterMode.NEAREST` everywhere. Camera follows local player.
  - **Chat HUD (CLI-05)** — Minecraft-pattern: chat is a non-modal overlay (DOM div absolutely positioned over Phaser canvas). Default state shows last N messages fading out; **Enter or T** opens chat input + freezes movement keys; Enter sends, Esc cancels and releases focus. Remote-player nameplates rendered above sprite via Phaser text or DOM (planner picks). Phase 4 D-02 wire (`s2c.chat_msg`, `c2s.chat_send`) consumed unchanged.
  - **Reconnect UX (CLI-09)** — top-banner notification ("Reconnecting…") on WS drop. Local input continues to be predicted but **world-mutating actions** (item acquisition, message-board posts, etc. — none in MVP scope but affordance baked in) are blocked while disconnected. Auto-retry every 1 s. Within 10 s grace → Colyseus `allowReconnection` resumes same session, server-authoritative position restored. After 10 s → silent re-auth from cookie. New session (sign-out + sign-in or full session expiry) → spawn at home portal, NOT last position. Banner clears on success; final failure shows "Disconnected — click to retry."
  - **Asset pipeline (AST-01, CLI-07)** — `tools/asset-pipeline/` standalone Node CLI (Phase 1 D-17 pattern; outside the workspace). Two modes: (a) `bootstrap` — reads `extracted/client-5-8/sprites/<id>/<frame>.bmp` + `meta.json`, decodes via sharp, emits per-sprite Aseprite-export-compatible PNG strips + JSON sidecars at `assets/source/sprites/<sprite_id>.{png,json}` (one-shot; output committed so devs can edit `.aseprite` source files locally and re-export); (b) `build` — packs `assets/source/**` into a single multi-sprite atlas (`atlas-mvp.png` + Phaser-format `atlas-mvp.json`) plus a `background-mvp.png`, writes `tools/asset-pipeline/output/pipeline-manifest.json` with rich per-sprite metadata (frames, origin, hashes) referencing Vite-hashed paths from Vite's own `manifest.json` after build. Phaser loads via `this.load.atlas('mvp', …)`. MVP slice scope: NaviStandD/U/R + NaviRunD/U/R (player) + Tile1, TSide1, VBorder, HBorder (room walls/floor) + one background. Sourced from `extracted/client-5-8/sprites/0000-NaviStandD/` etc.
  - **Room rendering** — `apps/server/rooms/mvp-lobby/000.json` arrives via `s2c.room_layout`, signature verified against `import.meta.env.VITE_ROOM_SIGNING_PUBKEY`, then rendered via Phaser `TilemapLayer` (or custom blit if planner finds Phaser tilemap awkward for the canonical layout JSON — see Claude's discretion). Hot-reload from server (Phase 4 D-10): on receiving a fresh `s2c.room_layout` for the current room, client re-builds the tilemap in place; player position preserved.
  - **Wire / protocol coordination** — Phase 6 plan **expands `packages/protocol`** to broadcast remote players' last-input axes (see D-08). This bumps `PROTOCOL_VERSION` and adds an `input_axes` field to `PlayerState` schema; `apps/server` tick loop emits the held input vector per account. Coordinated re-deploy ritual per Phase 4 D-03 (server before client).
  - **Per-env Vite bundles** (Phase 5 D-19 carryover) — `pnpm --filter @rebno/client build:staging` injects staging WSS URL + staging Ed25519 pubkey; `:prod` injects prod equivalents. Bundle ships zero static layout data per Phase 4 SRV-13.
  - **CLI-08 verification harness** — Both: (a) **Playwright two-client smoke** in `pnpm verify:phase-6` + `.github/workflows/deploy-staging.yml` (extends Phase 5 D-11 soak) — spawns 2 headless Chromium contexts, logs in as 2 accounts, asserts move + chat round-trip + nameplates visible; gates merge to `main`. (b) **Manual UAT** recorded in `06-HUMAN-UAT.md` with screenshot/video evidence — milestone artifact for "we shipped CLI-08." Same UAT pattern as Phase 5.
  - **Lints** — `lint-asset-pipeline.mjs` (manifest schema validation + atlas integrity), `lint-vite-env.mjs` (`.env.staging`/`.env.prod` carry required `VITE_*` keys, no secrets), wired into `pnpm verify:phase-6`.
  - **CSP + HiDPI + Chrome-first runtime guarantees** — strict CSP headers via Express on `/`, `<canvas image-rendering: pixelated>` fallback, integer-scale snap on `window.resize`. Best-effort other browsers, blocking only Chrome desktop.

  Out of scope (belongs in Phase 7 or later):
  - AST-02 (MIDI→OGG via ffmpeg + vintage soundfont), AST-03 (BMP fonts → WOFF2), AST-04 (full content-hashed manifest spanning audio + fonts) — Phase 7. Phase 6 ships silent (no audio in `extracted/client-5-8/` per ADR 0001).
  - Full room set + pixel-diff verification — Phase 7 PAR-03. Phase 6 ships exactly `mvp-lobby` through Phase 4 SRV-13 contract.
  - Multi-room transitions, friends list + presence, whispers, account recovery, account-level settings — Phase 7 PAR-04/PAR-06.
  - `.bnu` per-user transactional character migration — Phase 7 PAR-05.
  - Modernized admin web UI — Phase 7 PAR-07.
  - Full chat surface (whispers, channels, ignore/block, profanity wordlist, history rolling buffer) — Phase 7 PAR-04. Phase 6 ships only `chat_send`/`chat_msg` broadcast.
  - Mobile / native ports — out of scope per PROJECT.md.
  - Behavioural anti-cheat beyond authoritative-server model — out of scope per PROJECT.md.
  - Pre-game lobby, character creation, settings menu, message-board reader — Phase 7.
  - World-mutating action surfaces (item acquisition, message-board posts) — Phase 7. Phase 6 only bakes the **affordance** (block these while reconnecting); none ship in MVP scope.

  </domain>

  <decisions>
  ## Implementation Decisions

  ### Login + session UX (CLI-02, CLI-03)

  - **D-01 (hybrid Phaser scene + DOM-overlay form):** `LoginScene` renders retro pixel-art backdrop (Phaser); login form is a DOM `<form>` mounted via `Phaser.GameObjects.DOMElement`. Browser password-manager + autofill + accessibility (screen readers, autofill, paste) all work. Errors render inline below the form (`PROTOCOL_VERSION_MISMATCH`, bad credentials, `SERVER_DRAINING`).
  - **D-02 (autologin → ~500 ms spinner on login scene):** `BootScene` calls `GET /api/auth/me` on app boot. Valid session → show `LoginScene` with spinner + "Reconnecting as `<username>`…" for ~500 ms (visible feedback even on fast paths) → transition to `GameScene`. Invalid/expired → focused login form. User can hit Esc during the spinner to cancel and re-enter credentials. Frictionless steer (Phase 4 D-07) preserved without making the login screen "flash."
  - **D-03 (in-room force-reset overlay):** On `s2c.force_password_change` after `onJoin`, `GameScene` opens a non-dismissible password-change overlay (DOM form): movement keys frozen, scene rendered behind, `POST /api/auth/change-password`, on success server clears `accounts.force_reset` (Phase 4 D-08), overlay closes, normal play resumes — **no disconnect-reconnect cycle**.

  ### Chat HUD (CLI-05)

  - **D-04 (Minecraft-pattern non-modal overlay):** Chat is a DOM div absolutely positioned over the Phaser canvas. Default: latest N messages render bottom-left, fading out after a few seconds. **Enter or T** opens the chat input field + freezes movement keys (input field becomes focus-target so browser stops bubbling keystrokes to Phaser). **Enter** sends + closes; **Esc** cancels + closes; either way, movement keys re-engage. Visual indicator (e.g. cursor blink, distinct background) shows "chat-mode" active.
  - **D-05 (remote-player nameplates):** Always-on text label above remote-player sprites showing their `username`. Form factor (Phaser canvas text vs DOM overlay) is planner's call — both work; canvas text is cheaper, DOM gives crisper anti-aliasing.

  ### Reconnect UX (CLI-09)

  - **D-06 (top-banner reconnect):** WS drop → top-of-viewport yellow/orange banner: "Reconnecting…" with auto-retry every 1 s. Local prediction continues so the player's character stays responsive on screen. **World-mutating actions blocked** while disconnected (Phase 6 ships none, but the affordance is baked into the input/intent layer for Phase 7). Within Phase 4's 10 s grace → `allowReconnection` reuses session token; server position is authoritative on rejoin (no client-claim trust). Past 10 s → silent re-auth from cookie. Banner clears on success. Final failure (cookie also expired or repeated server unreachable) → "Disconnected — click to retry" clickable banner.
  - **D-07 (no cross-session location persistence — home-portal spawn on fresh login):** **Refines Phase 4 D-14.** Position persists in `characters.x/y/room_id` only for grace-window reconnect; on new session (full sign-out + sign-in, or session-expiry-and-rejoin), server spawns the player at their home portal, ignoring `characters.x/y`. REBNO is exploration-driven; travel itself is the progression, so per-session reset is intentional. **Server-side change required** (Phase 4 D-14 amended): `onJoin` for a new Better-Auth session emits home-portal position, NOT the persisted `characters.x/y`. Anti-cheat trivially satisfied — server is authoritative; client never claims position.

  ### Movement prediction + reconciliation (CLI-04, CLI-06)

  - **D-08 (schema expansion — server broadcasts remote-player input axes):** **Refines Phase 4 D-01.** `PlayerState` schema (`packages/protocol`) gains `input_axes` (last received c2s.input axes for the player). Server tick-loop populates from per-account stored input vector; `@colyseus/schema` delta-syncs. Bumps `PROTOCOL_VERSION`. Coordinated server-before-client deploy (Phase 4 D-03 ritual). Enables clients to run deterministic `packages/game-logic` `step()` for **remote players** during stale-snapshot extrapolation, so extrapolated positions stay collision-correct against the local signed-room layout.
  - **D-09 (event-driven c2s.input — refines Phase 4 D-02):** Client sends `c2s.input` 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; tick loop reads stored value each step. Heartbeat-coupled refresh (every 15 s — Phase 4 D-22 heartbeat cadence) reaffirms held inputs to survive dropped keyup packets. Lowers wire traffic; lowers Phase 4 D-22 input rate-limiter need (planner may relax `input` budget from 25/s burst 35).
  - **D-10 (threshold-gated reconciliation):** Local prediction error vs server snapshot:
    - `divergence < ~22 px` (≈ half a 44 × 40 tile, planner locks concrete number): smooth lerp toward server position over ~100 ms, replaying unacked inputs.
    - `divergence ≥ threshold`: hard snap to server position; no lerp.
    Avoids visible rubber-banding on big corrections while keeping micro-corrections invisible. Standard Source/Overwatch netcode pattern.
  - **D-11 (snapshot interp + step()-driven extrapolation for remote players):** Remote players render at ~100 ms backbuffer, interpolating between received state snapshots. If snapshot stale > 200 ms: extrapolate by running `packages/game-logic` `step()` locally for that player using their broadcast `input_axes` (D-08), with collision against the local signed-room layout — bounded, never floats through walls. Cap extrapolation horizon at 250 ms; thereafter, freeze the sprite. On next valid snapshot, snap-or-lerp by D-10's threshold rule.
  - **D-12 (HiDPI nearest-neighbor + integer scale):** Phaser `Scale.FIT` with `autoRound: true`; `Phaser.Textures.FilterMode.NEAREST` set on every loaded atlas; CSS `image-rendering: pixelated` as belt-and-suspenders. Snap to integer scale on `window.resize`. Closes CLI-06.

  ### Asset pipeline (AST-01, CLI-07)

  - **D-13 (`tools/asset-pipeline/` standalone CLI):** New Phase 6 tool, parallel to `tools/extract-gmd`, `tools/asset-catalog`, `tools/protocol-doc`, `tools/save-format-doc`, `tools/room-converter`. **Outside the pnpm workspace** (Phase 1 D-17 / Phase 2 D-15 boundary preserved). Reused by Phase 7 AST-02..04 (audio + fonts + manifest expansion).
  - **D-14 (two-mode pipeline — bootstrap + build):**
    - `pnpm asset-pipeline bootstrap` (one-shot): reads `extracted/client-5-8/sprites/<id>/<frame>.bmp` + companion `meta.json` (origin, frame_count) via sharp, emits per-sprite Aseprite-export-compatible PNG strips + JSON sidecars at `assets/source/sprites/<sprite_id>.{png,json}`. Output **committed** to git so devs can edit `.aseprite` source files locally, export PNG strips back to `assets/source/sprites/`, re-run `build`, and ship without re-running `bootstrap`.
    - `pnpm asset-pipeline build`: reads `assets/source/**`, packs into a single multi-sprite atlas (`atlas-mvp.png` + Phaser-format `atlas-mvp.json` with `{frames: {sprite_id_frame_n: {x, y, w, h}}, anchors: {sprite_id: {x, y}}}`) plus per-room `background-<room>.png`. Emits `tools/asset-pipeline/output/pipeline-manifest.json` with rich per-sprite metadata (sha256, frame_count, origin, atlas_ref).
  - **D-15 (Aseprite-friendly source format):** `assets/source/sprites/<sprite_id>.png` is a horizontal-strip PNG (Aseprite's canonical export); `assets/source/sprites/<sprite_id>.json` carries `{frame_w, frame_h, frame_count, origin: {x, y}, sprite_id}`. Devs edit `.aseprite` files (kept in `assets/source/sprites/aseprite/<sprite_id>.aseprite`, gitignored or committed per repo size policy — planner picks), export to the canonical PNG strip + JSON via Aseprite CLI (`aseprite -b sprite.aseprite --sheet sprite.png --data sprite.json --format json-array`).
  - **D-16 (hybrid manifest = Vite hashes + asset-pipeline indexes):** Vite handles file hashing + cache-busting (writes `dist/manifest.json` with content-hashed paths); `tools/asset-pipeline` post-processes `dist/manifest.json` into a richer `pipeline-manifest.json` indexing per-sprite metadata against Vite-hashed paths. Client fetches `pipeline-manifest.json` on `BootScene` startup, then loads atlas. Decouples Phase 7 AST-02..04 (audio + fonts) expansion from Vite internals.
  - **D-17 (MVP atlas scope):** Sprites for `mvp-lobby`: NaviStandD/U/R + NaviRunD/U/R (player, ~6 sprites × frame counts) + tile sprites (Tile1, TSide1, VBorder, HBorder). One background image (sourced from the smallest navigable extracted background that fits `mvp-lobby` semantics — planner picks at plan time after `tools/room-converter` outputs are inspected). Atlas budget: 512 × 512 px target (overflow → 1024 × 1024 fallback).

  ### Hosting + CLI-08 verification

  - **D-18 (same Fly app — `apps/server` serves static):** Vite build outputs to `apps/server/public/` (or `apps/server/dist/client/` — planner picks); Express adds `app.use(express.static(...))` mounted **after** `/api/auth/*` routes and **before** the Colyseus WS upgrade path. Single domain per env (`staging.rebno.decidel.com`, `rebno.decidel.com`); zero CORS; cookies `SameSite=Strict` viable. `.github/workflows/deploy-staging.yml` adds a client-build step (`pnpm --filter @rebno/client build:staging`) before the Docker build so the static bundle ships in the image. **Couples** client redeploy to server redeploy — acceptable at MVP scope; revisit at v2 if independent client release cadence becomes valuable.
  - **D-19 (CLI-08 verification = Playwright merge-gate + manual UAT milestone):**
    - **Playwright two-client smoke** — `apps/client/test/cli-08.e2e.test.ts` spawns 2 headless Chromium contexts via `@playwright/test`, logs in as 2 accounts (seeded via `pnpm migrate:legacy-accounts` ritual on staging), navigates to `mvp-lobby`, drives WASD on each, asserts: (a) each client sees the other's nameplate + sprite, (b) chat round-trip (client A sends "hello", client B receives with sender=A), (c) reconnect grace works (kill ws on A, reopen, A's character resumes). Wired into `pnpm verify:phase-6` + `.github/workflows/deploy-staging.yml` post-deploy step (extends Phase 5 D-11 soak harness). Failure marks staging deploy red; gates merge to `main` (closes CLI-08).
    - **Manual UAT (`06-HUMAN-UAT.md`)** — operator opens 2 Chrome tabs against `staging.rebno.decidel.com`, repeats the Playwright scenario by hand, captures screenshot + screen-recording video. Logged as the milestone artifact ("we shipped CLI-08"). Mirrors Phase 5's HUMAN-UAT.md pattern.

  ### Carried forward from prior phases (locked, not re-decided)

  Every prior `D-*` decision stands. Phase 6 explicitly relies on:
  - **ADR 0001** (`docs/adr/0001-client-engine.md`) — Phaser 3.90 locked. No engine flip.
  - **Phase 4 D-01..D-04** wire/state model (Colyseus Schema state-diff + msgpackr events + zod intents + `PROTOCOL_VERSION` first byte of `c2s.auth`). Phase 6 D-08 amends `PlayerState` to add `input_axes`; protocol bump.
  - **Phase 4 D-05..D-08** auth flow (Better-Auth `/api/auth/sign-in/email` returns `session_token`; client opens `wss://server/colyseus?token=<bearer>`; cookie-backed silent reconnect; in-room force-reset overlay; `legacy_credentials_staging` → `accounts` rehash on first login).
  - **Phase 4 D-10..D-13** room-layout signing + hot-reload (`s2c.room_layout`, Ed25519 verify against `VITE_ROOM_SIGNING_PUBKEY`, hot-swap on file change preserving player state). Phase 6 client implements verify + tilemap rebuild on hot-swap.
  - **Phase 4 D-20** `packages/game-logic` purity (no `Date.now`, no `Math.random`, no I/O). Phase 6 wraps `step()` for client prediction without violating the lint.
  - **Phase 4 D-22** rate limiter (token-bucket per `(account_id, msg_type)`). Phase 6 may relax `input` budget given D-09 event-driven cadence (planner decides).
  - **Phase 5 D-02** domains (`staging.rebno.decidel.com`, `rebno.decidel.com`).
  - **Phase 5 D-04** staging access (Fly proxy IP allowlist + `STAGING_INVITE_TOKEN` middleware). Phase 6 client must accept `?invite=<token>` in WSS connect URL when `import.meta.env.VITE_STAGING_MODE === '1'`.
  - **Phase 5 D-19** Ed25519 keypair lifecycle — client embeds `VITE_ROOM_SIGNING_PUBKEY` extracted from prod machine via `fly ssh` per env at build time.
  - **Phase 2 D-04** `mvp: yes|no` tagging in `docs/extracted-engine/` — Phase 6 cherry-picks `mvp:yes` features only; everything else is Phase 7.

  ### Claude's Discretion

  User answered every surfaced gray area; deferred two specific sub-questions to best judgment (recorded above as decisions):
  - **D-13** asset-pipeline tool home → standalone (`tools/asset-pipeline/`, Phase 1 D-17 pattern preserved).
  - **D-16** manifest format → hybrid (Vite handles hashing, asset-pipeline emits richer pipeline-manifest.json).

  Other discretion items inside selected areas:
  - **D-05** remote-player nameplate form-factor (Phaser canvas text vs DOM overlay) — planner picks during plan time after a quick perf comparison.
  - **D-10** divergence threshold concrete value (~22 px is the recommendation given 44 × 40 tile size; planner may pick 24 / 20 / 16 based on prediction-error histograms from Phase 5 staging soak data).
  - **D-11** extrapolation cap horizon concrete value (~250 ms recommendation; planner may tune given measured RTT distribution).
  - **D-15** `.aseprite` source storage location (committed under `assets/source/sprites/aseprite/` vs gitignored) — planner picks per repo size policy.
  - **D-17** MVP background image — selected at plan time after `tools/room-converter` discovers `mvp-lobby` dimensions.
  - **D-18** static-mount path (`apps/server/public/` vs `apps/server/dist/client/`) — planner picks at plan time.
  - Room-rendering — Phaser `TilemapLayer` is the recommendation; planner may flip to custom blit if `TilemapLayer` proves awkward for the canonical layout JSON shape from Phase 4 D-09.
  - Phase 6 plan **must** coordinate D-07 (server-side spawn semantics) + D-08 (schema expansion) + D-09 (event-driven inputs) into a small set of `apps/server` + `packages/protocol` patches landed before or alongside `apps/client`.

  Override any decision in planning if research or codebase reading surfaces a conflict.

  ### Folded Todos

  None — `gsd-sdk query todo.match-phase 6` returned 0 matches.

  </decisions>

  <canonical_refs>
  ## Canonical References

  **Downstream agents (gsd-phase-researcher, gsd-planner, gsd-pattern-mapper, gsd-executor) MUST read these before planning or implementing.**

  ### Project planning
  - `.planning/PROJECT.md` — Stage 6 Active scope ("MVP gate: two players join the same room, see each other move, exchange chat messages — multiplayer over the new server"); core value statement
  - `.planning/REQUIREMENTS.md` §"Client Rebuild — MVP (Stage 6)" — CLI-01..CLI-09 + AST-01 acceptance text
  - `.planning/ROADMAP.md` §"Phase 6: Client Rebuild — MVP Gate (CLI-08 HARD MILESTONE)" — goal + 6 success criteria + hard-milestone language
  - `.planning/STATE.md` — current position (Phase 05 complete; Phase 06 next)
  - `.planning/research/STACK.md` — Phaser 3.90 + Vite 8 + version pins; Fly.io specifics
  - `.planning/research/FEATURES.md` §"MVP-Critical (Stage 6 gate)" — defines `mvp:yes` cherry-pick scope
  - `.planning/research/ARCHITECTURE.md` — three-pipeline topology; where `apps/client` sits relative to `packages/protocol` + `packages/game-logic`
  - `.planning/research/PITFALLS.md` §B1 (server-authoritative — D-07 anti-cheat foundation), §C1 (HiDPI scale-mode pitfalls)
  - `.planning/codebase/CONCERNS.md` — repo-private-through-Phase-7 reminder; client deploy stays internal until cleared

  ### Prior CONTEXT.md (decisions consumed verbatim)
  - `.planning/phases/02-client-engine-documentation/02-CONTEXT.md` — D-04 `mvp` tagging convention
  - `.planning/phases/04-server-rebuild-mvp/04-CONTEXT.md` — D-01..D-25 (wire/state, auth+Colyseus, room hot-reload, persistence, lints); Phase 6 D-07/D-08/D-09 amend D-14/D-01/D-02 respectively
  - `.planning/phases/05-deploy/05-CONTEXT.md` — D-02 (region/domains), D-04 (staging invite token), D-19 (Ed25519 pubkey extract ritual + per-env Vite bundles)

  ### ADRs (locked decisions — Phase 6 consumes)
  - `docs/adr/0001-client-engine.md` — Phaser 3.90 locked (Phase 6 is where it lands)
  - `docs/adr/0002-persistence-layer.md` — SQLite + Litestream → Tigris (server-side; informs D-07 server-side spawn semantics)
  - `docs/adr/0003-canonical-snapshot.md` — `legacy/servers/enlyzeam-current/` import source (Phase 4 already consumed; Phase 6 has nothing new here)
  - `docs/adr/0004-room-hot-reload.md` — Phase 6 client implements verify + tilemap rebuild on `s2c.room_layout` hot-swap
  - `docs/adr/0005-deploy-topology.md` — two Fly apps + region `lax` (Phase 6 client ships into the same apps per D-18)
  - `docs/adr/0006-observability-stack.md` — OpenObserve self-hosted (Phase 6 client telemetry via OTel browser SDK is **deferred**; Phase 7 / v2 territory)

  ### Phase 1–5 outputs (consumed as input)
  - **Extracted assets:** `extracted/client-5-8/sprites/<sprite_id>/` + `meta.json` — `tools/asset-pipeline bootstrap` source (D-14)
  - `extracted/client-5-8/backgrounds/` — background-image source for D-17
  - `extracted/client-5-8/rooms/` — `tools/room-converter` already consumed for `mvp-lobby` (Phase 4)
  - **Server:** `apps/server/rooms/mvp-lobby/000.{json,sig}` — already seeded (Phase 4 D-12); Phase 6 client renders this layout
  - **Server (extension required):** `apps/server/src/RebnoRoom.{ts,js}` — gains `input_axes` broadcast (D-08); `onJoin` adds home-portal-spawn-on-new-session logic (D-07); `onMessage('input')` switches from per-tick to event-driven storage (D-09)
  - **Protocol:** `packages/protocol/src/state.ts` (or equivalent) — `PlayerState` schema gains `input_axes`; `PROTOCOL_VERSION` bumps (D-08)
  - **Protocol:** `packages/protocol/src/intents.ts` — `c2s.input` zod schema updated to event-driven shape `{seq, axes, buttons_down, buttons_up, monotonic_at_ms}` (D-09)
  - **Game-logic:** `packages/game-logic/src/step.ts` — pure `step(state, inputs, dt)` reused **as-is** for client-side prediction + remote-player extrapolation (D-11). No new game-logic in Phase 6.
  - **Tools:** `tools/asset-catalog/output/index.json` — sprite metadata cross-referenced during D-14 bootstrap step
  - **Tools:** `tools/room-converter/` — informs `mvp-lobby` JSON shape; Phase 6 doesn't call it at runtime
  - **Verification:** `apps/server/test/authority.integ.test.ts` + `apps/server/test/reconnect.integ.test.ts` (Phase 4) — Playwright two-client harness pattern reused (D-19)
  - **Verification:** `scripts/soak-staging.mjs` (Phase 5 D-11) — Phase 6 Playwright smoke extends this harness
  - **CI:** `scripts/verify-phase-5.mjs` + `.github/workflows/deploy-staging.yml` (Phase 5) — Phase 6 mirrors shape for `verify-phase-6.mjs` + adds client-build step

  ### Reverse-engineering wiki + extracted-engine docs
  - `docs/extracted-engine/README.md` — task-keyed jump table for engine subsystems
  - `docs/extracted-engine/MATRIX.md` + `MATRIX-rows.json` — engine choice rationale (informs Phaser feature usage in Phase 6)
  - `docs/extracted-engine/rendering.md` — `mvp:yes` rendering primitives Phase 6 must implement
  - `docs/extracted-engine/input.md` — keyboard event handling on the original (informs Phase 6 input layer)
  - `docs/extracted-engine/scene-room-model.md` — original room model; informs Phaser scene wiring + camera follow
  - `docs/extracted-engine/animation.md` — sprite frame/animation conventions for atlas packing (D-14 sidecar JSON shape)
  - `docs/extracted-engine/ui-and-menus.md` — chat HUD overlay pattern reference for D-04
  - `docs/extracted-engine/asset-catalog/index.json` — sprite cross-reference for AST-01 MVP-scope picks
  - `decomp/wiki/15-extraction-pipeline.md` — context for what `extracted/client-5-8/` carries
  - `decomp/wiki/16-bno-bnb-notes.md` — context only (Phase 7 PAR-05 territory; Phase 6 doesn't touch `.bnu`)

  ### External implementation references
  - Phaser 3.90 docs (`phaser.io/docs/3.90`) — Scene lifecycle, `Scale.FIT` + `autoRound`, `DOMElement`, `TilemapLayer`, `Tweens` for D-10 lerp, `Textures.FilterMode.NEAREST` for D-12
  - Colyseus 0.17 client docs (`docs.colyseus.io/colyseus/getting-started/javascript-client/`) — `Client.joinOrCreate`, room `state.onChange`, message handlers, `client.room.send()`, reconnect tokens
  - Vite 8 docs (`vitejs.dev/guide/`) — env modes, `import.meta.env`, manifest output, asset hashing, multi-env build via `--mode`
  - msgpackr browser bundle (`github.com/kriszyp/msgpackr#browser`) — encoder/decoder reuse on client side
  - zod docs — schema sharing between server (Phase 4) + client (Phase 6 reuses zod intents for type-safe send paths)
  - @colyseus/schema docs — client-side `state.onChange` + `MapSchema.onAdd/onChange/onRemove` patterns for player join/leave/move
  - Better-Auth docs (`better-auth.com`) — client SDK (`createAuthClient`) for `/api/auth/sign-in/email`, `signOut`, `me`, cookie semantics; `SameSite` config
  - Playwright docs (`playwright.dev`) — `@playwright/test` multi-context patterns for D-19 two-client harness
  - sharp docs (`sharp.pixelplumbing.com`) — BMP decode + PNG encode for D-14 bootstrap; lossless settings
  - Aseprite CLI docs (`aseprite.org/docs/cli/`) — canonical export to PNG strip + JSON-array sidecar
  - TexturePacker / `free-tex-packer` / sharp-bin-pack — atlas packing algorithm reference for D-14 build mode
  - HiDPI canvas + integer-scale references (`webgl-fundamentals.org/webgl/lessons/webgl-resizing-the-canvas.html`, Phaser `Scale.Manager`) — D-12

  ### What NOT to read in Phase 6
  - `legacy/servers/{enlyzeam-archive,local-current}/` — non-canonical per ADR 0003
  - `legacy/source-archive/` — older Client revisions, reference only
  - `legacy/unity-project/`, `legacy/maya-project/` — abandoned remake attempts; not authoritative
  - Modern decompilers (UTMT, Altar.NET) — incompatible with GM 5.3a (PITFALLS A1)
  - LiteFS / Postgres docs — explicitly out of scope per ADR 0002
  - Phaser 4 docs — explicitly rejected for MVP per ADR 0001 (Phase 7 retro re-evaluates)
  - PixiJS docs — engine choice locked Phaser 3.90 per ADR 0001

  </canonical_refs>

  <code_context>
  ## Existing Code Insights

  ### Reusable Assets

  - **`packages/game-logic/src/step.ts`** (Phase 4 D-20) — pure `step(state, inputs, dt) → state`; runs in browser unmodified. Phase 6 wraps it twice: (a) for the local player's prediction loop, (b) for remote-player extrapolation when snapshots go stale (D-11). Lint-enforced purity (no `Date.now`, no `Math.random`, no I/O) means it's safe in a Phaser scene update without breaking determinism.
  - **`packages/protocol/src/{state,intents,events}.ts`** (Phase 4) — Colyseus Schema classes, zod intents, msgpackr event types. Phase 6 imports browser-side; bumps `PROTOCOL_VERSION`; extends `PlayerState` (D-08) and `c2s.input` (D-09).
  - **`apps/server/src/RebnoRoom.js`** + `apps/server/src/onMessageHandlers.js` (Phase 4) — already handle `c2s.input`, `c2s.chat_send`, `c2s.heartbeat`, `c2s.room_join`. Phase 6 adds `input_axes` broadcast (D-08), event-driven input semantics (D-09), home-portal-spawn-on-new-session in `onJoin` (D-07).
  - **`apps/server/rooms/mvp-lobby/000.{json,sig}`** — already seeded (Phase 4 D-12). Phase 6 client consumes via `s2c.room_layout`.
  - **`apps/server/src/health.ts`** (Phase 4 D-21) + Phase 5 Express boot — Phase 6 adds `app.use(express.static(...))` mount for the Vite client bundle (D-18).
  - **`apps/server/src/auth-bootstrap.js`** + Better-Auth Express routes (Phase 4 D-05) — Phase 6 client SDK calls `/api/auth/sign-in/email`, `/api/auth/me`, `/api/auth/sign-out`, `/api/auth/change-password` with same-origin cookies.
  - **`extracted/client-5-8/sprites/0000-NaviStandD/` etc.** + `meta.json` — `tools/asset-pipeline bootstrap` source for AST-01 (D-14). 854 sprites total; MVP scope ~10 picks.
  - **`docs/extracted-engine/asset-catalog/index.json`** (Phase 2) — sprite metadata cross-reference; informs MVP-pick decisions (D-17).
  - **`tools/extract-gmd/src/types.ts`** — canonical extraction types reused by `tools/asset-pipeline`.
  - **`scripts/verify-phase-5.mjs`** + `.github/workflows/verify-phase-5.yml` (Phase 5) — Phase 6 mirrors shape for `verify-phase-6.mjs` (composite gate including Playwright two-client smoke + asset-pipeline lint + Vite env lint + trace:check).
  - **`scripts/soak-staging.mjs`** (Phase 5 D-11) — Phase 6 Playwright smoke (D-19) extends harness; reuses staging-deploy infrastructure.

  ### Established Patterns

  - **TS-everywhere** (PROJECT.md). `apps/client` is TypeScript-strict, Vite-built.
  - **pnpm workspace** (Phase 4 D-18). `apps/client` becomes the second `apps/*` workspace member; existing `tools/*` stay standalone (Phase 1 D-17 / Phase 2 D-15 boundary preserved → `tools/asset-pipeline` follows suit per D-13).
  - **Headless, CI-runnable** (Phase 1 D-01) — Phase 6 verification surface (Playwright headless Chromium, asset-pipeline lints, Vite env lint) runs on `ubuntu-latest` per Phase 5 precedent.
  - **Reproducibility-first** (Phase 1 D-15) — `tools/asset-pipeline build` output deterministic (sorted keys in manifest, pinned PNG encoder settings via sharp).
  - **Lint-as-forcing-function** (Phase 2/3/4/5 D-2X pattern) — `lint-asset-pipeline.mjs` (manifest schema + atlas integrity), `lint-vite-env.mjs` (`.env.staging`/`.env.prod` carry required `VITE_*` keys, no secrets), wired into `pnpm verify:phase-6`.
  - **Mvp tagging** (Phase 2 D-04) — Phase 6 cherry-picks `mvp:yes` features only.
  - **Anti-port reference** — Phase 6 ships **none** of the original `Ctrl+E` clipboard RCE / `Ctrl+Q` inspect / `Ctrl+O` codes admin model. Phase 7 PAR-07 fills the modernized admin UI; Phase 6 client has zero admin surface.
  - **ADR-on-first-lock** — Phase 6 produces no new ADR; ADR 0001 already locks the engine, ADR 0004 already locks hot-reload. If D-07/D-08/D-09 prove load-bearing in Phase 7 retro, an ADR captures the contract then.

  ### Integration Points

  - **Inputs:**
    - All prior phases' outputs (workspace + tools + extracted assets + signed-room layout + ADRs + research).
    - Phase 4 server runtime (extended with D-07/D-08/D-09 patches landed in Phase 6 plan).
    - Phase 5 deployed staging + prod Fly apps + per-env domains.
    - `legacy/open-source-release/BN Online Client 5-8.gmd` (already extracted; Phase 6 reads the extracted PNG/BMP outputs only).
  - **Outputs:**
    - `apps/client/` — full Vite + Phaser + TS app (`src/scenes/`, `src/net/`, `src/prediction/`, `src/ui/`, `src/auth/`, `src/assets/`, `src/main.ts`, `index.html`, `vite.config.ts`, `package.json`)
    - `apps/client/.env.staging` + `apps/client/.env.prod`
    - `apps/client/test/cli-08.e2e.test.ts` — Playwright two-client smoke (D-19)
    - `tools/asset-pipeline/` — new standalone Node CLI (D-13/D-14/D-15/D-16)
    - `assets/source/sprites/<sprite_id>.{png,json}` — committed Aseprite-export source files (output of `bootstrap`)
    - `apps/client/dist/` (gitignored) — Vite build artefact; shipped via `apps/server/public/` mount per D-18
    - `apps/server/src/RebnoRoom.js` patch — `input_axes` broadcast (D-08), event-driven input storage (D-09), home-portal `onJoin` (D-07)
    - `packages/protocol/src/state.ts` patch — `PlayerState.input_axes`, `PROTOCOL_VERSION` bump (D-08)
    - `packages/protocol/src/intents.ts` patch — `c2s.input` event-driven shape (D-09)
    - `tools/scripts/lint-asset-pipeline.mjs` + `tools/scripts/lint-vite-env.mjs`
    - `scripts/verify-phase-6.mjs` composite gate
    - `.github/workflows/deploy-staging.yml` extension — client-build step + Playwright smoke
    - `06-HUMAN-UAT.md` — milestone artifact (D-19)
  - **Downstream consumers:**
    - **Phase 7 PAR-01** (full client surface) — extends `apps/client/src/scenes/` with multi-room transitions, friends list, whispers, settings menu, message board reader; reuses `BootScene` + `LoginScene` + `GameScene` skeleton.
    - **Phase 7 PAR-03** (full room set) — uses `tools/room-converter` to populate every room; client tilemap rendering generalises from `mvp-lobby` to N rooms; D-04 hot-reload contract preserved.
    - **Phase 7 AST-02..04** (audio + fonts + content-hashed manifest) — extends `tools/asset-pipeline` per D-13/D-16 hybrid manifest.
    - **Phase 7 PAR-04** (full chat) — extends `apps/client/src/ui/chat/` from D-04 baseline (whispers, channels, ignore/block, profanity wordlist, history rolling buffer).
    - **Phase 7 PAR-05** (`.bnu` per-user character migration) — server-side; client unchanged at the protocol layer because home-portal spawn (D-07) means migrated character location is irrelevant on first login.
    - **Phase 7 PAR-07** (admin UI) — separate web UI; Phase 6 client surface has zero admin affordance.

  </code_context>

  <specifics>
  ## Specific Ideas

  - **CLI-08 is THE go/no-go for the rebuild** (ROADMAP.md). Phase 6 plan writes the Playwright smoke FIRST (TDD shape), then implements `apps/client` against it; CI gate prevents regression while the rest of Phase 6 builds out (D-19).
  - **Minecraft-pattern chat is the explicit user lock** (D-04). Enter or T toggles non-modal overlay; movement keys freeze on chat-mode; nameplate text always-on. Planner must NOT silently swap to a modal-popup chat UI.
  - **Anti-cheat via authoritative server is sufficient** (D-07). Server is authoritative; client never claims position. Reconnect "accept new location securely" = server's last-known x/y for the same session; new session = home portal regardless of client state. No behavioural anti-cheat in v1 (out of scope per PROJECT.md).
  - **Exploration is the progression model** (D-07). Cross-session location is intentionally NOT persisted — travel itself matters. This is the explicit user steer; Phase 4 D-14 (`characters.x/y` persisted on graceful-disconnect) is amended for Phase 6+.
  - **Schema expansion is acceptable Phase 6 cost** (D-08). Adding `PlayerState.input_axes` + bumping `PROTOCOL_VERSION` is preferred over client-only collision-clipped extrapolation, because it lets `packages/game-logic` `step()` drive remote-player extrapolation deterministically against the local signed-room layout. Coordinated server-before-client deploy ritual (Phase 4 D-03).
  - **Event-driven inputs** (D-09) — client sends on keydown/keyup transitions; server holds last-known axis vector. Saves bandwidth, simplifies server tick. Planner must add a heartbeat-coupled "still-pressed" reaffirmation message (every 15 s) to survive dropped keyup packets.
  - **Threshold-gated reconciliation with concrete tile size** (D-10) — tiles are 44 × 40 px; threshold candidate ~22 px (≈ half-tile). Planner picks final number from prediction-error histograms collected during the Phase 5 30-min staging soak (D-11). Invisible micro-corrections, no rubber-banding on big corrections.
  - **Aseprite-friendly asset source** (D-15) — devs edit `.aseprite` source files locally; pipeline reads PNG-strip + JSON-array exports (Aseprite's canonical CLI output). Keeps the 30+ year roadmap of "human dev edits a sprite" cheap.
  - **Same-Fly-app hosting** (D-18) — couples client + server deploy intentionally. MVP simplicity wins; CORS-free; cookies stay `SameSite=Strict`. Revisit at v2 if independent client release cadence becomes valuable.
  - **Playwright merge-gate + manual UAT milestone** (D-19) — CI keeps regression risk near zero; manual UAT provides the human "we shipped CLI-08" artifact + screen-recording video for posterity.
  - **Phaser 4 retro window** (ADR 0001) — Phase 6 ships on Phaser 3.90; Phase 7 retro re-evaluates Phaser 4. Phase 6 plan must not silently jump to Phaser 4.

  </specifics>

  <deferred>
  ## Deferred Ideas

  - **AST-02 (MIDI→OGG via ffmpeg + vintage soundfont) + AST-03 (BMP fonts → WOFF2) + AST-04 (full content-hashed manifest)** — Phase 7. Phase 6 ships silent (no audio in `extracted/client-5-8/` per ADR 0001) + uses Phaser default font. Hybrid manifest (D-16) is shaped to extend cleanly.
  - **Multi-room transitions, friends list + presence, whispers, account recovery, account-level settings** — Phase 7 PAR-04 / PAR-06.
  - **Full chat surface** (channels, ignore/block, profanity wordlist, history rolling buffer, whispers) — Phase 7 PAR-04. Phase 6 ships only `chat_send`/`chat_msg` broadcast on top of the D-04 HUD.
  - **`.bnu` per-user transactional character migration** — Phase 7 PAR-05.
  - **Modernized admin web UI** — Phase 7 PAR-07. Phase 6 client surface has zero admin affordance.
  - **Pre-game lobby, character creation, settings menu, message-board reader** — Phase 7.
  - **Mobile / native ports** — out of scope per PROJECT.md.
  - **OTel browser SDK / client-side telemetry to OpenObserve** — v2 / Phase 7. Phase 6 logs to console only; server-side OTel (Phase 5 D-12..D-16) covers the wire side.
  - **Independent client deploy cadence (separate Fly app for static client)** — v2; D-18 same-Fly-app coupling is acceptable at MVP scope.
  - **Behavioural anti-cheat** — out of scope per PROJECT.md. Authoritative server (D-07) is sufficient for v1.
  - **Pixel-diff verification of MVP room against original screenshots** — Phase 7 PAR-03. Phase 6 ships `mvp-lobby` only; pixel-perfect parity against original is Phase 7 territory.
  - **OAuth/passkey providers, public username changes** — Phase 7 PAR-06 + v2.
  - **Phaser 4 migration** — gated on ADR 0001 retro window (post-CLI-08, pre-Phase-7-PR-01).
  - **Independent input cadence rate-limit budget reduction** — D-09 makes Phase 4 D-22's 25/s burst 35 input budget overkill; planner may relax during Phase 6 plan if it doesn't add friction.

  ### Reviewed Todos (not folded)

  None — `gsd-sdk query todo.match-phase 6` returned 0 matches.

  </deferred>

  ---

  # Gap-Closure Addendum (2026-05-10)

  **Trigger:** 06-HUMAN-UAT.md executed by operator decidel 2026-05-10. All 5 tests block CLI-08 closure. Per user decision, all five gap-closure items scoped to **Phase 6** — no Phase 7 advance until CLI-08 truly green.

  **See:** `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md ## Findings` for finding details + sequencing recommendation.

  <gap_closure_decisions>

  ### Finding #1 — Login screen text invisible (high)

  - **D-23 (small CSS fix; one plan):** LoginScene CSS enforces UI-SPEC color tokens — title + field labels switch from `#000` → `#FFFFFF` (or token foreground). No design discussion needed; UI-SPEC §LoginScene already locks tokens. Add a Playwright contrast assertion (axe-core or simple computed-style check) to prevent regression.

  ### Finding #5 — No logout affordance (medium)

  - **D-24 (Esc menu — also fires on game-view click):** Add an in-GameScene **Esc menu** modal overlay (BNO-faithful command-screen UX feel). Menu options: **Logout** (clears Better-Auth session cookie + returns to LoginScene), **Resume** (closes overlay), **Settings** (placeholder, deferred to Phase 7). Trigger conditions: (a) operator presses Esc, (b) operator clicks the game canvas (single click). Movement keys frozen while overlay open. Logout flow: `POST /api/auth/sign-out` → clear cookie → `LoginScene.start()` (do NOT preserve invite token; user re-enters via fresh `?invite=` URL or saved bookmark). One plan.

  ### Finding #2 — Reconnect / cookie auto-login renders blank GameScene (critical)

  - **D-25 (investigate via /gsd-debug FIRST, then plan from findings):** Root cause unknown. Hypothesis space large (HUD mount idempotency, scene re-entry init, Phaser DOMElement re-create, Better-Auth cookie path bypassing BootScene → GameScene init). Run `/gsd-debug` session to isolate before writing a fix plan. Output: a debug summary doc that the next plan consumes verbatim. Fix plan ships with two new Playwright e2e tests added to 06-08's `cli-08.e2e.test.ts` family: (a) cookie-auto-login → GameScene renders HUD + remotes + self, (b) WS-kill → reconnect → GameScene re-renders HUD + remotes + self.

  ### Finding #3 — Movement does not match BNO (critical)

  - **D-26 (split into TWO plans; numerics first, sprite-state after #4):**
    - **Plan A (no asset dep):** Reverse numeric movement constants from extracted Client 5-8 GML and pin into `packages/game-logic`. Files to read (canonical refs below): `extracted/client-5-8/scripts/0285-player_update.gml`, `extracted/client-5-8/scripts/0347-iv_keyactions.gml`, `extracted/client-5-8/scripts/0355-pcode_mover.gml`, **plus the player object's GML events at `extracted/client-5-8/objects/0000-server/`** (⚠ in original BNO the player character object is named `server` — this is the canonical mover, NOT `0042-player` or any `navi*` script). Derive: walk speed (px/tick), key-buffering window, friction/accel if any, sub-pixel accumulator behavior, tick rate assumption. Update `PredictionEngine` + `step()` to use these constants. Add unit tests pinning the derived values (regression guard for any future tweak). **No asset dependency** — runs alongside #4 in parallel waves.
    - **Plan B (depends on Plan A + Finding #4):** 8-direction sprite-state machine — facing direction (D/U/L/R/DR/DL/UR/UL) + walk-cycle phase derived from velocity vector + cycle counter. Maps onto the player sprite atlases that #4 lands. Frame-perfect to BNO (consult `0000-server` events for facing-on-input semantics). Sequenced AFTER Plan A (numerics) AND #4 (atlases land).

  ### Finding #4 — No real BNO assets — first-pass (critical)

  - **D-27 (player sprites + 1 room = BNCentral):** First-pass real assets =
    - **Player sprite set:** all 8-direction Stand + Run sprites — `extracted/client-5-8/sprites/0027-NaviRunD/`, `0028-NaviRunR/` *(R-facing Run; corrects original enumeration omission flagged by plan-checker 2026-05-10)*, `0029-NaviStandR/`, `0030-NaviStandU/`, `0031-NaviRunU/`, `0032-NaviRunL/`, `0033-NaviStandL/`, `0036-NaviStandDR/`, `0037-NaviRunDR/`, `0038-NaviStandUR/`, `0039-NaviRunUR/`, `0040-NaviStandUL/`, `0041-NaviRunUL/`, `0042-NaviStandDL/`, `0043-NaviRunDL/`, plus the existing `0000-NaviStandD/` (already on disk). Total: 16 sprites (1 already-on-disk + 15 new). Bridge through the existing `tools/asset-pipeline/` plumbing (06-08 built the lint + manifest schema; never piped real input). Add a `bootstrap` mode (per existing CONTEXT D-13 reference) that reads `extracted/client-5-8/sprites/<id>/frames/img_*.bmp` + `meta.json` → emits PNG strips + Aseprite-compatible JSON sidecars at `assets/source/sprites/<id>.{png,json}`. Then `build` mode packs into `atlas-mvp.{png,json}`. Phaser loads via existing PlayerRenderer (replaces blue rectangle).
    - **Room:** `extracted/client-5-8/rooms/0058-BNCentral/` — **the actual latest online lobby iteration** in original BNO (NOT `0004-Online_Lobby`, which is older). Bridge `instances.json` + `tiles.json` + `creation-code.gml` + `backgrounds.json` into a server-emittable room-layout JSON consumable by `apps/server/rooms/<room>/000.json` and the Phase 4 SRV-13 contract + Phase 6 D-12 hot-reload protocol. Replaces the synthetic `mvp-lobby` room used in 06-07. Tile sprites needed for BNCentral come along with the bootstrap (the room's `tiles.json` references sprite IDs already covered by extracted/sprites/).
    - **Replaces** the `mvp-lobby` placeholder. Server room registration updated to use `bn-central` (or planner's preferred slug); Playwright e2e (06-08 + new D-25 tests) updated to assert `data-room-id="bn-central"`.
    - **Nametag (user-supplied 2026-05-10):** original BNO renders player display name as a cyan text label always-on above the sprite head. First-pass: cyan (`#22D3EE` accent already in UI-SPEC, or exact BNO cyan if extractable from `0000-server` draw events / `scr_draw*` text-color calls — verify before locking). Anchored to sprite top-center, follows movement, visible for self + remotes. Belongs to player-render plan (06-16 sprite-state Plan B is the natural home since it owns the per-player render path), not a separate plan. Confirm exact font/size/outline against extracted draw code before commit.
    - First-pass scope deliberately excludes audio (Phase 7 AST-02), fonts (Phase 7 AST-03), and the other 9 extracted rooms (Phase 7 PAR-03).

  ### Sequencing (advisory — locked at /gsd-plan-phase 6 time)

  | Wave | Plan | Finding | Depends on |
  |------|------|---------|------------|
  | 8 | 06-10: LoginScene contrast fix + Playwright assertion | #1 | — |
  | 8 | 06-11: Esc menu logout (incl. game-view click) | #5 | — |
  | 9 | 06-12: /gsd-debug session for reconnect/cookie blank-render | #2 | — |
  | 9 | 06-13: Reverse movement numeric constants from extracted GML (Plan A) | #3a | — |
  | 10 | 06-14: Asset-pipeline bootstrap → atlas-mvp + BNCentral room layout | #4 | — |
  | 11 | 06-15: Reconnect/cookie blank-render fix + 2 e2e tests + stale-dispatcher-room fix | #2 | 06-12 (debug findings) |
  | 11 | 06-16: 8-direction sprite-state machine (Plan B) | #3b | 06-13 + 06-14 |
  | 12 | 06-17: Re-run 06-HUMAN-UAT.md, capture real `06-CLI-08-milestone.mp4` | all | 06-10..06-16 |

  Wave 8 and 9 plans run in parallel within their wave (no shared files). Wave 10 (06-14) sequential — touches asset pipeline + server room registration. Wave 11 plans parallel within wave (different file sets). Wave 12 sequential (final UAT + close-out).

  ### Plan-tagging guidance for /gsd-plan-phase 6

  - 06-10 → `[doc->REQ-CDOC-*]` + `[impl/unit->REQ-CLI-06]` (visual/contrast under HiDPI umbrella) — or expand REQ list if a fresh contrast REQ-ID added.
  - 06-11 → `[impl->REQ-CLI-02]` + `[impl->REQ-CLI-03]` (logout is auth lifecycle).
  - 06-12 + 06-15 → `[impl/int->REQ-CLI-09]` (reconnect grace) + `[impl->REQ-CLI-04]` (re-entry render). 06-15 also folds in the secondary defect surfaced by `/gsd-debug` (`GameScene.ts:238-252` `if (!this.inputDispatcher)` guard keeps stale `room` reference after silent_reauth; movement intents would `room.send` through closed old room). Fix: refresh dispatcher's room ref on every reconnect, not only on first connect. Add unit test: dispatcher `.room` matches `GameScene.room` after silent_reauth cycle.
  - 06-13 + 06-16 → `[impl/unit->REQ-CLI-04]` (movement prediction; numerics + sprite-state).
  - 06-14 → `[impl->REQ-AST-01]` + `[impl->REQ-CLI-07]` (room rendering); evaluate whether AST-02..04 stay Phase 7 or partially absorb here (planner call).
  - 06-17 → `[doc->REQ-CLI-08]` (final milestone artifact).

  </gap_closure_decisions>

  <canonical_refs_addendum>

  ### Added 2026-05-10 (gap-closure)

  - `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md` — operator UAT findings; **MUST read** before planning gap-closure.
  - `extracted/client-5-8/objects/0000-server/` — original BNO player character object (named `server`, NOT `player`); contains the canonical mover events for Finding #3.
  - `extracted/client-5-8/scripts/0285-player_update.gml` — player tick/update logic.
  - `extracted/client-5-8/scripts/0347-iv_keyactions.gml` — input-vector keyactions mapping.
  - `extracted/client-5-8/scripts/0355-pcode_mover.gml` — mover pcode (per-tick movement application).
  - `extracted/client-5-8/rooms/0058-BNCentral/` — first-pass room (the actual latest online lobby iteration, replaces synthetic `mvp-lobby`).
  - `extracted/client-5-8/sprites/0000-NaviStandD/` through `extracted/client-5-8/sprites/0043-NaviRunDL/` — full 8-direction Stand+Run player sprite atlases (BMP frames + meta.json) + tiles + doors needed for BNCentral.
  - `tools/asset-pipeline/` (existing 06-08 plumbing) — bootstrap mode addition needed; `build` mode already shipped.

  </canonical_refs_addendum>

  <gotchas_addendum>

  ### Added 2026-05-10 (gap-closure)

  - **Player object naming:** BNO names the player character object `server` (`extracted/client-5-8/objects/0000-server/`), not `player`. `0042-player` is something else (likely lobby/session-related). Movement-fidelity work MUST consult `0000-server` events.
  - **Room slug:** original BNO uses `BNCentral` (room id 58); the room `0004-Online_Lobby` (id 4) is older. Use `0058-BNCentral` for first-pass real-asset work.
  - **Asset pipeline plumbing exists:** 06-08 shipped `tools/asset-pipeline/` (lint + manifest schema + build mode) but never piped real input. Don't rebuild — extend with `bootstrap` mode that consumes `extracted/client-5-8/sprites/<id>/frames/*.bmp`.
  - **`mvp-lobby` was synthetic:** 06-07 GameScene + Playwright assertions reference `mvp-lobby` room id. Updating to `bn-central` requires coordinated changes in `apps/server/rooms/`, `apps/client/src/scenes/`, and 06-08's `cli-08.e2e.test.ts` selectors.

  </gotchas_addendum>

  ---

  *Gap-closure addendum captured: 2026-05-10*
  *Operator: decidel*
  *Next step: `/gsd-plan-phase 6` to scaffold plans 06-10..06-17 per sequencing table above.*
  ```

  ## HUMAN-UAT.md (defects driving gap-closure)
  ```markdown
  ---
  phase: 6
  slug: client-rebuild-mvp-gate-cli-08-hard-milestone
  status: failed
  last_updated: 2026-05-10
  operator: decidel
  operator_run: 2026-05-10
  ---

  # Phase 6 — Human UAT (CLI-08 Milestone Artifact)

  > Five manual verifications driven by `06-VALIDATION.md §Manual-Only Verifications`. Test 2 (CLI-08) is THE milestone artifact for the rebuild — Capcom-derived BNO has had ~25 years between original and rebuild; this video is the proof.

  [doc->REQ-CLI-01] [doc->REQ-CLI-02] [doc->REQ-CLI-06] [doc->REQ-CLI-08] [doc->REQ-CLI-09] [doc->REQ-AST-01]

  > **2026-05-10 result:** ALL FIVE TESTS BLOCK CLI-08 CLOSURE. Networking + chat protocol works; UI/asset/UX layers fall short of "log in with their migrated account, walk around, and chat — bug-free" (CLAUDE.md core value). Five findings documented in `## Findings` below; per user decision (2026-05-10), all five gap-closure items are scoped to Phase 6 (no Phase 7 advance until CLI-08 truly green).

  ## Test 1 — HiDPI nearest-neighbor + integer scale (REQ-CLI-06)

  **Why manual:** Pixel-correctness on real Retina hardware not reproducible in headless Chromium at varying DPI matrices.

  **Instructions:**
  1. Open `https://staging.rebno.decidel.com/?invite=$STAGING_INVITE_TOKEN` on a 4K Retina display
  2. Open the same on a 1080p external monitor
  3. Open the same on a Windows machine with display scaling at 125% AND at 150%
  4. Log in, transition to GameScene
  5. For each environment, take a 4× zoom-in screenshot of the player sprite (NaviStandD)
  6. Visually inspect: pixel edges must be crisp, no anti-aliasing or sub-pixel blur

  **Acceptance:**
  - All sprites render with crisp pixel edges
  - No fractional-scale blur on Retina or Windows 125%/150%
  - Canvas snaps to integer scale on `window.resize`

  **Operator Notes:** HiDPI scale check itself was not exercised because of two upstream blockers surfaced before per-DPI sweeps were possible:
  - (a) Login screen text is unreadable — black title + black field labels on near-black `#0A0E1A` surface (see `uat-test-1-1.png`). Cannot read prompts in any DPI environment. **Finding #1.**
  - (b) GameScene renders only blue rectangles + nameplates — no real BNO assets piped through (see `uat-test-1-2.mp4`). Pixel-correctness on rectangles is meaningless; the test is for sprites. **Finding #4.**

  **Screenshots / Evidence:**
  - `uat-test-1-1.png` — login screen on 1080p Chrome (Windows 125% scaling), black-on-near-black title + labels
  - `uat-test-1-2.mp4` — two-window GameScene side-by-side showing blue-rectangle players (uat_a + uat_b) over empty black background

  **Status:** ⬛ FAIL — blocked by Findings #1 + #4. Re-run after gap-closure plans land real assets + readable login.

  ---

  ## Test 2 — CLI-08 MILESTONE: Two-player MVP gate (REQ-CLI-08)

  **Why manual:** The "we shipped CLI-08" milestone artifact requires human capture per CONTEXT D-19. Playwright provides regression coverage; this is the human deliverable.

  **Instructions:**
  1. Operator opens 2 Chrome browser windows (or two physical machines) against `https://staging.rebno.decidel.com/?invite=$STAGING_INVITE_TOKEN`
  2. Sign in as `uat_a` in window 1 and `uat_b` in window 2 (per psyche carry-over; original doc said `alice`/`bob` — those accounts do not exist in seeded staging DB; **doc bug, fixed in this revision**)
  3. Wait for both clients to enter `mvp-lobby` GameScene (`data-game-ready=true`)
  4. Start a screen recording covering BOTH browser windows (OBS / Loom / built-in screen recorder)
  5. In window 1 (uat_a): walk toward uat_b using WASD (~5 seconds of movement)
  6. In window 2 (uat_b): walk toward uat_a
  7. Confirm visually: each window shows BOTH player sprites + nameplates moving smoothly
  8. In window 1 (uat_a): press Enter, type `hello uat_b`, press Enter
  9. Confirm: window 2 shows `uat_a: hello uat_b` in chat HUD within 2 seconds
  10. In window 2 (uat_b): press Enter, type `hi uat_a`, press Enter
  11. Confirm: window 1 shows `uat_a: hi uat_a`
  12. Stop the recording
  13. Save the video as `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CLI-08-milestone.mp4`

  **Acceptance:**
  - Both clients see each other's sprite + nameplate move
  - Bidirectional chat works in real time
  - Video file exists and is committed (or externally linked)
  - Operator types "approved — CLI-08 shipped" in the resume signal

  **Operator Notes:** Networking + protocol layer works on the fresh-login path. Both clients see each other move; chat lands bidirectionally. BUT the full milestone fails on three counts:
  - (a) Movement does not match BNO (no friction/accel/sub-pixel feel reproduced from extracted GML — uses arbitrary client values). **Finding #3.**
  - (b) Visuals are blue rectangles + cyan nameplates only — no NaviStandD sprite, no room/tile background, no walls. **Finding #4.**
  - (c) Reconnect / cookie auto-login renders only the canvas frame — chat HUD missing, game content empty. Re-entry path is broken; only first-fresh-login renders correctly. **Finding #2.**

  The fresh-login two-player movement+chat slice was captured as `uat-test-1-2.mp4` (operator labeled it as Test 1 evidence; serves dual purpose). This is **NOT** the CLI-08 milestone video — that requires #2/#3/#4 fixed first.

  **Video:** `uat-test-1-2.mp4` (interim — fresh-login slice only, blue rectangles, no rooms). Final `06-CLI-08-milestone.mp4` blocked on Findings #2/#3/#4.

  **Status:** ⬛ FAIL — partial (networking + protocol confirmed working; presentation + reconnect path not). Re-run after Findings #2/#3/#4 close.

  ---

  ## Test 3 — Asset-pipeline Aseprite round-trip (REQ-AST-01)

  **Why manual:** `.aseprite` editor is a desktop app; export-and-rebuild is a human ergonomic check, not a CI assertion.

  **Instructions:**
  1. Open `assets/source/sprites/aseprite/0000-NaviStandD.aseprite` in Aseprite (skip if .aseprite source files were not committed per D-15 discretion)
  2. Modify a single pixel in frame 0 (any visible change)
  3. Export via Aseprite CLI:
     `aseprite -b 0000-NaviStandD.aseprite --sheet ../../source/sprites/0000-NaviStandD.png --data ../../source/sprites/0000-NaviStandD.json --format json-array`
  4. Run `pnpm asset-pipeline:build`
  5. Run `pnpm --filter @rebno/client build:staging`
  6. Reload `http://localhost:5173` (or staging) — confirm the modified pixel is visible
  7. Revert the change

  **Acceptance:**
  - Aseprite export to PNG strip + JSON sidecar succeeds
  - `asset-pipeline:build` re-packs the atlas without errors
  - Client renders the modified sprite

  **Operator Notes:** Cannot exercise — there are no real BNO assets in the project to edit. `assets/source/sprites/aseprite/0000-NaviStandD.aseprite` does not exist; no rooms, no tiles, no sprites are piped through the asset pipeline. The pipeline plumbing (lint:asset-pipeline + asset-pipeline:build/postprocess) was built in 06-08 but never fed real input. **Finding #4.**

  **Status:** ⬛ BLOCKED — by Finding #4. Re-run once first-pass real assets land (NaviStandD + at least one room/tile set extracted from BNO source per CLAUDE.md hard rule #6: extract → document → rewrite).

  ---

  ## Test 4 — Browser password manager + autofill (REQ-CLI-02)

  **Why manual:** Password manager integrations (1Password, Chrome built-in) are not headless-driveable.

  **Instructions:**
  1. Open `https://staging.rebno.decidel.com/?invite=$STAGING_INVITE_TOKEN` in Chrome with built-in password manager enabled (no extensions for first run)
  2. Sign in with a fresh test account (not previously saved)
  3. Confirm: Chrome prompts "Save password?" — accept
  4. Sign out
  5. Reload the page
  6. Confirm: Chrome auto-fills the password field
  7. Repeat with 1Password browser extension if available

  **Acceptance:**
  - Chrome built-in password save prompt appears
  - Autofill works on subsequent visits
  - (Optional) 1Password extension recognises the form
  - `autocomplete="username"` and `autocomplete="current-password"` attributes verified via DevTools (UI-SPEC §Login screen)

  **Operator Notes:** Cannot exercise — there is no logout affordance in the client. Once cookies are set, the LoginScene auto-bypasses to GameScene. The "Sign out" step in the instructions cannot be performed. Need a logout button (or `/logout` route + cookie/session clear) before this test is runnable. **Finding #5.**

  **Status:** ⬛ BLOCKED — by Finding #5. Re-run once logout affordance exists.

  ---

  ## Test 5 — Reconnect grace banner UX (REQ-CLI-09)

  **Why manual:** Banner timing + visual prominence is a subjective UX call beyond "did it reconnect".

  **Instructions:**
  1. Log in to staging; transition to GameScene
  2. Open Chrome DevTools → Console
  3. Run: `(window).colyseus?.connection?.transport?.ws?.close?.() ?? window.__rebno?.room?.connection?.close?.()`
  4. Confirm: amber banner appears within 1 s with text `Reconnecting…`
  5. Wait for auto-reconnect (within 10 s grace per Phase 4 D-22)
  6. Confirm: banner shows `Reconnected.` (transient flash, ~1 s) then disappears
  7. Confirm: player position is preserved (within-grace reconnect path)
  8. Repeat: kill WS + wait > 15 s (forces past grace) → banner should show `Disconnected — click to retry` (destructive red); click → reconnect succeeds → home portal spawn (per D-07 cross-session reset)

  **Acceptance:**
  - Banner appears within 1 s of WS close
  - Banner clears within 1 s of reconnect
  - Reconnect within grace preserves position
  - Reconnect past grace spawns at home portal (D-07)
  - Visual transitions feel smooth (no jank)

  **Operator Notes:** Networking layer reconnects (WS comes back), but post-reconnect the client renders only the canvas frame — no chat HUD, no remote players, no own player visible. Same root cause as Finding #2 (re-entry to GameScene does not re-mount HUD/renderers; init only fires on first fresh-login from LoginScene). Banner timing was not separately graded because the rendered content behind the banner is empty. **Finding #2.**

  **Status:** ⬛ FAIL — by Finding #2. Re-run after re-entry/reconnect path correctly re-mounts game scene.

  ---

  ## Findings (block CLI-08 closure)

  Per user decision 2026-05-10: all five findings scoped to **Phase 6** as gap-closure plans. No Phase 7 advance until CLI-08 truly green.

  | # | Finding | Severity | Suggested gap-closure |
  |---|---------|----------|----------------------|
  | 1 | Login screen text invisible — black title + labels on `#0A0E1A` surface | high (UX blocker) | One LoginScene CSS plan: enforce UI-SPEC color tokens (foreground white/cyan); add visual regression test |
  | 2 | Reconnect / cookie auto-login renders blank GameScene (canvas frame only, no HUD, no remotes, no self) | **critical (regression)** | Plan to fix re-entry init: HUD mount + renderer wiring must idempotently re-fire on every GameScene enter, not only first transition from LoginScene. Add Playwright e2e covering cookie-auto-login + WS-kill-then-reconnect rendering paths (current 06-08 e2e only covers fresh login) |
  | 3 | Movement doesn't match BNO — no friction/accel/sub-pixel feel from extracted client GML | **critical (scope/identity)** | Plan to derive PredictionEngine constants from extracted Client 5-8 GML (`player_step`, `friction`, speed values, key-buffering) per CLAUDE.md hard rule #5/#6. Update game-logic + add unit tests pinning the derived values |
  | 4 | No real BNO assets — blue rectangles only; no rooms, tiles, sprites, atlas content | **critical (scope/identity)** | Plan(s) to extract first-pass assets from BNO source: at minimum NaviStandD sprite atlas + one room (tiles + collision + spawn points). Pipe through existing asset-pipeline:build → atlas → Phaser texture. Tag REQ-AST-01..04 + REQ-PAR-* as appropriate |
  | 5 | No logout — sticky cookie auto-login blocks Test 4 + general UX | medium | Plan to add logout button to GameScene (top-right or Esc menu); cleanly clear Better-Auth session cookie + return to LoginScene |

  **Sequencing recommendation (advisory, lock in `/gsd-discuss-phase 6 --gaps` if used):**
  - Wave 8: #1 (login contrast — small, no deps)
  - Wave 9: #5 (logout button — small, unblocks Test 4)
  - Wave 10: #2 (re-entry render fix — critical regression, must precede final UAT)
  - Wave 11: #3 (movement fidelity from extracted GML — needs Phase 1 extraction artifacts)
  - Wave 12: #4 (first-pass assets from BNO source — needs Phase 1 extraction artifacts; largest)
  - Wave 13: re-run 06-HUMAN-UAT.md, capture final `06-CLI-08-milestone.mp4`

  ## Sign-Off

  | Test | Requirement | Status |
  |------|-------------|--------|
  | 1 — HiDPI | REQ-CLI-06 | ⬛ FAIL (blocked by #1, #4) |
  | 2 — CLI-08 milestone | REQ-CLI-08 | ⬛ FAIL (partial: networking ok; #2 + #3 + #4 fail) |
  | 3 — Aseprite round-trip | REQ-AST-01 | ⬛ BLOCKED (#4) |
  | 4 — Password manager | REQ-CLI-02 | ⬛ BLOCKED (#5) |
  | 5 — Reconnect banner | REQ-CLI-09 | ⬛ FAIL (#2) |

  **Operator approval:** decidel, 2026-05-10 — **NOT APPROVED.** All five tests block CLI-08 milestone closure. Five gap-closure plans required in Phase 6 before re-running UAT.

  **Phase 6 status:** IN_PROGRESS — UAT failed; gap-closure plans scoped (Findings #1–#5).
  ```

  ## Gap-Closure Plans

  ### Plan 06-10
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 10
  type: execute
  wave: 8
  depends_on: []
  files_modified:
    - apps/client/public/forms/login.html
    - apps/client/src/scenes/LoginScene.ts
    - apps/client/test/e2e/login-contrast.e2e.test.ts
    - apps/client/test/e2e/fixtures.ts
  autonomous: true
  requirements: [CLI-06, CLI-02]
  must_haves:
    truths:
      - "Login form heading + Username/Password labels + input text + CTA are readable on the #0A0E1A LoginScene background — measured contrast ratio ≥ 4.5:1 (WCAG AA normal text) for every UI-SPEC §LoginScene element"
      - "Computed-style colors match UI-SPEC §Color tokens exactly — heading + labels render #F3F4F6 (text-on-dominant), CTA renders #0A0E1A on #22D3EE (text-on-accent), error text renders #EF4444"
      - "Playwright contrast assertion fires on every CI run; any future regression that drops a token below 4.5:1 fails the e2e suite (BLOCKER guard against UAT Finding #1 recurrence)"
      - "Operator opening the staging URL on Windows 125% scaling can READ the form prompts (Finding #1 reproducer no longer reproduces)"
    artifacts:
      - path: apps/client/public/forms/login.html
        provides: "Login form HTML with explicit color tokens applied to title, labels, inputs, CTA, error region — no relying on UA defaults that produced the black-on-near-black UAT failure"
        contains: "color: #F3F4F6"
      - path: apps/client/src/scenes/LoginScene.ts
        provides: "Phaser-side LoginScene wires color tokens for any in-canvas backdrop text (Reconnecting-as-username spinner) so backdrop + DOM form share the contract"
        contains: "#F3F4F6"
      - path: apps/client/test/e2e/login-contrast.e2e.test.ts
        provides: "Playwright e2e — visits LoginScene, queries getComputedStyle for every UI-SPEC §LoginScene element, asserts foreground/background contrast ratio ≥ 4.5:1 via WCAG luminance formula"
        contains: "4.5"
    key_links:
      - from: apps/client/public/forms/login.html
        to: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md §Color
        via: "every color: declaration in the form references a token from the UI-SPEC table (#0A0E1A, #1F2937, #22D3EE, #EF4444, #F3F4F6) — no ad-hoc colors"
        pattern: "#(0A0E1A|1F2937|22D3EE|EF4444|F3F4F6)"
      - from: apps/client/test/e2e/login-contrast.e2e.test.ts
        to: apps/client/public/forms/login.html
        via: "page.goto(baseURL/forms/login.html) → page.evaluate(getComputedStyle) → contrast assertion"
        pattern: "getComputedStyle"
      - from: apps/client/playwright.config.ts (existing)
        to: apps/client/test/e2e/login-contrast.e2e.test.ts
        via: "Playwright auto-discovers *.e2e.test.ts under apps/client/test/e2e/; new test runs alongside cli-08.e2e.test.ts"
        pattern: "test/e2e"
  ---

  <objective>
  Close UAT Finding #1 (D-23): the login screen renders black-on-near-black text against the `#0A0E1A` background, making the form unreadable on every DPI environment the operator tested. Repaint LoginScene's DOM form with the UI-SPEC §Color tokens (foreground `#F3F4F6` on dominant `#0A0E1A`, accent `#22D3EE` for CTA), and add a Playwright contrast-ratio assertion as a regression guard so any future CSS drift below 4.5:1 fails CI before it reaches UAT.

  Purpose: per CONTEXT D-23 — UI-SPEC §LoginScene already locks the design tokens; this plan enforces them in code and pins them with an automated check. No design discussion or new tokens. CSS-only intervention plus one regression test.

  Output: a repainted login form (HTML + matching Phaser-side color references), a new Playwright e2e file that asserts WCAG AA contrast on every UI-SPEC §LoginScene element, and a green `pnpm --filter @rebno/client test:e2e` run including the new file. Per gap-closure addendum line 359 tag guidance — `[impl/unit->REQ-CLI-06]` (HiDPI / visual umbrella) plus `[doc->REQ-CLI-02]` (login lifecycle) on the markdown.
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-PATTERNS.md
  @apps/client/public/forms/login.html
  @apps/client/src/scenes/LoginScene.ts
  @apps/client/test/e2e/cli-08.e2e.test.ts
  @apps/client/test/e2e/fixtures.ts
  @apps/client/playwright.config.ts

  <interfaces>
  <!-- UI-SPEC §Color tokens (06-UI-SPEC.md lines 87-96) — locked. -->
  <!-- Every CSS color declaration in the login form MUST reference one of these. -->

  Dominant (60%):  #0A0E1A   (page + form background)
  Secondary (30%): #1F2937   (input field background, divider lines)
  Accent (10%):    #22D3EE   (CTA button background, focused-input border)
  Destructive:     #EF4444   (inline error text)
  Text on dominant:#F3F4F6   (body / label / heading text on #0A0E1A)
  Text on accent:  #0A0E1A   (text on the #22D3EE CTA)

  <!-- Typography (UI-SPEC §Typography lines 67-75): -->
  Body / label:    16px / 400 / line-height 1.5 / monospace stack
  Heading:         24px / 600 / line-height 1.2 / monospace stack
  Display (Phaser spinner): 28px / 600 / line-height 1.2 / monospace stack
  Font stack:      ui-monospace, "SF Mono", Menlo, Consolas, monospace

  <!-- Copy contract (UI-SPEC §Copywriting Contract lines 118-133) — DO NOT alter copy: -->
  Heading:         "Log in to BN Online"
  Username label:  "Username" / autocomplete="username"
  Password label:  "Password" / autocomplete="current-password"
  CTA:             "Log in"
  Spinner copy:    "Reconnecting as <username>…" (single-char ellipsis U+2026)
  Esc hint:        "Press Esc to log in as someone else"

  <!-- WCAG contrast formula (used by the new Playwright assertion) -->
  relativeLuminance(R,G,B):
    for c in [R,G,B]: c /= 255; c = (c <= 0.03928) ? c/12.92 : ((c+0.055)/1.055)**2.4
    return 0.2126*R + 0.7152*G + 0.0722*B
  contrastRatio(L1,L2):
    return (max(L1,L2) + 0.05) / (min(L1,L2) + 0.05)
  WCAG AA normal text: ≥ 4.5
  WCAG AA large text (≥ 18pt or ≥ 14pt bold): ≥ 3.0
  For Phase 6 we use ≥ 4.5 across the board (no large-text exemption — heading at 24px/600 still passes the stricter bar with the chosen tokens).

  <!-- Existing fixtures (apps/client/test/e2e/fixtures.ts) carry baseURL via Playwright config -->
  loginAs(page, account)              — drives form submit; reused as smoke check post-fix
  waitForGameReady(page)              — waits for canvas[data-game-ready=true]; not needed here
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: Apply UI-SPEC §Color tokens to LoginScene form HTML + matching Phaser backdrop colors</name>
    <files>
      apps/client/public/forms/login.html,
      apps/client/src/scenes/LoginScene.ts
    </files>
    <read_first>
      apps/client/public/forms/login.html (current black-on-near-black state),
      apps/client/src/scenes/LoginScene.ts (current Phaser backdrop + spinner color),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md §Color (lines 84-110) + §Typography (lines 67-79) + §Copywriting Contract §Login screen (lines 118-133),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md Test 1 + Findings table row #1 (the operator's exact failure description)
    </read_first>
    <behavior>
      - Form heading "Log in to BN Online" rendered in #F3F4F6 / 24px / weight 600 / monospace stack on #0A0E1A surface
      - Username + Password labels rendered in #F3F4F6 / 16px / weight 400 on #0A0E1A
      - Username + Password input text rendered in #F3F4F6 on #1F2937 (secondary surface) with 1px #1F2937 border that switches to #22D3EE on :focus (UI-SPEC §Color "chat-mode-active cursor / input border" pattern, applied to login inputs as well — accent reserved for the four explicit uses; this is one)
      - CTA button: background #22D3EE, text #0A0E1A, 16px / 400, monospace; :hover lightens to #67E8F9 (NOT in UI-SPEC strictly but standard hover-treatment of accent is acceptable and noted in done-criteria; if checker objects, swap to a 1.1 alpha bump on the same accent)
      - Inline error region rendered in #EF4444 / 16px / 400 — empty by default, populated on submit failure with the UI-SPEC copy ("Wrong username or password.", etc.)
      - Phaser-side LoginScene backdrop fillStyle stays #0A0E1A (matches form so the DOMElement mount blends seamlessly); fast-path "Reconnecting as <username>…" Phaser text uses color #F3F4F6 / monospace 28px (UI-SPEC Display role)
      - autocomplete="username" + autocomplete="current-password" attributes preserved (do not strip — RESEARCH §Pattern 2 pitfall)
      - All form copy strings unchanged (see <interfaces> Copy contract)
    </behavior>
    <action>
  **1. apps/client/public/forms/login.html** — Tag `<!-- [impl->REQ-CLI-02] [impl->REQ-CLI-06] -->` near the top. Replace any existing `color:` and `background:` declarations on `body`, `form`, `h1`, `label`, `input`, `button`, and the error region with the UI-SPEC tokens above. Wire `:focus` border on inputs to `#22D3EE`. Preserve all `autocomplete` attributes, all `id` attributes (`#username`, `#password`), all copy strings, and the form `<button type=submit>` structure (used by `loginAs` in fixtures.ts).

  Use a single inline `<style>` block (matches the file's current shape — do NOT introduce a separate CSS file; per 06-UI-SPEC.md `Tool: none` minimal-chrome posture).

  Concrete style block (paste as a block; adjust indentation to match file):
  ```css
  body {
    margin: 0;
    background: #0A0E1A;
    color: #F3F4F6;
    font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
    font-size: 16px;
    line-height: 1.5;
  }
  form#login-form {
    max-width: 320px;
    margin: 64px auto;
    padding: 24px; /* UI-SPEC spacing token "lg" */
    background: #0A0E1A;
    display: flex;
    flex-direction: column;
    gap: 16px; /* token "md" */
  }
  form#login-form h1 {
    margin: 0 0 16px 0;
    font-size: 24px;
    font-weight: 600;
    line-height: 1.2;
    color: #F3F4F6;
  }
  form#login-form label {
    display: flex;
    flex-direction: column;
    gap: 4px; /* token "xs" */
    color: #F3F4F6;
  }
  form#login-form input[type=text],
  form#login-form input[type=password] {
    font-family: inherit;
    font-size: 16px;
    font-weight: 400;
    line-height: 1.5;
    padding: 8px; /* token "sm" */
    color: #F3F4F6;
    background: #1F2937;
    border: 1px solid #1F2937;
    outline: none;
  }
  form#login-form input[type=text]:focus,
  form#login-form input[type=password]:focus {
    border-color: #22D3EE;
  }
  form#login-form button[type=submit] {
    font-family: inherit;
    font-size: 16px;
    font-weight: 400;
    line-height: 1.5;
    padding: 8px 16px;
    color: #0A0E1A;
    background: #22D3EE;
    border: none;
    cursor: pointer;
  }
  form#login-form button[type=submit]:hover {
    background: #67E8F9; /* same hue, lighter — accepted hover treatment */
  }
  #login-error {
    min-height: 1.5em;
    color: #EF4444;
    font-size: 16px;
  }
  ```

  **2. apps/client/src/scenes/LoginScene.ts** — Tag `// [impl->REQ-CLI-02] [impl->REQ-CLI-06]` near the LoginScene class header. Find the Phaser backdrop fill and the fast-path spinner text:
  - Backdrop fillStyle / camera background → confirm 0x0A0E1A (matches form bg). If currently a different hex (e.g. plain black 0x000000), update to 0x0A0E1A.
  - Fast-path spinner Phaser.GameObjects.Text → set `color: '#F3F4F6'`, `fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, monospace'`, `fontSize: '28px'`, `fontStyle: '600'` (UI-SPEC §Typography Display role; UI-SPEC line 77 explicitly upgrades the prior 24px to 28px for this surface).
  - Esc hint Phaser text under spinner: same color / family, 16px / 400.

  Do not alter the form-mount logic, fast-path timer logic, or any scene-transition logic — those belong to Findings #2 (06-15) and the existing fast-path code paths.
    </action>
    <verify>
      <automated>node -e "const fs=require('fs'); const html=fs.readFileSync('apps/client/public/forms/login.html','utf-8'); const ts=fs.readFileSync('apps/client/src/scenes/LoginScene.ts','utf-8'); const tokens=['#0A0E1A','#F3F4F6','#22D3EE','#EF4444','#1F2937']; for (const t of tokens) { if (!html.includes(t)) { console.error('MISSING in login.html:', t); process.exit(1); } } if (!ts.includes('#F3F4F6') &amp;&amp; !ts.includes('0xF3F4F6')) { console.error('MISSING #F3F4F6 in LoginScene.ts'); process.exit(1); } console.log('contrast tokens present');"</automated>
    </verify>
    <done>
      apps/client/public/forms/login.html contains explicit color tokens for body, form, h1, label, input (default + :focus), button, and #login-error — every color references a UI-SPEC §Color value (no ad-hoc hex). apps/client/src/scenes/LoginScene.ts uses #F3F4F6 (or 0xF3F4F6) for the spinner + Esc hint and 0x0A0E1A for the backdrop. Operator can read the form on a #0A0E1A page on Windows 125% scaling (manual smoke; locked by Task 2's automated check).
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Playwright contrast e2e — getComputedStyle + WCAG luminance assertion ≥ 4.5</name>
    <files>
      apps/client/test/e2e/login-contrast.e2e.test.ts,
      apps/client/test/e2e/fixtures.ts
    </files>
    <read_first>
      apps/client/test/e2e/cli-08.e2e.test.ts (existing e2e shape — copy the imports + describe pattern),
      apps/client/test/e2e/fixtures.ts (existing baseURL handling),
      apps/client/playwright.config.ts (auto-discovery glob — confirm *.e2e.test.ts is picked up)
    </read_first>
    <behavior>
      - Test 1: visiting `/forms/login.html` (or whatever path the LoginScene loads it from — fixtures.ts knows; if the form is mounted dynamically by Phaser, navigate to the app root, wait for the form to appear, then probe) yields a heading element with computed color = rgb(243,244,246) on parent computed background = rgb(10,14,26) — contrast ratio ≥ 4.5
      - Test 2: same probe for both `<label>` elements, both `<input>` elements (default state — no focus), the `<button type=submit>`, and the `#login-error` region (assert ratio ≥ 4.5 even though the region is empty by default — when populated, error text MUST contrast against form bg)
      - Test 3: focusing an input via `.focus()` and re-probing computed border-color asserts `rgb(34, 211, 238)` (#22D3EE) — guards the accent-on-focus contract
      - All three tests use a shared `getContrast(page, selector)` helper that reads getComputedStyle for color + (effective parent background, walking the ancestor chain until a non-transparent background is found) and runs the WCAG luminance formula
    </behavior>
    <action>
  **1. apps/client/test/e2e/fixtures.ts** — Add (do not replace) a small helper export. Tag `// [unit->REQ-CLI-06]` above the export.

  ```ts
  // [unit->REQ-CLI-06]
  export type ContrastProbe = { fg: string; bg: string; ratio: number };

  /** Run inside page.evaluate. Walks ancestors for first opaque background. */
  export const computeContrastInPage = `
    (selector) => {
      const el = document.querySelector(selector);
      if (!el) return null;
      const fg = getComputedStyle(el).color;
      let node = el;
      let bg = 'rgba(0, 0, 0, 0)';
      while (node) {
        const c = getComputedStyle(node).backgroundColor;
        if (c && !c.includes('rgba(0, 0, 0, 0)') && !c.includes('transparent')) { bg = c; break; }
        node = node.parentElement;
      }
      if (bg === 'rgba(0, 0, 0, 0)') bg = getComputedStyle(document.body).backgroundColor;
      const parse = (s) => {
        const m = s.match(/rgba?\\(([^)]+)\\)/);
        if (!m) return [0,0,0];
        return m[1].split(',').slice(0,3).map((v) => parseInt(v.trim(), 10));
      };
      const lum = (rgb) => {
        const cs = rgb.map((v) => { const n = v / 255; return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4); });
        return 0.2126*cs[0] + 0.7152*cs[1] + 0.0722*cs[2];
      };
      const Lfg = lum(parse(fg));
      const Lbg = lum(parse(bg));
      const ratio = (Math.max(Lfg, Lbg) + 0.05) / (Math.min(Lfg, Lbg) + 0.05);
      return { fg, bg, ratio };
    }
  `;
  ```

  **2. apps/client/test/e2e/login-contrast.e2e.test.ts** — New file. Tag `// [int->REQ-CLI-06] [unit->REQ-CLI-02]` at top. Mirror cli-08.e2e.test.ts's imports + describe shape.

  ```ts
  // [int->REQ-CLI-06] [unit->REQ-CLI-02]
  // Regression guard for UAT Finding #1 (D-23): black-on-near-black login text.
  // Asserts WCAG AA contrast ≥ 4.5:1 for every UI-SPEC §LoginScene element.

  import { test, expect } from '@playwright/test';
  import { computeContrastInPage } from './fixtures';

  const ELEMENTS = [
    { sel: 'form#login-form h1', label: 'heading' },
    { sel: 'form#login-form label[for="username"]', label: 'username label' },
    { sel: 'form#login-form label[for="password"]', label: 'password label' },
    { sel: 'form#login-form input#username', label: 'username input' },
    { sel: 'form#login-form input#password', label: 'password input' },
    { sel: 'form#login-form button[type=submit]', label: 'submit CTA' },
  ];

  test.describe('LoginScene contrast (UI-SPEC §Color)', () => {
    test.beforeEach(async ({ page }) => {
      // Navigate to the app root; LoginScene mounts the DOM form into the page.
      // If the form is served as a static include at /forms/login.html, that
      // path also works for a pure-form contrast check; both are valid because
      // the styles are inline.
      await page.goto('/');
      // Wait for the form to be in the DOM (LoginScene mounts it on create()).
      await page.waitForSelector('form#login-form h1', { timeout: 10000 });
    });

    for (const { sel, label } of ELEMENTS) {
      test(`${label} contrast >= 4.5:1`, async ({ page }) => {
        const r = await page.evaluate(
          new Function('selector', 'return (' + computeContrastInPage + ')(selector);') as any,
          sel,
        ) as { fg: string; bg: string; ratio: number } | null;
        if (!r) throw new Error(`element not found: ${sel}`);
        expect(r.ratio, `${label}: fg=${r.fg} bg=${r.bg} ratio=${r.ratio.toFixed(2)}`).toBeGreaterThanOrEqual(4.5);
      });
    }

    test('focused input border switches to accent #22D3EE', async ({ page }) => {
      await page.locator('form#login-form input#username').focus();
      const borderColor = await page.locator('form#login-form input#username').evaluate(
        (el) => getComputedStyle(el as HTMLElement).borderColor,
      );
      // rgb(34, 211, 238) === #22D3EE
      expect(borderColor.replace(/\s+/g, '')).toMatch(/rgb\(34,211,238\)|#22D3EE/i);
    });
  });
  ```

  **3. Run locally** with the dev server already booted by `playwright.config.ts`'s two-mode webServer (per 06-08 SUMMARY): `pnpm --filter @rebno/client test:e2e -- --grep "LoginScene contrast"`. All 7 tests (6 elements × ratio + 1 focus) GREEN before commit.
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test:e2e -- --grep "LoginScene contrast"</automated>
    </verify>
    <done>
      apps/client/test/e2e/login-contrast.e2e.test.ts exists with 7 GREEN tests (6 contrast probes + 1 focus-border probe). fixtures.ts exports computeContrastInPage helper. The new file is auto-discovered by playwright.config.ts and runs in both local and CI modes. Any CSS regression that drops a UI-SPEC §LoginScene element below 4.5:1 contrast fails the e2e suite. Operator's UAT Finding #1 reproducer is now machine-checked.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Untrusted CSS / theme drift → user readability | Future PRs may swap a token without re-running UI-SPEC review; the contrast e2e is the forcing function |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-10-01 | Tampering | Adversary or accidental edit drops form text below WCAG 4.5:1 | mitigate | Playwright contrast e2e (login-contrast.e2e.test.ts) gates merge in apps/client/test:e2e CI run |
  | T-06-10-02 | Disclosure | Inline styles leak design tokens to public bundle | accept | UI-SPEC tokens are not secrets; CSS hex values are public-information by design |
  | T-06-10-03 | Repudiation | Operator claims contrast was OK at UAT but evidence missing | mitigate | The e2e provides per-element contrast ratio in failure output; logs preserved per Playwright reporter |
  </threat_model>

  <verification>
  - `pnpm --filter @rebno/client test:e2e -- --grep "LoginScene contrast"` exits 0 (7 tests green)
  - `pnpm --filter @rebno/client typecheck` exits 0 (TypeScript happy with the new fixtures export)
  - Visual smoke: open `apps/client/public/forms/login.html` in a browser locally — heading + labels + inputs + CTA visibly readable on the dark navy background
  - `pnpm verify:phase-6` (after this plan + downstream Wave 11/12 plans land) passes the e2e step including the new contrast file
  - `pnpm trace:check` reports `[impl->REQ-CLI-06]` and `[unit->REQ-CLI-06]` tags now present for visual contrast coverage
  </verification>

  <success_criteria>
  - UAT Finding #1 (D-23) closed: login form is readable on the #0A0E1A surface across all DPI environments
  - All UI-SPEC §LoginScene §Color tokens enforced in code (heading + labels + inputs + CTA + error region)
  - Playwright contrast e2e (`login-contrast.e2e.test.ts`) is part of `pnpm verify:phase-6` step 9 — regression guard active
  - Phase 6 UAT re-run (06-17) can read the form prompts on first inspection, unblocking all DPI sweep evidence in Test 1 (HiDPI nearest-neighbor)
  - Tags `[impl/unit->REQ-CLI-06]` + `[doc->REQ-CDOC-*]` present (per gap-closure addendum line 359)
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-SUMMARY.md` recording:
  (a) Final color tokens applied (heading / labels / inputs / CTA / error / focus border)
  (b) Computed contrast ratios for each UI-SPEC §LoginScene element (paste from the Playwright reporter output for posterity)
  (c) Confirmation the new e2e file runs in both local + CI modes
  (d) Any deviations from the UI-SPEC §Color table (should be zero — flag the hover treatment if checker objects)
  (e) Confirmation operator can read the form on the next staging deploy (manual smoke check; final lock comes at 06-17 UAT re-run)
  </output>
  </content>
  </invoke>```

  ### Plan 06-11
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 11
  type: execute
  wave: 8
  depends_on: []
  files_modified:
    - apps/client/src/ui/EscMenu.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/auth/client.ts
    - apps/client/src/__test__/esc-menu.test.ts
    - apps/client/test/e2e/logout.e2e.test.ts
    - apps/client/index.html
  autonomous: true
  requirements: [CLI-02, CLI-03]
  must_haves:
    truths:
      - "Operator pressing Esc inside GameScene opens a non-modal menu overlay with three options: Logout / Resume / Settings (placeholder)"
      - "Operator clicking the Phaser game canvas (single click anywhere on the canvas) ALSO opens the same Esc menu — equivalent UX surfaces per D-24"
      - "While the Esc menu is open, movement keys (WASD + arrows) are frozen — keystrokes do not reach the InputDispatcher → no c2s.input over the wire"
      - "Selecting Logout calls POST /api/auth/sign-out, clears the Better-Auth session cookie via Better-Auth client SDK, drops the Colyseus room (room.leave()), and transitions back to LoginScene with no session"
      - "Settings option is rendered but is a no-op placeholder ('Settings — coming in Phase 7') — does NOT enable a deferred Phase 7 surface"
      - "Resume closes the overlay and re-engages movement keys without any auth/network side-effect"
      - "Logout flow does NOT preserve the staging invite token — operator re-enters via fresh `?invite=` URL or saved bookmark per D-24"
      - "Playwright e2e covers: open via Esc, open via canvas click, Logout returns to LoginScene with empty session cookie"
    artifacts:
      - path: apps/client/src/ui/EscMenu.ts
        provides: "DOM overlay component — non-modal-style Esc menu with Logout / Resume / Settings buttons; emits onLogout / onResume / onSettings events; mount/unmount lifecycle similar to existing ChatHUD"
        contains: "EscMenu"
      - path: apps/client/src/scenes/GameScene.ts
        provides: "Wires Esc keydown + canvas pointerdown → escMenu.open(); escMenu.onLogout → signOut + room.leave() + scene.start('LoginScene'); escMenu.open()/close() toggles InputDispatcher freeze state"
        contains: "escMenu"
      - path: apps/client/src/auth/client.ts
        provides: "signOut() helper — wraps Better-Auth SDK signOut + clears any sessionStorage reconnection-token cache; idempotent so double-click of Logout doesn't error"
        contains: "signOut"
      - path: apps/client/src/__test__/esc-menu.test.ts
        provides: "vitest unit suite — EscMenu mount/unmount idempotency, button-click event emission, focus-trap-not-required (non-modal per D-24)"
        contains: "EscMenu"
      - path: apps/client/test/e2e/logout.e2e.test.ts
        provides: "Playwright e2e — login + Esc → menu visible; click Logout → back at LoginScene; document.cookie no longer contains better-auth session"
        contains: "Logout"
      - path: apps/client/index.html
        provides: "DOM-overlay slot for #esc-menu (sibling of #chat-hud + #reconnect-banner under #dom-overlay)"
        contains: "esc-menu"
    key_links:
      - from: apps/client/src/scenes/GameScene.ts
        to: apps/client/src/ui/EscMenu.ts
        via: "import + instantiate in create(); call escMenu.open() on Esc keydown OR canvas pointerdown"
        pattern: "EscMenu|escMenu"
      - from: apps/client/src/ui/EscMenu.ts
        to: apps/client/src/auth/client.ts
        via: "Logout button → onLogout event → GameScene calls signOut() then room.leave() then scene.start('LoginScene')"
        pattern: "signOut"
      - from: apps/client/src/scenes/GameScene.ts
        to: apps/client/src/scenes/LoginScene.ts
        via: "scene.start('LoginScene', { fastPath: false }) on logout — DO NOT pass username/sessionToken; user must re-authenticate"
        pattern: "scene.start.*LoginScene"
      - from: apps/client/src/scenes/GameScene.ts
        to: InputDispatcher freeze state
        via: "while escMenu.isOpen, InputDispatcher.setFrozen(true) — same pattern as chat-mode freeze"
        pattern: "setFrozen|isFrozen"
  ---

  <objective>
  Close UAT Finding #5 (D-24): there is no logout affordance, so operators are stuck with cookie auto-login and can never re-test the Login screen, password manager flow, or "log in as someone else" scenario. Add a BNO-faithful Esc menu overlay (Logout / Resume / Settings-placeholder) wired to BOTH Esc keydown AND a single click on the game canvas. Logout cleanly drops the Better-Auth session cookie + the Colyseus room, returns to LoginScene; Resume closes the overlay; Settings is a Phase 7 deferred placeholder.

  Purpose: per CONTEXT D-24 — the Esc menu UX feels right for BNO's command-screen aesthetic, and dual triggers (Esc + canvas click) cover both keyboard-led and mouse-led operator habits. Unblocks UAT Test 4 (browser password manager + autofill — currently BLOCKED by Finding #5) and adds the missing auth-lifecycle surface for CLI-02/CLI-03.

  Output: a new DOM-overlay component (EscMenu), GameScene wiring for both triggers, an idempotent signOut helper, a vitest unit suite for the overlay, and a Playwright e2e that proves the Logout cookie clear + LoginScene transition. Per gap-closure addendum line 360 — `[impl->REQ-CLI-02] [impl->REQ-CLI-03]`.
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-PATTERNS.md
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/auth/client.ts
  @apps/client/src/ui/ChatHUD.ts
  @apps/client/src/ui/reconnect-banner.ts
  @apps/client/index.html
  @apps/client/test/e2e/cli-08.e2e.test.ts
  @apps/client/test/e2e/fixtures.ts

  <interfaces>
  <!-- Existing patterns to mirror — read each file fully before writing EscMenu. -->

  ChatHUD shape (apps/client/src/ui/ChatHUD.ts) — canonical DOM-overlay reference:
  - export class ChatHUD { mount(parent: HTMLElement); unmount(); ... }
  - if (this.mounted) return; idempotency guard (line 81)
  - mounts under #dom-overlay (apps/client/index.html line 18)
  - emits events via callback constructor args, not DOM CustomEvent
  - styles inline via `el.style.X = ...` (matches UI-SPEC `Tool: none` posture)

  ReconnectBanner shape (apps/client/src/ui/reconnect-banner.ts) — second canonical reference:
  - mount(parent), update(state), unmount()
  - DOM div positioned: fixed; styled inline

  Better-Auth client SDK (apps/client/src/auth/client.ts existing exports):
  - signIn.email({...}) — already used by LoginScene form submit
  - getSession() — already used by BootScene cookie probe
  - signOut() — Better-Auth client SDK exports this; verify it's wired into the createAuthClient instance; if not, expose it from auth/client.ts

  InputDispatcher freeze pattern (existing — chat-mode uses this):
  - this.inputDispatcher.setFrozen(true)  → c2s.input messages suppressed
  - this.inputDispatcher.setFrozen(false) → resume normal input flow
  - If this method does not exist, add it; existing chat-mode wiring per D-04 implies SOME freeze mechanism — reuse it

  GameScene Esc-key handling: Phase 4 D-22 + Phase 6 D-04 reserve Esc for "cancel chat input". The new Esc menu fires when Esc is pressed AND chat is NOT in chat-mode. Order: chat-mode-aware Esc handler runs first; if chat input is closed, falls through to esc-menu open. Implement as a precedence check inside the existing Esc handler.

  Phaser canvas click trigger: this.input.on('pointerdown', () => { if (!escMenu.isOpen() && !chatHud.isChatMode()) escMenu.open(); }). Single click is sufficient (D-24 — "operator clicks the game canvas").

  DOM-overlay slot (apps/client/index.html):
  <div id="dom-overlay">
    <!-- ChatHUD mounts here -->
    <!-- ReconnectBanner mounts here -->
    <!-- EscMenu mounts here (new) -->
  </div>

  UI-SPEC §Color tokens (06-UI-SPEC.md lines 87-96) — Esc menu uses:
  - Background:        #0A0E1A (dominant; matches login form for visual consistency)
  - Button background: #1F2937 (secondary)
  - Logout button accent: #EF4444 (destructive — Logout is the destructive option per UI-SPEC §Destructive reserved-for "destructive-looking surfaces" — explicitly logout DROPS the session, equivalent to the disconnect-banner pattern)
  - Resume button accent: #22D3EE (accent — primary safe action)
  - Settings (disabled placeholder): #1F2937 with #F3F4F6 text at 0.5 opacity to signal disabled
  - Text on dominant: #F3F4F6
  - Font: monospace 16px / 400 (UI-SPEC §Typography body)
  - Heading "Menu": 24px / 600 (UI-SPEC §Typography heading)

  Spacing: padding 24px (token "lg"), gap 16px (token "md") between buttons.

  Position: centered absolutely, 320px wide (matches login form), z-index above canvas + ChatHUD but below ReconnectBanner.
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: EscMenu DOM-overlay component + idempotency unit tests</name>
    <files>
      apps/client/src/ui/EscMenu.ts,
      apps/client/src/__test__/esc-menu.test.ts,
      apps/client/index.html
    </files>
    <read_first>
      apps/client/src/ui/ChatHUD.ts (canonical mount/unmount pattern + event-callback shape),
      apps/client/src/ui/reconnect-banner.ts (second reference for inline styles + state updates),
      apps/client/src/__test__/chat-hud.test.ts (if exists — copy vitest harness shape; if absent, model after any apps/client/src/__test__/*.test.ts using JSDOM),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md §Color + §Spacing + §Typography
    </read_first>
    <behavior>
      - mount() — appends #esc-menu (hidden by default) under #dom-overlay; idempotent (`if (this.mounted) return;`)
      - open() — sets display, populates buttons, emits no-op (caller responsible for freeze); idempotent (`if (this._open) return;`)
      - close() — hides, idempotent
      - isOpen() — returns boolean
      - constructor takes { onLogout, onResume, onSettings } callback bag; Logout/Resume/Settings buttons invoke each respectively
      - unmount() — removes the DOM node; idempotent
      - Settings button is rendered but disabled (button.disabled = true; visible 'Settings — coming in Phase 7' tooltip / sub-text)
      - All inline styles use UI-SPEC §Color tokens (assert via test: button.style.color === 'rgb(243, 244, 246)' etc.)
    </behavior>
    <action>
  **1. apps/client/index.html** — Tag `<!-- [impl->REQ-CLI-02] [impl->REQ-CLI-03] -->` near the dom-overlay div. Add a comment slot for #esc-menu (no actual node — EscMenu mounts dynamically per the ChatHUD pattern):
  ```html
  <!-- [impl->REQ-CLI-02] [impl->REQ-CLI-03] EscMenu mounts here on demand -->
  ```

  **2. apps/client/src/ui/EscMenu.ts** — Tag `// [impl->REQ-CLI-02] [impl->REQ-CLI-03]` at top.

  ```ts
  // [impl->REQ-CLI-02] [impl->REQ-CLI-03]
  // Esc menu overlay (D-24). Non-modal — does NOT trap focus. Movement freeze
  // is the caller's responsibility (GameScene wires inputDispatcher.setFrozen).

  export interface EscMenuCallbacks {
    onLogout: () => void | Promise<void>;
    onResume: () => void;
    onSettings?: () => void; // intentionally unused in Phase 6 — Settings is a Phase 7 placeholder
  }

  export class EscMenu {
    private root: HTMLDivElement | null = null;
    private mounted = false;
    private _open = false;
    constructor(private cbs: EscMenuCallbacks) {}

    mount(parent: HTMLElement): void {
      if (this.mounted) return;
      const root = document.createElement('div');
      root.id = 'esc-menu';
      root.setAttribute('data-testid', 'esc-menu');
      root.style.cssText = [
        'position: fixed', 'top: 50%', 'left: 50%', 'transform: translate(-50%, -50%)',
        'width: 320px', 'padding: 24px', 'background: #0A0E1A',
        'color: #F3F4F6',
        'font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace',
        'font-size: 16px', 'line-height: 1.5',
        'display: none', 'flex-direction: column', 'gap: 16px',
        'z-index: 100',
      ].join('; ') + ';';

      const heading = document.createElement('div');
      heading.textContent = 'Menu';
      heading.style.cssText = 'font-size: 24px; font-weight: 600; line-height: 1.2;';

      const mkBtn = (label: string, bg: string, fg: string, onClick: () => void, opts: { disabled?: boolean; title?: string; testid?: string } = {}) => {
        const b = document.createElement('button');
        b.type = 'button';
        b.textContent = label;
        b.disabled = !!opts.disabled;
        if (opts.title) b.title = opts.title;
        if (opts.testid) b.setAttribute('data-testid', opts.testid);
        b.style.cssText = [
          'font-family: inherit', 'font-size: 16px', 'font-weight: 400',
          'padding: 8px 16px', `color: ${fg}`, `background: ${bg}`,
          'border: none', 'cursor: pointer',
          opts.disabled ? 'opacity: 0.5' : '',
        ].filter(Boolean).join('; ') + ';';
        b.addEventListener('click', () => { if (!b.disabled) onClick(); });
        return b;
      };

      // Resume = primary safe action (accent)
      const resumeBtn = mkBtn('Resume', '#22D3EE', '#0A0E1A', () => this.cbs.onResume(), { testid: 'esc-menu-resume' });
      // Settings = placeholder (Phase 7)
      const settingsBtn = mkBtn('Settings', '#1F2937', '#F3F4F6', () => this.cbs.onSettings?.(), {
        disabled: true, title: 'Settings — coming in Phase 7', testid: 'esc-menu-settings',
      });
      // Logout = destructive (drops session)
      const logoutBtn = mkBtn('Logout', '#EF4444', '#0A0E1A', async () => {
        logoutBtn.disabled = true; // guard against double-click
        try { await this.cbs.onLogout(); } finally { logoutBtn.disabled = false; }
      }, { testid: 'esc-menu-logout' });

      root.append(heading, resumeBtn, settingsBtn, logoutBtn);
      parent.appendChild(root);
      this.root = root;
      this.mounted = true;
    }

    open(): void {
      if (!this.mounted || this._open) return;
      if (this.root) this.root.style.display = 'flex';
      this._open = true;
    }

    close(): void {
      if (!this._open) return;
      if (this.root) this.root.style.display = 'none';
      this._open = false;
    }

    isOpen(): boolean { return this._open; }

    unmount(): void {
      if (!this.mounted) return;
      this.root?.remove();
      this.root = null;
      this.mounted = false;
      this._open = false;
    }
  }
  ```

  **3. apps/client/src/__test__/esc-menu.test.ts** — Tag `// [unit->REQ-CLI-02] [unit->REQ-CLI-03]`.

  ```ts
  // [unit->REQ-CLI-02] [unit->REQ-CLI-03]
  import { describe, it, expect, beforeEach, vi } from 'vitest';
  import { EscMenu } from '../ui/EscMenu';

  describe('EscMenu', () => {
    let parent: HTMLElement;
    beforeEach(() => {
      document.body.innerHTML = '<div id="dom-overlay"></div>';
      parent = document.getElementById('dom-overlay')!;
    });

    it('mounts idempotently', () => {
      const m = new EscMenu({ onLogout: vi.fn(), onResume: vi.fn() });
      m.mount(parent); m.mount(parent);
      expect(parent.querySelectorAll('#esc-menu').length).toBe(1);
    });

    it('opens and closes', () => {
      const m = new EscMenu({ onLogout: vi.fn(), onResume: vi.fn() });
      m.mount(parent);
      expect(m.isOpen()).toBe(false);
      m.open(); expect(m.isOpen()).toBe(true);
      m.close(); expect(m.isOpen()).toBe(false);
    });

    it('open/close are idempotent', () => {
      const m = new EscMenu({ onLogout: vi.fn(), onResume: vi.fn() });
      m.mount(parent);
      m.open(); m.open(); expect(m.isOpen()).toBe(true);
      m.close(); m.close(); expect(m.isOpen()).toBe(false);
    });

    it('Logout button invokes onLogout callback', async () => {
      const onLogout = vi.fn();
      const m = new EscMenu({ onLogout, onResume: vi.fn() });
      m.mount(parent); m.open();
      (parent.querySelector('[data-testid=esc-menu-logout]') as HTMLButtonElement).click();
      expect(onLogout).toHaveBeenCalledOnce();
    });

    it('Resume button invokes onResume callback', () => {
      const onResume = vi.fn();
      const m = new EscMenu({ onLogout: vi.fn(), onResume });
      m.mount(parent); m.open();
      (parent.querySelector('[data-testid=esc-menu-resume]') as HTMLButtonElement).click();
      expect(onResume).toHaveBeenCalledOnce();
    });

    it('Settings button is disabled (Phase 7 placeholder)', () => {
      const m = new EscMenu({ onLogout: vi.fn(), onResume: vi.fn() });
      m.mount(parent); m.open();
      const btn = parent.querySelector('[data-testid=esc-menu-settings]') as HTMLButtonElement;
      expect(btn.disabled).toBe(true);
      expect(btn.title).toMatch(/Phase 7/);
    });

    it('uses UI-SPEC §Color tokens (Resume = accent, Logout = destructive)', () => {
      const m = new EscMenu({ onLogout: vi.fn(), onResume: vi.fn() });
      m.mount(parent); m.open();
      const resume = parent.querySelector('[data-testid=esc-menu-resume]') as HTMLButtonElement;
      const logout = parent.querySelector('[data-testid=esc-menu-logout]') as HTMLButtonElement;
      expect(resume.style.background).toMatch(/#22D3EE|rgb\(34,\s*211,\s*238\)/i);
      expect(logout.style.background).toMatch(/#EF4444|rgb\(239,\s*68,\s*68\)/i);
    });

    it('unmounts cleanly', () => {
      const m = new EscMenu({ onLogout: vi.fn(), onResume: vi.fn() });
      m.mount(parent); m.unmount();
      expect(parent.querySelectorAll('#esc-menu').length).toBe(0);
      expect(m.isOpen()).toBe(false);
    });
  });
  ```
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test -- src/__test__/esc-menu.test.ts</automated>
    </verify>
    <done>
      apps/client/src/ui/EscMenu.ts exports a mount/open/close/unmount lifecycle matching the ChatHUD pattern. 8 vitest tests in esc-menu.test.ts pass. UI-SPEC §Color tokens applied verbatim (Resume = #22D3EE accent, Logout = #EF4444 destructive, Settings disabled at 0.5 opacity). DOM-overlay HTML slot tagged.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: GameScene wiring (Esc + canvas click → menu) + signOut helper + Playwright logout e2e</name>
    <files>
      apps/client/src/scenes/GameScene.ts,
      apps/client/src/auth/client.ts,
      apps/client/test/e2e/logout.e2e.test.ts
    </files>
    <read_first>
      apps/client/src/scenes/GameScene.ts (find: existing Esc handler in chat-mode; existing this.input.on('pointerdown') if any; InputDispatcher freeze API),
      apps/client/src/auth/client.ts (find: existing createAuthClient + signIn.email + getSession; check if signOut is exported),
      apps/client/src/net/colyseus-client.ts (room.leave() pattern — used during reconnect/teardown),
      apps/client/test/e2e/cli-08.e2e.test.ts (Playwright shape — copy describe/test imports + loginAs fixture usage)
    </read_first>
    <behavior>
      - Esc keydown inside GameScene: if chat-mode is open → defer to chat handler; else → escMenu.open() + inputDispatcher.setFrozen(true)
      - Phaser pointerdown on canvas: if escMenu not open AND chat not in chat-mode AND no other modal active → escMenu.open() + inputDispatcher.setFrozen(true)
      - Resume → escMenu.close() + inputDispatcher.setFrozen(false)
      - Logout → call signOut() → room.leave() (best-effort; catch errors) → scene.start('LoginScene', { fastPath: false }) (no username, no sessionToken — fresh login required)
      - signOut() in auth/client.ts: calls Better-Auth client.signOut(); clears any sessionStorage 'colyseus-reconnect' token cache; idempotent (safe to call multiple times)
      - Playwright e2e: loginAs(accountA) → wait for game-ready → press Escape → assert [data-testid=esc-menu] visible → click [data-testid=esc-menu-logout] → assert form#login-form heading visible AND document.cookie does NOT contain better-auth session cookie name
    </behavior>
    <action>
  **1. apps/client/src/auth/client.ts** — Add or expose signOut. Tag `// [impl->REQ-CLI-02] [impl->REQ-CLI-03]` near the new export.

  If the file already creates an authClient via `createAuthClient({...})` per Better-Auth docs, ensure `authClient.signOut` is reachable. Add a small wrapper:

  ```ts
  // [impl->REQ-CLI-02] [impl->REQ-CLI-03]
  /** Signs out the Better-Auth session. Idempotent — safe to call multiple times. */
  export async function signOut(): Promise<void> {
    try {
      await authClient.signOut();
    } catch (err) {
      // Better-Auth signOut on an already-cleared session may 401; treat as success.
      console.warn('[auth] signOut: ignoring non-fatal error', err);
    }
    // Clear Colyseus reconnection-token cache so a stale token can't auto-resume
    try {
      sessionStorage.removeItem('colyseus-reconnect');
    } catch {/* sessionStorage may be unavailable in some test envs */}
  }
  ```

  (Adjust import name if the existing module exports `client` instead of `authClient`. Match existing file conventions.)

  **2. apps/client/src/scenes/GameScene.ts** — Tag `// [impl->REQ-CLI-02] [impl->REQ-CLI-03]` near the new EscMenu wiring section.

  Add to GameScene:
  ```ts
  // imports
  import { EscMenu } from '../ui/EscMenu';
  import { signOut } from '../auth/client';

  // in class fields
  private escMenu!: EscMenu;

  // in create() — after chatHud.mount + reconnectBanner.mount
  this.escMenu = new EscMenu({
    onLogout: async () => {
      // [impl->REQ-CLI-02] [impl->REQ-CLI-03]
      this.escMenu.close();
      try { await signOut(); } catch {/* noop — signOut is idempotent */}
      try { this.room?.leave(); } catch {/* room may already be closed */}
      this.scene.start('LoginScene', { fastPath: false });
    },
    onResume: () => {
      this.escMenu.close();
      this.inputDispatcher?.setFrozen(false);
    },
  });
  const overlay = document.getElementById('dom-overlay');
  if (overlay) this.escMenu.mount(overlay);

  // Esc keydown wiring — adapt to existing handler structure
  // IMPORTANT: precedence — chat-mode Esc cancels chat first; only fires menu when chat is closed.
  this.input.keyboard?.on('keydown-ESC', () => {
    if (this.chatHud?.isChatMode?.()) return; // existing chat-mode handler takes Esc
    if (this.escMenu.isOpen()) {
      this.escMenu.close();
      this.inputDispatcher?.setFrozen(false);
    } else {
      this.escMenu.open();
      this.inputDispatcher?.setFrozen(true);
    }
  });

  // Canvas pointerdown — D-24 secondary trigger
  this.input.on('pointerdown', () => {
    if (this.escMenu.isOpen()) return;
    if (this.chatHud?.isChatMode?.()) return;
    // any other modal (force-reset overlay)? skip if active
    this.escMenu.open();
    this.inputDispatcher?.setFrozen(true);
  });

  // in shutdown()/destroy() — cleanup
  this.escMenu?.unmount();
  ```

  **Edge cases to handle in the executor's discretion (not load-bearing for this plan):**
  - If `inputDispatcher.setFrozen` does not exist as an API yet, add it: `setFrozen(b: boolean) { this._frozen = b; }` + check `_frozen` in dispatch path.
  - If `chatHud.isChatMode` does not exist as a method, add a getter.
  - If GameScene has shutdown() lifecycle missing, add `shutdown() { this.escMenu?.unmount(); }`.

  **3. apps/client/test/e2e/logout.e2e.test.ts** — Tag `// [int->REQ-CLI-02] [int->REQ-CLI-03]`.

  ```ts
  // [int->REQ-CLI-02] [int->REQ-CLI-03]
  // Regression guard for UAT Finding #5 (D-24): no logout affordance.
  // Tests both Esc trigger AND canvas click trigger; verifies cookie cleared.

  import { test, expect } from '@playwright/test';
  import { loginAs, waitForGameReady, accountA } from './fixtures';

  test.describe('Esc menu + logout (D-24)', () => {
    test('Esc opens menu; Logout returns to LoginScene with cleared session', async ({ page }) => {
      await loginAs(page, accountA);
      await waitForGameReady(page);
      await page.keyboard.press('Escape');
      await expect(page.locator('[data-testid=esc-menu]')).toBeVisible();
      await page.click('[data-testid=esc-menu-logout]');
      // Back at LoginScene
      await expect(page.locator('form#login-form h1')).toBeVisible({ timeout: 5000 });
      // Better-Auth session cookie cleared
      const cookies = await page.context().cookies();
      const sessionCookies = cookies.filter((c) => /better-auth|session/i.test(c.name));
      expect(sessionCookies.every((c) => !c.value || c.value === '')).toBe(true);
    });

    test('canvas click opens menu (D-24 secondary trigger)', async ({ page }) => {
      await loginAs(page, accountA);
      await waitForGameReady(page);
      // Click roughly the center of the canvas
      const canvas = page.locator('canvas[data-game-ready=true]');
      await canvas.click({ position: { x: 100, y: 100 } });
      await expect(page.locator('[data-testid=esc-menu]')).toBeVisible();
    });

    test('Resume closes menu and re-engages movement', async ({ page }) => {
      await loginAs(page, accountA);
      await waitForGameReady(page);
      await page.keyboard.press('Escape');
      await expect(page.locator('[data-testid=esc-menu]')).toBeVisible();
      await page.click('[data-testid=esc-menu-resume]');
      await expect(page.locator('[data-testid=esc-menu]')).toBeHidden();
    });
  });
  ```
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test:e2e -- --grep "Esc menu"</automated>
    </verify>
    <done>
      GameScene wires both Esc keydown AND canvas pointerdown to escMenu.open(); chat-mode-Esc precedence preserved. signOut() helper exported from auth/client.ts and idempotent. Logout flow: signOut → room.leave → scene.start('LoginScene'). Playwright e2e (3 tests) GREEN. Operator can now exit GameScene; UAT Test 4 (browser password manager) is unblocked for re-test at 06-17.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Client → Better-Auth signOut endpoint | Logout intent crosses into auth perimeter; cookie clear is server-side authoritative |
  | Operator click → game-canvas → modal escalation | Canvas click is "untrusted" in that it's incidentally fired during gameplay; menu must NOT open spuriously while in chat-mode or during a force-reset overlay |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-11-01 | Elevation | Logout doesn't actually clear the cookie → next session reuses old auth | mitigate | Playwright e2e asserts `document.cookie` does not contain better-auth session after Logout; signOut() wraps Better-Auth SDK + clears sessionStorage reconnection-token cache |
  | T-06-11-02 | DoS | Operator spams canvas click while typing in chat → menu pops over chat input | mitigate | pointerdown handler checks chatHud.isChatMode() and exits early; chat-mode Esc precedence preserved |
  | T-06-11-03 | Tampering | Adversary replaces Resume callback to trigger Logout silently | accept | Code is bundled; no runtime extension surface in Phase 6; if a Phase 7 plugin model is added, revisit |
  | T-06-11-04 | Repudiation | Operator claims Logout didn't fire | mitigate | Logout button has data-testid; e2e captures the click; Playwright reporter logs the failure if button missing |
  </threat_model>

  <verification>
  - `pnpm --filter @rebno/client test -- src/__test__/esc-menu.test.ts` exits 0 (8 vitest tests)
  - `pnpm --filter @rebno/client test:e2e -- --grep "Esc menu"` exits 0 (3 Playwright tests)
  - `pnpm --filter @rebno/client typecheck` exits 0
  - Manual smoke: log in to local dev, press Esc → menu opens; click Logout → back at LoginScene; cookie cleared in DevTools Application tab
  - `pnpm verify:phase-6` (after this plan + Wave 11 land) passes the new e2e file as part of step 9
  - `pnpm trace:check` reports `[impl->REQ-CLI-02]` + `[impl->REQ-CLI-03]` tags satisfied for the new logout surface
  </verification>

  <success_criteria>
  - UAT Finding #5 (D-24) closed: GameScene has a logout affordance reachable via both Esc and canvas click
  - BNO-faithful command-screen UX (per D-24 rationale) — non-modal overlay that freezes movement keys while open
  - UAT Test 4 (browser password manager) unblocked — operator can sign out, sign in again, exercise autofill
  - Settings option visible as a Phase 7 placeholder (does NOT smuggle in deferred functionality)
  - Tags `[impl->REQ-CLI-02] [impl->REQ-CLI-03]` (per gap-closure addendum line 360)
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-11-SUMMARY.md` recording:
  (a) Final EscMenu component public API (mount/open/close/isOpen/unmount + callback shape)
  (b) GameScene precedence rules locked (chat-mode Esc takes precedence; canvas click suppressed during chat-mode + during any modal)
  (c) signOut() idempotency confirmed by manual double-click smoke test
  (d) Confirmation that document.cookie has the better-auth session cleared after Logout (paste a Playwright reporter snippet for traceability)
  (e) Any deviations from D-24 (should be zero — flag if Settings turned out to need a real handler beyond placeholder)
  </output>
  </content>
  </invoke>```

  ### Plan 06-12
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 12
  type: execute
  wave: 9
  depends_on: []
  files_modified:
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md
  autonomous: true
  requirements: [CLI-09, CLI-04]
  must_haves:
    truths:
      - "06-12-DEBUG-FINDINGS.md exists in the phase directory and is the canonical input contract for plan 06-15 — 06-15's executor reads this file (not the generic .planning/debug/reconnect-blank-render.md) to produce the fix"
      - "Document carries the root cause verbatim: the Better-Auth session_token is dropped between BootScene → LoginScene fast-path → GameScene; cookie auto-login forwards only `username`; GameScene early-returns at empty token (line 191-192 of GameScene.ts)"
      - "Document carries the recommended fix direction (Option A — three edits in BootScene/LoginScene/GameScene threading sessionToken through init data; Option B — defence in depth via getSession() self-heal in GameScene.create)"
      - "Document folds in the secondary defect (GameScene.ts:238-252 stale inputDispatcher.room reference after silent_reauth) with a regression test sketch — flagged as in-scope for 06-15 per CONTEXT addendum line 361"
      - "Document specifies the four required regression tests (1 GameScene unit, 1 LoginScene unit, 2 Playwright e2e in cli-08.e2e.test.ts family) so 06-15's executor has a checklist"
      - "No source file modifications occur in this plan — pure-doc consolidation"
    artifacts:
      - path: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md
        provides: "Phase-local consolidation of the diagnose-only output from .planning/debug/reconnect-blank-render.md — input contract for plan 06-15"
        contains: "session_token"
    key_links:
      - from: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md
        to: .planning/debug/reconnect-blank-render.md
        via: "explicit citation in §0 — points to the global debug document for full diagnostic trail; phase-local doc is the curated input for 06-15"
        pattern: "reconnect-blank-render"
      - from: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md
        to: 06-15-PLAN.md (downstream)
        via: "06-15's <context> block references this doc; 06-15's tasks consume the §Required test coverage list verbatim"
        pattern: "06-12-DEBUG-FINDINGS"
  ---

  <objective>
  Consolidate the diagnose-only output from `.planning/debug/reconnect-blank-render.md` (root-causing UAT Finding #2) into a phase-local `06-12-DEBUG-FINDINGS.md` document that plan 06-15 will consume verbatim as its input contract. This plan does NOT modify any source files; it produces ONE Markdown artifact whose job is to give 06-15's executor a self-contained, in-phase reference for the cookie-auto-login blank-render bug, the recommended fix shape, the secondary stale-inputDispatcher-room defect (folded in per CONTEXT addendum line 361), and the four required regression tests.

  Purpose: per CONTEXT D-25 — `/gsd-debug` was run BEFORE planning to isolate the root cause; the global debug doc lives at `.planning/debug/reconnect-blank-render.md` (already root-caused, status `root_caused`). 06-15 needs a phase-local copy curated to "fix shape + test checklist" so the executor doesn't have to re-derive the diagnosis. Also folds in the silently-out-of-scope §6 secondary defect (stale `InputDispatcher.room` after silent_reauth) which the gap-closure addendum scopes INTO 06-15.

  Output: one Markdown artifact (`06-12-DEBUG-FINDINGS.md`) in the phase directory. Per gap-closure addendum line 361 — `[doc->REQ-CDOC-*]` (the doc itself) + `[impl/int->REQ-CLI-09]` is delivered downstream by 06-15.

  Important: this is a doc-only plan with one task. Per planner anti-pattern guidance, normally we'd combine; here the doc IS the single concern and ~10-15% context cost is appropriate as a wave-9 dependency lever (06-15 cannot start without it).
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/debug/reconnect-blank-render.md
  @apps/client/src/scenes/BootScene.ts
  @apps/client/src/scenes/LoginScene.ts
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/auth/client.ts
  @apps/client/src/net/colyseus-client.ts

  <interfaces>
  <!-- The global debug doc IS the input. The phase-local doc is the curated -->
  <!-- output. The interface contract is the section list below — 06-15's      -->
  <!-- executor will read these sections by header name.                       -->

  Required sections in 06-12-DEBUG-FINDINGS.md (in this order):
    §0  Source + status — 1-line citation of .planning/debug/reconnect-blank-render.md, status: root_caused, no source files modified
    §1  Symptom — 4-line summary (cookie auto-login + WS-kill→reload paths both render blank canvas)
    §2  Root cause — sessionToken dropped through Boot→Login fast-path→Game; GameScene early-return at !sessionToken; verbatim line numbers
    §3  Affected files table — 6 rows (BootScene 78-95, LoginScene 80-116 + 106-109, GameScene 191-192 + 238-252, auth/client.ts 84-108, colyseus-client.ts 82-99, RebnoRoom.ts 267-269 read-only)
    §4  Primary fix shape — Option A (3 edits, preferred) + Option B (defence-in-depth) — 06-15's executor picks one; both should ship
    §5  Secondary defect (in scope for 06-15 per CONTEXT addendum) — stale inputDispatcher.room after silent_reauth; fix sketch + unit-test sketch
    §6  Required regression tests — 4-item checklist:
          a) Unit (GameScene): scene.init({ username }) without sessionToken → getSession returns token → joinRebnoRoom IS called
          b) Unit (LoginScene): fast-path forwards sessionToken into scene.start('GameScene', ...)
          c) e2e (cookie auto-login): login → reload → [data-game-ready=true] re-asserts within 5s + chat line renderable
          d) e2e (WS kill within grace): login → __rebno.room.connection.close() → SDK auto-reconnects → [data-game-ready=true] still truthy + chat round-trip
    §7  Out of scope (documented for clarity) — server bearer-vs-cookie posture is correct; pure client-side data flow bug
    §8  References — list of file paths and the global debug doc

  Tagging contract:
    Top of file: [doc->REQ-CDOC-04] (or whichever CDOC ID covers reconnect-flow doc — fall back to a CLI-09 doc tag if no CDOC ID matches; the markdown can carry both safely)
    AND [doc->REQ-CLI-09] [doc->REQ-CLI-04] for downstream traceability
  </interfaces>
  </context>

  <tasks>

  <task type="auto">
    <name>Task 1: Write 06-12-DEBUG-FINDINGS.md per the section contract</name>
    <files>
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md
    </files>
    <read_first>
      .planning/debug/reconnect-blank-render.md (full file — this is the source-of-truth being consolidated; copy line-numbered evidence verbatim where it improves precision),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md (Gap-Closure Addendum lines 307+, especially D-25 + plan-tagging guidance line 361 noting the secondary defect is in scope for 06-15),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md (Test 1 + Test 5 + Findings #2 — operator's exact reproduction steps)
    </read_first>
    <action>
  Create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md` with the following structure. Tag at top:

  ```md
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 12
  type: doc-only
  status: complete
  input_for: 06-15-PLAN.md
  source: .planning/debug/reconnect-blank-render.md
  ---

  # Reconnect / Cookie-Auto-Login Blank GameScene — Consolidated Findings (input for 06-15)

  [doc->REQ-CLI-09] [doc->REQ-CLI-04] [doc->REQ-CDOC-04]

  > **Diagnose-only consolidation.** No source files were edited by this plan. This document is the input contract for plan **06-15** (the fix plan); 06-15 owns code edits, regression tests, and the secondary `InputDispatcher` stale-room defect (folded in per CONTEXT D-25 + addendum line 361).

  ## §0 Source + status

  - Original diagnostic: `.planning/debug/reconnect-blank-render.md` (`status: root_caused`)
  - Operator UAT trigger: `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md` Test 1 + Test 5 + Findings table row #2
  - This plan modifies zero source files.

  ## §1 Symptom

  On either of these reconnect paths the canvas goes blank — only the `#0A0E1A` background, no HUD message history, no remotes, no self avatar:

  1. **Reload the tab** after a fresh login (cookie auto-login fast-path).
  2. **Kill the WS** in DevTools (`__rebno.room.connection.close()`) and let the SDK either auto-resume within grace or fall through to silent reauth. Past-grace branch: the disconnect banner click reloads the tab → same cookie auto-login path as #1.

  Specifically: `data-game-ready` never set; `window.rebno.remotePlayers === []`; no WebSocket in DevTools Network panel; chat hint mounts but fades after 5 s.

  ## §2 Root cause

  **The cookie auto-login path drops the Better-Auth `session_token` between BootScene → LoginScene (fast-path) → GameScene.** The server's WS `onAuth` handler requires `session_token` as a `Bearer` header, so `GameScene.connect()` short-circuits and no Colyseus room is ever joined.

  Verbatim evidence (file paths + line numbers — do not re-derive):

  1. `apps/client/src/scenes/GameScene.ts:191-192` — `if (!this.sessionToken) return;` (silent early-return)
  2. `apps/server/src/RebnoRoom.ts:267-269` — server requires `Authorization: Bearer ${session_token}`; cookie WS auth is NOT wired
  3. `apps/client/src/scenes/BootScene.ts:78-95` — fetches `getSession()` (which returns `{ user, token }`) but only forwards `username` to LoginScene fast-path init data
  4. `apps/client/src/scenes/LoginScene.ts:106-109` — fast-path timer: `this.scene.start('GameScene', { username })` — no sessionToken
  5. `apps/client/src/scenes/LoginScene.ts:173-177` — form-submit path DOES pass `result.session_token` (the working path; reference for the fix shape)
  6. `apps/client/src/auth/client.ts:84-108` — `getSession()` already returns `SessionShape.token`; the data is reachable, just not forwarded
  7. `apps/client/src/net/colyseus-client.ts:82-99` — sessionStorage reconnection-token cache is unreachable because connect() returns early before joinRebnoRoom()

  Why the secondary symptoms vanish too (per debug doc):
  - No remotes: `bindHandlers` never fires → `players.onAdd` never registers → no `playerRenderer.addRemote(...)` calls
  - No self avatar: same chain — `onLocalJoin` (the `players.onAdd` callback for local sessionId) never runs
  - No room background: `onRoomLayout` (the `s2c.room_layout` handler) is bound inside `bindHandlers` and never reached
  - HUD chat hint disappears: ChatHUD mounts before connect() early-return so the hint appears, then fades to opacity 0 after 5 s (ChatHUD.ts:127-129)

  ## §3 Affected files

  | File | Lines | Role in the bug | Edited by 06-15? |
  |------|-------|-----------------|------------------|
  | `apps/client/src/scenes/BootScene.ts` | 78-95 | Drops `session.token`; only forwards `username` | YES |
  | `apps/client/src/scenes/LoginScene.ts` | 80-116, esp 106-109 | Fast-path passes `{ username }` only to GameScene | YES |
  | `apps/client/src/scenes/GameScene.ts` | 191-192 | Early-return on missing `sessionToken` | YES (Option A) or KEEP + Option B self-heal |
  | `apps/client/src/scenes/GameScene.ts` | 238-252 | `if (!this.inputDispatcher)` keeps stale `room` after silent_reauth (§5 secondary defect) | YES (per CONTEXT addendum line 361) |
  | `apps/client/src/auth/client.ts` | 84-108 | `getSession()` already returns `token` — fix consumes directly | NO (read-only) |
  | `apps/client/src/net/colyseus-client.ts` | 82-99 | Cached-reconnect path is reachable without bearer for within-grace branch (informational; fix is upstream) | NO |
  | `apps/server/src/RebnoRoom.ts` | 267-269 | Server contract: WS `onAuth` is bearer-only, no cookie fallback | NO — DO NOT change |

  ## §4 Primary fix shape

  ### Option A (preferred — minimal surface, three edits)

  1. `BootScene.create` (line 88-95): forward `session.token` into `LoginSceneInitData` alongside `username`.
  2. `LoginScene.init` / `LoginSceneInitData` shape (line 26-51): accept `sessionToken?: string`; store on the instance.
  3. `LoginScene.renderFastPath` (line 106-109): pass `{ sessionToken, username }` to `scene.start('GameScene', …)`.

  Keep the existing `GameScene.connect` early-return as a defensive check (still covers the genuinely unauthenticated case) but ensure the cookie path satisfies it.

  ### Option B (defence in depth — combine with A, not instead of)

  In `GameScene.create`, if `this.sessionToken` is undefined, call `getSession()` and adopt `session.token`. Self-heal against future scene-init shape changes. **06-15 should ship BOTH** — they're complementary:
  - A fixes the immediate path
  - B prevents recurrence if anyone refactors scene-init wiring later

  ## §5 Secondary defect (in scope for 06-15 per CONTEXT addendum line 361)

  In the past-grace silent-reauth branch (`GameScene.connect` runs a second time inside the same Phaser scene), the existing `InputDispatcher` keeps a stale `room` reference:

  `apps/client/src/scenes/GameScene.ts:238-252`
  ```ts
  if (!this.inputDispatcher) {
    ...
    this.inputDispatcher = new InputDispatcher(this.room, stubPrediction);
    ...
  }
  ```

  The `if (!this.inputDispatcher)` guard means the dispatcher is created once and never re-pointed at the new `this.room` after a silent reauth. Any movement intent dispatched after reauth will be `room.send`-ed through the closed/garbage-collected old room object → silent movement freeze.

  **Fix sketch (06-15 owns):**

  ```ts
  if (!this.inputDispatcher) {
    this.inputDispatcher = new InputDispatcher(this.room, stubPrediction);
  } else {
    // Refresh room ref — silent_reauth produced a new room object; old one is closed.
    this.inputDispatcher.setRoom(this.room);
  }
  ```

  (Or expose a `room` setter on InputDispatcher if not already there.)

  **Unit test sketch (06-15 owns):**
  ```ts
  // after silent_reauth cycle
  expect(dispatcher.room).toBe(gameScene.room);
  ```

  ## §6 Required regression tests for 06-15

  1. **Unit** (`apps/client/src/__test__/game-scene.test.ts`): add a case where `scene.init({ username: 'me' })` (no `sessionToken`) is followed by `getSession()` returning a token; assert `joinRebnoRoom` IS called with the token. Covers Option B.
  2. **Unit** (`apps/client/src/__test__/login-scene.test.ts`): assert that `LoginScene` fast-path forwards `sessionToken` into `scene.start('GameScene', ...)`. Covers Option A end-to-end at the LoginScene→GameScene boundary.
  3. **e2e** (`apps/client/test/e2e/cli-08.e2e.test.ts` family — add to existing file or create `cookie-reload.e2e.test.ts`):
     - log in with the form;
     - assert `[data-game-ready="true"]` on the canvas;
     - reload the page;
     - re-assert `[data-game-ready="true"]` within 5 s of reload;
     - assert at least one `[data-chat-line]` is renderable (send a message after reload).
  4. **e2e** (kill-WS path — same file or sibling):
     - log in;
     - call `__rebno.room.connection.close()` from `page.evaluate`;
     - wait for the SDK to auto-reconnect (within grace);
     - assert `[data-game-ready="true"]` is STILL truthy and a follow-up chat send round-trips.
  5. **Unit** (covers §5 secondary): after silent_reauth cycle, assert `dispatcher.room === gameScene.room`.

  (Tests #1-4 close Finding #2; test #5 closes the secondary defect bundled into 06-15.)

  ## §7 Out of scope (documented for clarity)

  - Server-side bearer-vs-cookie posture: **correct as-is**. The bug is purely client-side data flow.
  - Future cookie-WS-auth refactor: deferred to a Phase 7 retro if the bearer-only contract proves brittle. Phase 6 ships with bearer-only.
  - Reconnect-banner UX timing: graded by UAT Test 5 separately; not in this finding's scope.

  ## §8 References

  - `apps/client/src/scenes/BootScene.ts`
  - `apps/client/src/scenes/LoginScene.ts`
  - `apps/client/src/scenes/GameScene.ts`
  - `apps/client/src/auth/client.ts`
  - `apps/client/src/net/colyseus-client.ts`
  - `apps/client/src/net/reconnect.ts`
  - `apps/client/src/ui/ChatHUD.ts`
  - `apps/client/src/ui/reconnect-banner.ts`
  - `apps/client/index.html`
  - `apps/server/src/RebnoRoom.ts` (read-only; do not edit)
  - `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md` (Finding #2 source)
  - `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md` (D-25 sequencing + addendum line 361 secondary-defect inclusion)
  - `.planning/debug/reconnect-blank-render.md` (full diagnostic trail; this doc is its phase-local consolidation)
  ```

  Confirm the file is complete and exits with §8 References. No source-file edits.
    </action>
    <verify>
      <automated>node -e "const fs=require('fs'); const p='.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md'; const t=fs.readFileSync(p,'utf-8'); const required=['§0 Source','§1 Symptom','§2 Root cause','§3 Affected files','§4 Primary fix shape','§5 Secondary defect','§6 Required regression tests','§7 Out of scope','§8 References','session_token','InputDispatcher','Option A','Option B','06-15']; for (const s of required) { if (!t.includes(s)) { console.error('MISSING:', s); process.exit(1); } } console.log('all sections present');"</automated>
    </verify>
    <done>
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md exists with §0-§8 sections, root cause + Option A/B fix shape + secondary defect + 5-item test checklist + references. Tagged [doc->REQ-CLI-09] [doc->REQ-CLI-04] [doc->REQ-CDOC-04]. Zero source files modified. Plan 06-15 has its input contract.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Phase-local doc → 06-15's executor | If this doc misrepresents the root cause, 06-15 ships a wrong fix; mitigated by direct citation of line numbers from the global debug doc |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-12-01 | Repudiation | Doc gets edited later, drifts from the truth captured at /gsd-debug time | mitigate | §0 cites the global debug doc + UAT — both immutable per planning-state model; if drift, 06-15 reconciles |
  | T-06-12-02 | Tampering | Doc instructs 06-15 to edit server bearer logic | mitigate | §3 + §4 + §7 explicitly mark `apps/server/src/RebnoRoom.ts` as read-only; instructions are bounded |
  </threat_model>

  <verification>
  - File exists at the canonical phase-local path
  - All required §0-§8 sections present (verified by inline node script)
  - No source-file modifications in apps/, packages/, tools/ (this plan is doc-only — `git status` shows only the new .md file)
  - `pnpm trace:check` reports the new doc tags satisfied for REQ-CLI-09 and REQ-CLI-04 doc-stage coverage
  </verification>

  <success_criteria>
  - 06-15-PLAN.md (next wave) can name `06-12-DEBUG-FINDINGS.md` in its `<context>` block and the executor has self-contained instructions
  - Secondary defect (stale `InputDispatcher.room` after silent_reauth) is documented + bundled into 06-15's scope per CONTEXT addendum line 361
  - Four canonical regression tests are pre-specified — 06-15's executor doesn't have to invent them
  - Tags: `[doc->REQ-CLI-09] [doc->REQ-CLI-04] [doc->REQ-CDOC-04]`
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-SUMMARY.md` recording:
  (a) Confirmation the file shipped at the canonical path
  (b) Any clarifications added beyond the global debug doc (e.g. fold-in language for the secondary defect)
  (c) Pointer that 06-15 is the consumer; this plan completes when the doc exists
  (d) Trace tag verification snippet (paste `pnpm trace:check` output for REQ-CLI-09 + REQ-CLI-04)
  </output>
  </content>
  </invoke>```

  ### Plan 06-13
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 13
  type: execute
  wave: 9
  depends_on: []
  files_modified:
    - packages/game-logic/src/constants.ts
    - packages/game-logic/src/step.ts
    - packages/game-logic/test/movement-constants.test.ts
    - packages/game-logic/test/step-bno-fidelity.test.ts
    - apps/client/src/prediction/predictor.ts
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md
  autonomous: true
  requirements: [CLI-04]
  must_haves:
    truths:
      - "Movement numeric constants in packages/game-logic/src/constants.ts are derived from extracted Client 5-8 GML — NOT invented arbitrarily — and every constant carries a comment citing its source GML file + line"
      - "06-13-MOVEMENT-DERIVATION.md records the derivation trail: which GML files were read, what each numeric value was originally, how it maps onto packages/game-logic units (px/tick, sub-pixel accumulator, tick rate)"
      - "Unit tests pin every derived constant — any future tweak that changes walk speed, friction, key-buffering, sub-pixel accumulator behavior, or tick-rate assumption must update the constant AND the test in the same commit (regression guard against silent drift)"
      - "step() in packages/game-logic/src/step.ts consumes the constants from constants.ts (not inline magic numbers); deterministic output verified by snapshot test against a known-good input/output trace from BNO-faithful expected values"
      - "PredictionEngine in apps/client/src/prediction/predictor.ts uses the same constants (no client-side override); the lint-prevented import of arbitrary numbers is preserved"
      - "CLAUDE.md hard rule #4/#5/#6 honored: extract → document → rewrite. Numbers come from extracted GML (already done in Phase 1), document the derivation (this plan's MOVEMENT-DERIVATION.md), then code consumes (constants.ts + step.ts)"
      - "Player object naming gotcha honored: the canonical mover is `extracted/client-5-8/objects/0000-server/` events (NOT 0042-player or any navi* script — addendum line 387)"
    artifacts:
      - path: packages/game-logic/src/constants.ts
        provides: "Movement constants derived from extracted GML — walk_speed_px_per_tick, key_buffer_window_ms, friction_decel_px_per_tick_sq, accel_px_per_tick_sq, sub_pixel_accumulator_mode, tick_rate_hz; each carries a comment citing source GML file + line"
        contains: "WALK_SPEED"
      - path: packages/game-logic/src/step.ts
        provides: "Pure step(state, inputs, dt) consuming constants.ts; preserves Phase 4 D-20 purity (no Date.now, no Math.random, no I/O)"
        contains: "WALK_SPEED"
      - path: packages/game-logic/test/movement-constants.test.ts
        provides: "Pinning tests — each constant has a test asserting its exact derived value with a comment citing the GML source line; any drift fails the test"
        contains: "WALK_SPEED"
      - path: packages/game-logic/test/step-bno-fidelity.test.ts
        provides: "Snapshot tests — known input vectors (e.g. 'press right for 60 ticks at 30hz') produce known position outputs matching BNO behavior; deterministic"
        contains: "fidelity"
      - path: apps/client/src/prediction/predictor.ts
        provides: "PredictionEngine consumes packages/game-logic step() with no override — no inline magic numbers"
        contains: "step"
      - path: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md
        provides: "Doc-only: derivation trail mapping every GML line to the constant in constants.ts; auditable evidence the numbers are not invented"
        contains: "0000-server"
    key_links:
      - from: packages/game-logic/src/constants.ts
        to: extracted/client-5-8/objects/0000-server/events/Step.gml + Keyboard-37/38/39/40.gml
        via: "every export carries a // SOURCE: comment citing the extracted GML file + line; lint can later assert (out of scope here)"
        pattern: "SOURCE:.*extracted/client-5-8"
      - from: packages/game-logic/src/step.ts
        to: packages/game-logic/src/constants.ts
        via: "import { WALK_SPEED, ACCEL, FRICTION, KEY_BUFFER_WINDOW_MS, TICK_RATE_HZ } from './constants'"
        pattern: "from './constants'"
      - from: apps/client/src/prediction/predictor.ts
        to: packages/game-logic/src/step.ts
        via: "PredictionEngine wraps step(state, inputs, dt) per Phase 6 D-11 extrapolation pattern; no override constants"
        pattern: "step|PredictionEngine"
      - from: packages/game-logic/test/movement-constants.test.ts
        to: packages/game-logic/src/constants.ts
        via: "import + assert each export against the value in MOVEMENT-DERIVATION.md"
        pattern: "expect.*WALK_SPEED|expect.*ACCEL"
  ---

  <objective>
  Close UAT Finding #3a (D-26 Plan A): movement does not match BNO because the prediction loop uses arbitrary client-invented constants instead of values derived from the original Client 5-8 GML. Read the canonical mover events on the original player-character object (`extracted/client-5-8/objects/0000-server/` — NOT 0042-player or navi* scripts; see CONTEXT addendum line 387), reverse the numeric constants (walk speed, key-buffering window, friction/accel if any, sub-pixel accumulator behavior, tick rate), pin them into `packages/game-logic/src/constants.ts`, update `step()` and `PredictionEngine` to consume the constants, write pinning unit tests + a BNO-fidelity snapshot test, and document the derivation trail in `06-13-MOVEMENT-DERIVATION.md`.

  Purpose: per CONTEXT D-26 Plan A — this plan is the numeric foundation. Plan B (06-16) will build the 8-direction sprite-state machine on top of these constants and on top of the asset atlases from 06-14. No asset dependency in this plan; runs in parallel with the other Wave-9 plan (06-12). CLAUDE.md hard rule #4/#5/#6 (extract → document → rewrite) is the governing principle.

  Output: a single canonical constants module + matching tests + derivation doc + minimal step() and PredictionEngine wiring updates. Per gap-closure addendum line 362 — `[impl/unit->REQ-CLI-04]` (movement prediction; numerics).
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-RESEARCH.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-PATTERNS.md
  @packages/game-logic/src/step.ts
  @apps/client/src/prediction/predictor.ts
  @apps/server/src/RebnoRoom.ts
  @decomp/wiki/15-extraction-pipeline.md
  @extracted/client-5-8/objects/0000-server/events/Step.gml
  @extracted/client-5-8/objects/0000-server/events/Step-1.gml
  @extracted/client-5-8/objects/0000-server/events/Step-2.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-37.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-38.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-39.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-40.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-37.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-38.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-39.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-40.gml
  @extracted/client-5-8/objects/0000-server/events/Create.gml
  @extracted/client-5-8/scripts/0285-player_update.gml
  @extracted/client-5-8/scripts/0347-iv_keyactions.gml
  @extracted/client-5-8/scripts/0355-pcode_mover.gml

  <interfaces>
  <!-- Existing interfaces — read each before editing. -->

  packages/game-logic/src/step.ts (Phase 4 D-20 — pure):
    export function step(state, inputs, dt) → state
    - Lint-enforced: no Date.now, no Math.random, no I/O
    - Currently consumes inline magic numbers (THIS plan extracts them to constants.ts)

  apps/client/src/prediction/predictor.ts (Phase 6 plan 06-XX):
    Wraps step() for client-side prediction; reconciler.ts handles divergence
    Currently re-derives some constants client-side (THIS plan removes that override)

  apps/server/src/RebnoRoom.ts (Phase 4 + Phase 6 D-09):
    tickLoop calls step() server-side — automatically picks up new constants
    TICK_RATE_HZ may be referenced here too (Phase 4 D-22 set it; verify the value matches what BNO uses, if not, this plan adjusts)

  GML reading guide (HARD RULES — read first):
    - 0000-server is the player character object in BNO (PROJECT.md gotcha — NOT 0042-player)
    - Step.gml / Step-1.gml / Step-2.gml — per-tick movement application
    - Keyboard-37/38/39/40 — held-key handlers (37=left, 38=up, 39=right, 40=down — Win VK codes)
    - KeyRelease-* — key-up handlers; reveal whether motion stops instantly (no friction) or decelerates
    - Create.gml — initial state; reveals starting speed/accel/friction values
    - Other-*.gml — special cases (room enter, etc.) — usually skip unless they touch motion vars
    - 0285-player_update.gml + 0347-iv_keyactions.gml + 0355-pcode_mover.gml — script-level callbacks invoked from the object events; read after the object events

  Numeric constants to derive (planner has named these; executor confirms exact values from GML):
    WALK_SPEED_PX_PER_TICK            — speed magnitude when a direction key is held (most likely "spd" or "sp" var in GML)
    ACCEL_PX_PER_TICK_SQ              — only if Step.gml shows acceleration (e.g. spd += accel until cap); else 0 (BNO is likely instant-stop, no accel)
    FRICTION_PX_PER_TICK_SQ           — only if KeyRelease shows decay; else 0 (instant stop on release)
    KEY_BUFFER_WINDOW_MS              — only if Keyboard-* shows key-pressed-recently grace; else 0
    SUB_PIXEL_ACCUMULATOR_MODE        — 'none' | 'snap-floor' | 'snap-round' | 'fractional' (look for `x = floor(x + ...)` or `x += ...` patterns; GM5 may store integer vs real)
    TICK_RATE_HZ                      — `room_speed` global in Create.gml or first room init (commonly 30 or 60 in GM5; BNO is probably 30)
    STARTING_DIRECTION                — direction at spawn (likely DOWN for BNO)

  If a GML construct doesn't translate cleanly (e.g. GM5 uses `motion_set(direction, speed)` builtins), document the equivalent in the derivation doc and pick the closest TS-numeric model.

  Lint guard (deferred to a future plan, NOT in scope here): a future lint could enforce that every export in constants.ts carries a SOURCE: comment. Plan now by writing the comments; the lint can pin later.

  Tagging contract:
    packages/game-logic/src/constants.ts → // [impl->REQ-CLI-04]
    packages/game-logic/src/step.ts (header)→ // [impl->REQ-CLI-04]
    packages/game-logic/test/*.test.ts → // [unit->REQ-CLI-04]
    apps/client/src/prediction/predictor.ts (header) → // [impl->REQ-CLI-04]
    06-13-MOVEMENT-DERIVATION.md → [doc->REQ-CLI-04]
  </interfaces>
  </context>

  <tasks>

  <task type="auto">
    <name>Task 1: Reverse-engineer movement constants from extracted GML; document derivation</name>
    <files>
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md,
      packages/game-logic/src/constants.ts
    </files>
    <read_first>
      extracted/client-5-8/objects/0000-server/events/Create.gml (look for: spd, sp, hsp, vsp, friction, accel, room_speed; whatever variables are initialized HERE govern the rest),
      extracted/client-5-8/objects/0000-server/events/Step.gml + Step-1.gml + Step-2.gml (per-tick movement application — confirm what hsp/vsp do per tick),
      extracted/client-5-8/objects/0000-server/events/Keyboard-37.gml + 38.gml + 39.gml + 40.gml (held-key handlers — what speed do they set? do they set vector or speed+direction?),
      extracted/client-5-8/objects/0000-server/events/KeyRelease-37.gml + 38.gml + 39.gml + 40.gml (key-up — instant stop or friction-decay?),
      extracted/client-5-8/scripts/0285-player_update.gml + 0347-iv_keyactions.gml + 0355-pcode_mover.gml (cross-reference — does the player object call these scripts? what do they do?),
      extracted/client-5-8/objects/0000-server/meta.json (object metadata — sprite refs, depth, persistence; informs derivation context),
      extracted/client-5-8/rooms/0058-BNCentral/meta.json (look for room_speed if not in object Create; rooms can override)
    </read_first>
    <action>
  **1. Read all the GML files above.** Note observations as you go. Common GM5 patterns:

  | GML pattern | Meaning |
  |-------------|---------|
  | `room_speed = 30;` (in room init or game setup) | TICK_RATE_HZ = 30 |
  | `spd = 4;` or `sp = 4;` | WALK_SPEED_PX_PER_TICK = 4 (verify the unit; some games store px/sec) |
  | `motion_set(0, 4);` | direction-aware motion: 0 = right, 90 = up, 180 = left, 270 = down; speed 4 |
  | `hsp = 4; vsp = 0;` | direct h/v vector |
  | `x += hsp; y += vsp;` (in Step) | applied per tick — px/tick |
  | `if (keyboard_check(vk_right)) hsp = 4;` | instant-set; no accel |
  | `if (keyboard_check(vk_right)) hsp += 0.5;` | accelerating; FRICTION + ACCEL apply |
  | `hsp *= 0.9;` (in KeyRelease or Step when no key) | friction = 0.1 per tick (decay factor 0.9) |
  | `hsp = 0;` (in KeyRelease) | instant stop, no friction |
  | `x = round(x + hsp);` or `x += hsp; x = floor(x);` | sub-pixel: snap-round / snap-floor |
  | `x += hsp;` (no rounding, x is real) | sub-pixel: fractional |

  **2. Write `06-13-MOVEMENT-DERIVATION.md`** documenting the derivation:

  ```md
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 13
  type: doc-only-derivation
  status: complete
  ---

  # Movement Constants — Derivation from Extracted Client 5-8 GML

  [doc->REQ-CLI-04]

  > Per CLAUDE.md hard rule #4/#5/#6: extract → document → rewrite. Phase 1 produced `extracted/client-5-8/`; this document maps each numeric constant in `packages/game-logic/src/constants.ts` to its source GML line, satisfying the "document" stage.

  ## Source GML (read by this plan)

  | File | Role in derivation |
  |------|--------------------|
  | `extracted/client-5-8/objects/0000-server/events/Create.gml` | Initial values: spd / sp / hsp / vsp / friction / accel / room_speed if set here |
  | `extracted/client-5-8/objects/0000-server/events/Step.gml` (+ Step-1 + Step-2) | Per-tick application: how x/y advance, sub-pixel handling |
  | `extracted/client-5-8/objects/0000-server/events/Keyboard-37/38/39/40.gml` | Held-key direction handlers (Win VK 37-40 = L/U/R/D) |
  | `extracted/client-5-8/objects/0000-server/events/KeyRelease-37/38/39/40.gml` | Key-up: instant stop vs friction decay |
  | `extracted/client-5-8/scripts/0285-player_update.gml` | Script invoked from object events (cross-reference) |
  | `extracted/client-5-8/scripts/0347-iv_keyactions.gml` | Input-vector mapping (cross-reference) |
  | `extracted/client-5-8/scripts/0355-pcode_mover.gml` | Mover pcode (cross-reference) |
  | `extracted/client-5-8/rooms/0058-BNCentral/meta.json` | room_speed override if present |

  ## Derivation table

  | Constant | Value | Source | Notes |
  |----------|-------|--------|-------|
  | `TICK_RATE_HZ` | _<derived>_ | _<file>:<line>_ | _<verbatim GML quote>_ |
  | `WALK_SPEED_PX_PER_TICK` | _<derived>_ | _<file>:<line>_ | _<verbatim>_ |
  | `ACCEL_PX_PER_TICK_SQ` | _<derived or 0>_ | _<file>:<line>_ or "absent — instant-set per Keyboard-* events" | |
  | `FRICTION_PX_PER_TICK_SQ` | _<derived or 0>_ | _<file>:<line>_ or "absent — instant-stop per KeyRelease-* events" | |
  | `KEY_BUFFER_WINDOW_MS` | _<derived or 0>_ | _<file>:<line>_ or "absent — direct keyboard_check, no buffering" | |
  | `SUB_PIXEL_ACCUMULATOR_MODE` | _'fractional' | 'snap-floor' | 'snap-round' | 'none'_ | _<file>:<line>_ | _<verbatim quote of x/y assignment line>_ |
  | `STARTING_DIRECTION` | _<derived; likely 270 = DOWN>_ | _<Create.gml line>_ | |
  | `STARTING_X`, `STARTING_Y` | _<derived if present in Create>_ | _<Create.gml line>_ | (informational; server-side D-07 home-portal spawn overrides) |

  ## GML constructs that don't translate directly

  _(For each: brief note on the GM5 builtin used and the chosen TS equivalent.)_

  - `motion_set(dir, speed)` → equivalent: hsp = cos(dir°) * speed; vsp = -sin(dir°) * speed (TS step() applies)
  - _<other constructs encountered>_

  ## Decisions made by this plan

  _(Document any judgement calls — e.g. "GML uses `keyboard_check_pressed` for buffering at line N; we map this to KEY_BUFFER_WINDOW_MS = X")_

  ## Open questions for 06-16 (sprite-state plan)

  The 8-direction facing-on-input semantics will read additional events:
  - Draw.gml — sprite selection per facing direction
  - Step events again — facing transitions

  Out of scope for this numeric-only plan but flagged so 06-16 doesn't have to rediscover the source files.
  ```

  **Fill in the table from your GML reading. Use exact verbatim quotes.** If a constant is "absent" (e.g. no friction in BNO), say so explicitly with the negative-evidence quote.

  **3. Write `packages/game-logic/src/constants.ts`** — Tag at top:

  ```ts
  // [impl->REQ-CLI-04]
  // Movement constants derived from extracted Client 5-8 GML.
  // See .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md
  // Every export here carries a SOURCE: comment citing the extracted GML file + line.
  // Pure data module — no Date.now, no Math.random, no I/O (Phase 4 D-20 lint applies).

  /** Tick rate. SOURCE: extracted/client-5-8/objects/0000-server/events/Create.gml:<line> ("room_speed = N;") */
  export const TICK_RATE_HZ = <DERIVED>;

  /** Walk speed in pixels per tick when a direction key is held.
   *  SOURCE: extracted/client-5-8/objects/0000-server/events/Keyboard-39.gml:<line> ("hsp = N;" or "spd = N;") */
  export const WALK_SPEED_PX_PER_TICK = <DERIVED>;

  /** Acceleration in px/tick² (0 if BNO uses instant-set, no acceleration).
   *  SOURCE: <file>:<line> (or "absent — instant-set per Keyboard-* handlers") */
  export const ACCEL_PX_PER_TICK_SQ = <DERIVED>;

  /** Friction (deceleration) in px/tick² (0 if KeyRelease sets hsp = 0 directly).
   *  SOURCE: <file>:<line> (or "absent — instant-stop per KeyRelease-* handlers") */
  export const FRICTION_PX_PER_TICK_SQ = <DERIVED>;

  /** Key-buffering window in ms (0 if BNO uses keyboard_check directly without buffering).
   *  SOURCE: <file>:<line> (or "absent") */
  export const KEY_BUFFER_WINDOW_MS = <DERIVED>;

  /** Sub-pixel accumulator mode.
   *  SOURCE: extracted/client-5-8/objects/0000-server/events/Step.gml:<line> ("x += hsp;" → 'fractional', or "x = floor(x + hsp);" → 'snap-floor') */
  export const SUB_PIXEL_ACCUMULATOR_MODE = <'fractional' | 'snap-floor' | 'snap-round' | 'none'> as const;

  /** Starting facing direction. 270 = DOWN per BNO sprite convention.
   *  SOURCE: extracted/client-5-8/objects/0000-server/events/Create.gml:<line> */
  export const STARTING_DIRECTION_DEGREES = <DERIVED>;
  ```

  (Fill `<DERIVED>` from your GML reading. If a value is uncertain, prefer the most conservative interpretation — instant-set/instant-stop defaults — and document the uncertainty in MOVEMENT-DERIVATION.md.)
    </action>
    <verify>
      <automated>node -e "const fs=require('fs'); const c=fs.readFileSync('packages/game-logic/src/constants.ts','utf-8'); if (c.includes('<DERIVED>')) { console.error('UNRESOLVED: <DERIVED> placeholder left in constants.ts — fill all values from extracted GML before commit'); process.exit(1); } const required=['TICK_RATE_HZ','WALK_SPEED_PX_PER_TICK','ACCEL_PX_PER_TICK_SQ','FRICTION_PX_PER_TICK_SQ','KEY_BUFFER_WINDOW_MS','SUB_PIXEL_ACCUMULATOR_MODE','SOURCE:','extracted/client-5-8']; for (const s of required) { if (!c.includes(s)) { console.error('MISSING in constants.ts:', s); process.exit(1); } } const d=fs.readFileSync('.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md','utf-8'); if (d.includes('<DERIVED>')) { console.error('UNRESOLVED: <DERIVED> placeholder left in derivation doc'); process.exit(1); } for (const s of ['Derivation table','TICK_RATE_HZ','WALK_SPEED_PX_PER_TICK','SUB_PIXEL_ACCUMULATOR_MODE','0000-server']) { if (!d.includes(s)) { console.error('MISSING in derivation doc:', s); process.exit(1); } } console.log('constants + derivation present, no placeholders remain');"</automated>
    </verify>
    <done>
      constants.ts exports 6+ named constants, each with a SOURCE: comment citing extracted GML. 06-13-MOVEMENT-DERIVATION.md records the derivation trail with file+line citations and verbatim GML quotes. Any 'absent' constant is explicitly flagged with negative evidence. No source files outside packages/game-logic/src/constants.ts touched in this task.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Update step() + PredictionEngine to consume constants; pin with unit + fidelity tests</name>
    <files>
      packages/game-logic/src/step.ts,
      packages/game-logic/test/movement-constants.test.ts,
      packages/game-logic/test/step-bno-fidelity.test.ts,
      apps/client/src/prediction/predictor.ts
    </files>
    <read_first>
      packages/game-logic/src/step.ts (current implementation — find the inline magic numbers being replaced),
      packages/game-logic/test/*.test.ts (existing test layout — copy harness shape; vitest or node:test depending on what's already there),
      apps/client/src/prediction/predictor.ts (find any inline constants the client-side overrode; remove and use the canonical ones),
      apps/server/src/RebnoRoom.ts (cross-check: server tickLoop should use step() unmodified — verify TICK_RATE_HZ here matches packages/game-logic constants.ts; if drift, this plan unifies),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md (your own derivation doc — the test expected values come from here)
    </read_first>
    <behavior>
      - movement-constants.test.ts: one test per exported constant in constants.ts; each test asserts the exact derived value with a comment citing the GML source line; test fails if any export's value drifts
      - step-bno-fidelity.test.ts: a small set of golden input → output snapshots — e.g. "from (0, 0), press right for 60 ticks at TICK_RATE_HZ → end position is (60 * WALK_SPEED_PX_PER_TICK, 0)"; "press right for 1 tick then release → end position depends on FRICTION; if FRICTION=0, snap to (WALK_SPEED, 0); if FRICTION>0, decay to 0 over N ticks"; each scenario calls step() repeatedly and asserts the trajectory
      - PredictionEngine in apps/client/src/prediction/predictor.ts no longer overrides any movement constant — fully consumes step() output
      - All tests are deterministic (no Date.now, no Math.random)
    </behavior>
    <action>
  **1. packages/game-logic/src/step.ts** — Tag `// [impl->REQ-CLI-04]` near the export. Replace any inline magic numbers (e.g. `4` for speed, `30` for tick rate, `0.9` for friction factor) with imports from `./constants`:

  ```ts
  // [impl->REQ-CLI-04]
  import {
    WALK_SPEED_PX_PER_TICK,
    ACCEL_PX_PER_TICK_SQ,
    FRICTION_PX_PER_TICK_SQ,
    TICK_RATE_HZ,
    SUB_PIXEL_ACCUMULATOR_MODE,
  } from './constants';

  // ... existing step() signature unchanged ...
  // Replace inline numbers with the imported constants. Sub-pixel handling
  // follows SUB_PIXEL_ACCUMULATOR_MODE per the derivation doc.
  ```

  (The exact replacement depends on the existing step.ts shape. The executor reads it, identifies every magic number used in motion math, and substitutes the named constant. If step() currently does NOT exist or is a stub, implement the minimal correct shape per the derivation: instant-set on key-down, instant-stop on key-release, sub-pixel mode applied per accumulator MODE.)

  **2. packages/game-logic/test/movement-constants.test.ts** — Tag `// [unit->REQ-CLI-04]`.

  ```ts
  // [unit->REQ-CLI-04]
  // Pinning tests for movement constants. Drift = test failure.
  // Source values: see ../src/constants.ts SOURCE: comments + 06-13-MOVEMENT-DERIVATION.md.

  import { describe, it, expect } from 'vitest'; // or node:test if that's the existing harness
  import {
    TICK_RATE_HZ,
    WALK_SPEED_PX_PER_TICK,
    ACCEL_PX_PER_TICK_SQ,
    FRICTION_PX_PER_TICK_SQ,
    KEY_BUFFER_WINDOW_MS,
    SUB_PIXEL_ACCUMULATOR_MODE,
    STARTING_DIRECTION_DEGREES,
  } from '../src/constants';

  describe('movement constants — derived from extracted Client 5-8 GML', () => {
    it('TICK_RATE_HZ pins to BNO room_speed', () => {
      expect(TICK_RATE_HZ).toBe(<DERIVED>); // SOURCE: <file>:<line>
    });
    it('WALK_SPEED_PX_PER_TICK pins to BNO Keyboard-* hsp/spd value', () => {
      expect(WALK_SPEED_PX_PER_TICK).toBe(<DERIVED>);
    });
    it('ACCEL_PX_PER_TICK_SQ pins (0 if instant-set in BNO)', () => {
      expect(ACCEL_PX_PER_TICK_SQ).toBe(<DERIVED>);
    });
    it('FRICTION_PX_PER_TICK_SQ pins (0 if instant-stop in BNO)', () => {
      expect(FRICTION_PX_PER_TICK_SQ).toBe(<DERIVED>);
    });
    it('KEY_BUFFER_WINDOW_MS pins (0 if direct keyboard_check)', () => {
      expect(KEY_BUFFER_WINDOW_MS).toBe(<DERIVED>);
    });
    it('SUB_PIXEL_ACCUMULATOR_MODE pins to Step.gml x/y assignment style', () => {
      expect(SUB_PIXEL_ACCUMULATOR_MODE).toBe(<'fractional' | 'snap-floor' | 'snap-round' | 'none'>);
    });
    it('STARTING_DIRECTION_DEGREES pins (270 = DOWN per BNO convention)', () => {
      expect(STARTING_DIRECTION_DEGREES).toBe(<DERIVED>);
    });
  });
  ```

  **3. packages/game-logic/test/step-bno-fidelity.test.ts** — Tag `// [unit->REQ-CLI-04]`.

  ```ts
  // [unit->REQ-CLI-04]
  // BNO movement fidelity — golden input/output snapshots from derived constants.
  // Each scenario calls step() N times and asserts the deterministic trajectory.

  import { describe, it, expect } from 'vitest';
  import { step } from '../src/step';
  import { WALK_SPEED_PX_PER_TICK, FRICTION_PX_PER_TICK_SQ } from '../src/constants';

  const initState = () => ({ /* match the existing state shape — players: { local: { x: 0, y: 0, ... } } */ });
  const inputsHeldRight = { axes: { x: 1, y: 0 }, /* ... */ };
  const inputsRelease = { axes: { x: 0, y: 0 }, /* ... */ };

  describe('step() fidelity vs BNO constants', () => {
    it('press right for 60 ticks → x advances by 60 * WALK_SPEED_PX_PER_TICK (instant-set, no accel)', () => {
      let s = initState();
      for (let i = 0; i < 60; i++) {
        s = step(s, inputsHeldRight, 1 / TICK_RATE_HZ);
      }
      expect(s.players.local.x).toBe(60 * WALK_SPEED_PX_PER_TICK);
      expect(s.players.local.y).toBe(0);
    });

    it('press right for 1 tick then release → instant-stop OR friction-decay per FRICTION', () => {
      let s = initState();
      s = step(s, inputsHeldRight, 1 / TICK_RATE_HZ);
      const xAfterPress = s.players.local.x;
      s = step(s, inputsRelease, 1 / TICK_RATE_HZ);
      if (FRICTION_PX_PER_TICK_SQ === 0) {
        // Instant stop: x stays at xAfterPress (no further motion)
        expect(s.players.local.x).toBe(xAfterPress);
      } else {
        // Friction-decay: x advances less than another full WALK_SPEED on the next tick
        expect(s.players.local.x).toBeLessThan(xAfterPress + WALK_SPEED_PX_PER_TICK);
        expect(s.players.local.x).toBeGreaterThanOrEqual(xAfterPress);
      }
    });

    it('determinism: same input → same output across 1000 runs', () => {
      let final1 = initState();
      let final2 = initState();
      for (let i = 0; i < 100; i++) {
        final1 = step(final1, inputsHeldRight, 1 / TICK_RATE_HZ);
        final2 = step(final2, inputsHeldRight, 1 / TICK_RATE_HZ);
      }
      expect(final1).toEqual(final2);
    });
  });
  ```

  (Adjust the state shape + step() signature to match the existing module. The TICK_RATE_HZ import is needed if the dt parameter is in seconds.)

  **4. apps/client/src/prediction/predictor.ts** — Tag `// [impl->REQ-CLI-04]` at top. Find any local `const SPEED = 4` or similar magic numbers; replace with imports from `@rebno/game-logic/constants` (or whatever the workspace export path is). If predictor.ts already correctly delegates to step() without overrides, just add the tag and a comment noting "no client-side override; consumes packages/game-logic step() unmodified per Phase 6 D-11."

  **5. Verify server-side too**: read `apps/server/src/RebnoRoom.ts` for any hardcoded `30` (tick rate) or duplicated movement constants. If found, the executor MAY (within this plan's scope) update RebnoRoom.ts to import TICK_RATE_HZ from `@rebno/game-logic/constants` to keep the server in sync. If the server uses a different tick-loop module, document the cross-reference but do NOT block — Phase 4 D-22 already established the server-side rate; this plan's responsibility is the game-logic + client side.
    </action>
    <verify>
      <automated>pnpm --filter @rebno/game-logic test &amp;&amp; pnpm --filter @rebno/client typecheck</automated>
    </verify>
    <done>
      step.ts imports all motion constants from constants.ts (no inline magic numbers in motion math). predictor.ts has no client-side override of WALK_SPEED / FRICTION / ACCEL / TICK_RATE. movement-constants.test.ts pins 7 constants (one test each). step-bno-fidelity.test.ts has 3+ golden trajectory tests including a determinism check. All tests GREEN. RebnoRoom.ts cross-reference verified (either uses constants from @rebno/game-logic or documented why not in the SUMMARY).
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Extracted GML → derived TS constants | If derivation misreads a GML expression (e.g. units px/tick vs px/sec), client + server simulate at wrong speed → players see desync; mitigated by Plan A's pinning tests + 06-17 UAT video evidence |
  | packages/game-logic constants → server step() | Server is authoritative; if server is faster than client, prediction overshoots → reconciliation snaps; if slower, prediction undershoots → reconciliation catches up. D-10 threshold absorbs small drift; large drift means a constants bug |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-13-01 | Tampering | Future PR silently changes WALK_SPEED to "feel better" — diverges from BNO fidelity | mitigate | movement-constants.test.ts pins exact value; SOURCE: comment forces PR author to update derivation doc + GML citation |
  | T-06-13-02 | Information leakage | None — constants are not secrets | accept | n/a |
  | T-06-13-03 | Repudiation | "We don't know where this number came from" | mitigate | SOURCE: comment + MOVEMENT-DERIVATION.md = full trail from GML line → constant → test |
  | T-06-13-04 | DoS | A constant set wrong (e.g. WALK_SPEED = 1000) creates server-side spd that exceeds rate-limit / collision step → bug, not security; caught by step-bno-fidelity tests |
  </threat_model>

  <verification>
  - `pnpm --filter @rebno/game-logic test` exits 0 (movement-constants + step-bno-fidelity all GREEN)
  - `pnpm --filter @rebno/client typecheck` exits 0
  - `pnpm --filter @rebno/client test` exits 0 (no PredictionEngine regressions)
  - `pnpm trace:check` reports `[impl->REQ-CLI-04]` and `[unit->REQ-CLI-04]` tags satisfied for the new files
  - 06-13-MOVEMENT-DERIVATION.md exists, has the derivation table fully populated (no `<DERIVED>` placeholders left)
  - Manual smoke (deferred to 06-17 UAT): two-client move feels closer to BNO. Final lock at 06-17 UAT video.
  </verification>

  <success_criteria>
  - Numeric movement constants derived from extracted Client 5-8 GML (per CLAUDE.md hard rule #4/#5/#6) and pinned in code + tests
  - Derivation trail auditable in 06-13-MOVEMENT-DERIVATION.md
  - step() and PredictionEngine consume constants from a single source (constants.ts) — no client/server drift
  - Plan B (06-16 sprite-state machine) has a stable numeric foundation to build on
  - Tags `[impl/unit->REQ-CLI-04]` (per gap-closure addendum line 362)
  - Player object naming gotcha (0000-server, NOT 0042-player or navi*) honored throughout
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-SUMMARY.md` recording:
  (a) Final values of all 7+ derived constants (paste from constants.ts)
  (b) Any GML-construct-doesn't-translate notes (e.g. motion_set → cos/sin decomposition)
  (c) Any uncertainty flagged in derivation (acknowledge if a value was best-guess from ambiguous GML)
  (d) Cross-reference outcome: did RebnoRoom.ts get updated to import TICK_RATE_HZ from constants.ts, or is the server's existing value verified to match?
  (e) Confirmation 06-16 (sprite-state plan) has a stable numeric foundation
  (f) Trace tag verification snippet for REQ-CLI-04 impl + unit stages
  </output>
  </content>
  </invoke>```

  ### Plan 06-14
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 14
  type: execute
  wave: 10
  depends_on: []
  files_modified:
    - tools/asset-pipeline/src/bootstrap.ts
    - tools/asset-pipeline/cli.ts
    - tools/asset-pipeline/test/bootstrap.test.ts
    - tools/asset-pipeline/src/build.ts
    - tools/room-converter/src/bn-central.ts
    - tools/room-converter/test/bn-central.test.ts
    - apps/server/rooms/bn-central/000.json
    - apps/server/rooms/bn-central/000.sig
    - apps/server/src/RebnoRoom.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/assets/room-renderer.ts
    - apps/client/src/assets/atlas-loader.ts
    - apps/client/src/render/PlayerRenderer.ts
    - apps/client/test/e2e/cli-08.e2e.test.ts
    - assets/source/sprites/.gitkeep
    - package.json
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md
  autonomous: true
  requirements: [AST-01, CLI-07]
  must_haves:
    truths:
      - "tools/asset-pipeline ships a `bootstrap` mode that reads extracted/client-5-8/sprites/<id>/frames/img_*.bmp + meta.json and emits assets/source/sprites/<id>.{png,json} (Aseprite-compatible PNG strip + JSON sidecar)"
      - "All 15 sprite IDs from CONTEXT addendum line 336 (0000, 0027, 0029-0033, 0036-0043) are bootstrapped → committed Aseprite-compatible source files; tile/door sprites referenced by 0058-BNCentral/tiles.json are also bootstrapped"
      - "tools/asset-pipeline `build` packs the bootstrapped sprites into atlas-mvp.{png,json} via existing 06-08 build mode (extending, not rebuilding)"
      - "tools/room-converter has a bn-central converter that reads extracted/client-5-8/rooms/0058-BNCentral/{instances.json,tiles.json,creation-code.gml,backgrounds.json,meta.json} and emits apps/server/rooms/bn-central/000.json conforming to Phase 4 SRV-13 room-layout schema + Ed25519 signed (000.sig) per Phase 4 D-12"
      - "Server room registration in apps/server/src/RebnoRoom.ts replaces 'mvp-lobby' with 'bn-central' (or registers both during transition; final state = bn-central is the canonical room for CLI-08 UAT)"
      - "Client GameScene + RoomRenderer + PlayerRenderer load atlas-mvp.{png,json} and render real BNO sprites (replacing the blue rectangle placeholder); PlayerRenderer.addRemote/ensureLocal use NaviStandD as default frame"
      - "Playwright e2e (06-08's cli-08.e2e.test.ts) updated to assert data-room-id='bn-central' and that a non-blue sprite is rendered (e.g. canvas pixel sample at player position is NOT solid #0000FF / blue rectangle marker)"
      - "Replaces synthetic mvp-lobby — does NOT keep both as production rooms; mvp-lobby may stay as a dev-only / test-only fallback at Claude's discretion but bn-central is the canonical CLI-08 room"
      - "First-pass scope EXCLUDES audio (Phase 7 AST-02), fonts (Phase 7 AST-03), other 9 extracted rooms (Phase 7 PAR-03) — explicitly per addendum line 340"
    artifacts:
      - path: tools/asset-pipeline/src/bootstrap.ts
        provides: "Bootstrap mode entry point — reads extracted sprite frames + meta, emits Aseprite-compatible source files via sharp BMP→PNG strip"
        contains: "bootstrap"
      - path: tools/asset-pipeline/cli.ts
        provides: "CLI dispatcher — adds `bootstrap` subcommand alongside the existing `build`"
        contains: "bootstrap"
      - path: tools/room-converter/src/bn-central.ts
        provides: "BN Central room converter — bridges extracted/client-5-8/rooms/0058-BNCentral/* → apps/server/rooms/bn-central/000.json (SRV-13 schema)"
        contains: "0058-BNCentral"
      - path: apps/server/rooms/bn-central/000.json
        provides: "Signed room-layout JSON (SRV-13 contract) for BN Central — instances + tiles + backgrounds, ready for s2c.room_layout broadcast"
        contains: "bn-central"
      - path: apps/server/rooms/bn-central/000.sig
        provides: "Ed25519 signature over 000.json (Phase 4 D-12 / Phase 5 D-19 ritual)"
        contains: ""
      - path: assets/source/sprites/.gitkeep
        provides: "Marks the directory as committed; bootstrap output (per-sprite .png + .json) goes here per CONTEXT D-14"
        contains: ""
      - path: apps/client/src/render/PlayerRenderer.ts
        provides: "Loads atlas-mvp; renders real NaviStandD sprite for self + remotes (no more blue rectangle); 8-direction sprite SELECTION wiring lives in 06-16, not here — this plan ships StandD as the default-frame baseline"
        contains: "NaviStandD"
      - path: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md
        provides: "Doc-only — lists every sprite/tile bootstrapped + the 0058-BNCentral conversion summary; auditable AST-01 first-pass scope"
        contains: "0027"
    key_links:
      - from: tools/asset-pipeline/src/bootstrap.ts
        to: extracted/client-5-8/sprites/<id>/frames/img_*.bmp + meta.json
        via: "sharp.raw().resize().png() pipeline; reads meta.json for origin + frame_count; emits assets/source/sprites/<id>.{png,json}"
        pattern: "extracted/client-5-8/sprites"
      - from: tools/room-converter/src/bn-central.ts
        to: extracted/client-5-8/rooms/0058-BNCentral/*.json + creation-code.gml
        via: "JSON parse + GML lex (light — extract instance.create_code if needed); maps to SRV-13 layout shape"
        pattern: "0058-BNCentral"
      - from: apps/server/src/RebnoRoom.ts
        to: apps/server/rooms/bn-central/000.json
        via: "broadcastRoomLayout reads bn-central instead of mvp-lobby; room registration matchmaker.defineRoomType('bn-central', ...)"
        pattern: "bn-central"
      - from: apps/client/src/scenes/GameScene.ts
        to: apps/client/src/render/PlayerRenderer.ts + apps/client/src/assets/atlas-loader.ts
        via: "preload atlas-mvp via this.load.atlas('mvp', '/atlas-mvp.png', '/atlas-mvp.json'); PlayerRenderer.ensureLocal/addRemote use atlas frame keys"
        pattern: "atlas-mvp"
      - from: apps/client/test/e2e/cli-08.e2e.test.ts
        to: data-room-id="bn-central" + non-blue-pixel assertion
        via: "expect(canvas.evaluate(canvas.getAttribute('data-room-id'))).toBe('bn-central')"
        pattern: "bn-central"
  ---

  <objective>
  Close UAT Finding #4 (D-27): there are no real BNO assets in the project — GameScene shows blue rectangles + cyan nameplates over an empty black background, and the synthetic `mvp-lobby` room used in 06-07 has no relationship to the original game. Bridge the existing `tools/asset-pipeline/` (06-08 lint + manifest schema; never piped real input) with a `bootstrap` mode that converts extracted Client 5-8 BMP sprite frames + meta into Aseprite-compatible PNG-strip source files; pack via the existing `build` mode into `atlas-mvp.{png,json}`; convert the canonical first-pass room (`extracted/client-5-8/rooms/0058-BNCentral/` — the actual latest online lobby in BNO, NOT the older `0004-Online_Lobby`) into a Phase 4 SRV-13 room-layout JSON signed per Phase 4 D-12; wire server room registration to use `bn-central` (replacing `mvp-lobby`); update GameScene + PlayerRenderer + RoomRenderer to load the atlas and render real sprites + the BN Central room background and tiles; update the 06-08 Playwright e2e to assert `data-room-id="bn-central"` and that the player render is no longer a solid-blue rectangle.

  Purpose: per CONTEXT D-27 — first-pass real assets are the unblock for UAT Test 1 (HiDPI nearest-neighbor — needs real sprites to evaluate pixel correctness), Test 2 (CLI-08 milestone artifact — needs real game presentation), Test 3 (Aseprite round-trip — needs source files committed). Player object naming gotcha (0000-server, NOT 0042-player) and room naming gotcha (0058-BNCentral, NOT 0004-Online_Lobby) are honored. Nametag (D-27a) ships in 06-16 (where the per-player render path lives), NOT here.

  Output: extended asset-pipeline bootstrap mode + bn-central room converter + signed room JSON + server registration update + client atlas loader + PlayerRenderer real-sprite rendering + e2e assertion update + asset manifest doc. Per gap-closure addendum line 363 — `[impl->REQ-AST-01] [impl->REQ-CLI-07]`.

  This is the largest plan in the gap-closure set (3 tasks, multiple subsystems). Splitting further would fragment a single coherent feature ("real assets land end-to-end"); the natural seam is bootstrap+atlas (Task 1) → room conversion + server (Task 2) → client render + e2e (Task 3).
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-RESEARCH.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-PATTERNS.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-08-SUMMARY.md
  @tools/asset-pipeline/cli.ts
  @tools/asset-pipeline/src/build.ts
  @tools/asset-pipeline/src/manifest.ts
  @tools/asset-catalog/cli.ts
  @tools/extract-gmd/cli.ts
  @tools/room-converter/src/index.ts
  @tools/scripts/lint-room-layout.mjs
  @tools/scripts/lint-asset-pipeline.mjs
  @apps/server/src/RebnoRoom.ts
  @apps/server/rooms/mvp-lobby/000.json
  @apps/server/rooms/mvp-lobby/000.sig
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/assets/room-renderer.ts
  @apps/client/src/assets/atlas-loader.ts
  @apps/client/src/render/PlayerRenderer.ts
  @apps/client/test/e2e/cli-08.e2e.test.ts
  @apps/client/test/e2e/fixtures.ts
  @extracted/client-5-8/sprites/0000-NaviStandD/meta.json
  @extracted/client-5-8/sprites/0027-NaviRunD/meta.json
  @extracted/client-5-8/rooms/0058-BNCentral/meta.json
  @extracted/client-5-8/rooms/0058-BNCentral/instances.json
  @extracted/client-5-8/rooms/0058-BNCentral/tiles.json
  @extracted/client-5-8/rooms/0058-BNCentral/backgrounds.json
  @extracted/client-5-8/rooms/0058-BNCentral/creation-code.gml

  <interfaces>
  <!-- Existing tools/asset-pipeline shape (06-08 SUMMARY confirms shipped) -->

  tools/asset-pipeline/cli.ts:
    Subcommands today: `build`, `postprocess` (per 06-09 verify-phase-6.mjs steps 4 + 6)
    Add: `bootstrap`

  tools/asset-pipeline/src/build.ts:
    Reads assets/source/sprites/<id>.{png,json}; packs into atlas-mvp.{png,json}
    Uses sharp + a bin-pack algorithm
    Already shipped (06-08); THIS plan extends inputs (more sprites) but DOES NOT change the build algorithm

  tools/asset-pipeline/src/manifest.ts:
    Emits tools/asset-pipeline/output/pipeline-manifest.json with sha256 + paths
    Already shipped; THIS plan adds entries for the new sprites (manifest is regenerated)

  tools/scripts/lint-asset-pipeline.mjs:
    Validates pipeline-manifest.json schema_version === 1; cross-checks atlas sha256
    Already shipped; THIS plan's bootstrap output flows through it unchanged

  <!-- New: bootstrap mode contract -->

  bootstrap input shape:
    extracted/client-5-8/sprites/<id>/meta.json:
      { "name": "NaviStandD", "frame_w": <int>, "frame_h": <int>, "frame_count": <int>, "origin_x": <int>, "origin_y": <int>, ... }
    extracted/client-5-8/sprites/<id>/frames/img_NNN.bmp (one BMP per frame)

  bootstrap output shape (Aseprite-compatible — D-15):
    assets/source/sprites/<id>.png   (horizontal strip; frame_count frames laid out left-to-right at frame_w × frame_h)
    assets/source/sprites/<id>.json:
      {
        "frames": { "<id>_0": { "frame": { "x": 0, "y": 0, "w": frame_w, "h": frame_h } }, ... },
        "meta": {
          "image": "<id>.png",
          "format": "RGBA8888",
          "size": { "w": frame_count * frame_w, "h": frame_h },
          "scale": "1",
          "frame_w": frame_w, "frame_h": frame_h,
          "frame_count": frame_count,
          "origin": { "x": origin_x, "y": origin_y },
          "sprite_id": "<id>",
          "source": "extracted/client-5-8/sprites/<id>/"
        }
      }

    This shape matches Aseprite's `--format json-array` export (D-15) so devs can edit
    the .png + .json in Aseprite, re-export with the same shape, and re-run `build`
    without re-running `bootstrap`.

  <!-- Sprite IDs to bootstrap (CONTEXT addendum line 336): -->
    Player sprites (16 total):
      0000-NaviStandD, 0027-NaviRunD, 0028-NaviRunR,
      0029-NaviStandR, 0030-NaviStandU, 0031-NaviRunU, 0032-NaviRunL, 0033-NaviStandL,
      0036-NaviStandDR, 0037-NaviRunDR,
      0038-NaviStandUR, 0039-NaviRunUR,
      0040-NaviStandUL, 0041-NaviRunUL,
      0042-NaviStandDL, 0043-NaviRunDL
    Tile sprites:
      Whatever sprite IDs are referenced by 0058-BNCentral/tiles.json
      (read tiles.json — it lists tileset_id → sprite_id; bootstrap each)
      Plus any door/object sprites referenced by 0058-BNCentral/instances.json
      (read instances.json — `object_id` references; cross-look-up in extracted/client-5-8/objects/<id>/meta.json
      for the `sprite_id` field, then bootstrap that sprite)

  <!-- Room layout SRV-13 schema (Phase 4 D-12) -->
  apps/server/rooms/<room>/000.json:
    {
      "room_id": "bn-central",
      "version": 1,
      "width_tiles": <int>, "height_tiles": <int>,
      "tile_w": 44, "tile_h": 40,                           // Phase 4 / Phase 6 standard
      "background": { "sprite_id": "<bg_id>", "tile_x": 0, "tile_y": 0 },
      "tiles": [{ "tileset_sprite_id": "<id>", "x": <px>, "y": <px>, "frame": <int> }, ...],
      "instances": [{ "object_id": "<original_obj_id>", "x": <px>, "y": <px> }, ...],
      "spawn_points": [{ "name": "home_portal", "x": <px>, "y": <px> }, ...]
    }
    (Reference apps/server/rooms/mvp-lobby/000.json for the EXACT shape; mirror it.)

  apps/server/rooms/<room>/000.sig:
    Ed25519 signature over the canonical-JSON-bytes of 000.json
    Use the existing signing helper (Phase 4 D-12 / Phase 5 D-19 ritual)
    Private key extraction is operator-side via `fly ssh`; for local dev there is a dev keypair already
    per 06-08 fixtures and the Phase 5 ROOM_SIGNING_PRIVATE_KEY ritual.

  <!-- Server room registration (apps/server/src/RebnoRoom.ts) -->

  Today: matchmaker.defineRoomType('mvp-lobby', RebnoRoom, { layoutPath: 'apps/server/rooms/mvp-lobby/000.json' })
  After: matchmaker.defineRoomType('bn-central', RebnoRoom, { layoutPath: 'apps/server/rooms/bn-central/000.json' })

  home-portal spawn (Phase 6 D-07): on first-session join, server reads spawn_points.home_portal from the loaded layout
  and uses {x, y}; verify the bn-central room HAS a home_portal spawn point — if 0058-BNCentral/instances.json has a
  specific portal/door object marking the entry, that's it; if not, the planner picks a sensible center-of-room default
  (documented in 06-14-ASSET-MANIFEST.md).

  <!-- Client wiring -->

  apps/client/src/scenes/GameScene.ts (preload):
    this.load.atlas('mvp', '/atlas-mvp.png', '/atlas-mvp.json');
    // Vite dev: paths served from public/; staging/prod: served from apps/server/public/ via D-18 mount

  apps/client/src/render/PlayerRenderer.ts (replace blue rect):
    ensureLocal() / addRemote() build a Phaser.GameObjects.Sprite using atlas frame:
      const sprite = scene.add.sprite(x, y, 'mvp', '0000-NaviStandD_0');
      sprite.setOrigin(origin_x / frame_w, origin_y / frame_h);  // honor extracted origin
    THIS plan ships only the StandD default frame; 06-16 wires 8-direction selection.

  apps/client/src/assets/room-renderer.ts (extend):
    Read the SRV-13 layout JSON; for each tile entry, render via Phaser tilemap or sprite-blit;
    background rendered as a single Phaser.GameObjects.Image at world origin.
    Mirror the existing mvp-lobby render path; replace mvp-lobby paths with bn-central (or
    generalize to read room_id from layout JSON).

  apps/client/src/assets/atlas-loader.ts:
    Already wires Phaser.Textures.FilterMode.NEAREST per Phase 6 D-12; just ensure it sets the
    filter on the new 'mvp' atlas after this.load.atlas resolves.

  <!-- e2e update -->

  apps/client/test/e2e/cli-08.e2e.test.ts:
    Add/update assertions:
      - canvas[data-room-id="bn-central"] (or wherever the attribute lives — set on Phaser canvas root after room layout received)
      - Player render is NOT a solid color rectangle: page.evaluate sample of canvas pixel
        at known player position; assert R/G/B is not (0, 0, 255) (blue marker) and the
        pixel is opaque (alpha = 255). Coarse but sufficient for a non-blue-rect check.
      - Existing chat round-trip + nameplate assertions remain unchanged
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: tools/asset-pipeline bootstrap mode + bootstrap all 15 player sprites + bn-central tile/object sprites; rebuild atlas-mvp</name>
    <files>
      tools/asset-pipeline/src/bootstrap.ts,
      tools/asset-pipeline/cli.ts,
      tools/asset-pipeline/test/bootstrap.test.ts,
      tools/asset-pipeline/src/build.ts,
      assets/source/sprites/.gitkeep,
      package.json
    </files>
    <read_first>
      tools/asset-pipeline/cli.ts (current dispatcher — find existing build / postprocess subcommands; mirror the registration shape for `bootstrap`),
      tools/asset-pipeline/src/build.ts (verify it handles the bootstrap output shape — assets/source/sprites/<id>.{png,json} — without changes),
      tools/asset-pipeline/src/manifest.ts (verify it picks up new sprite entries automatically; if it has a hard-coded list, update it),
      tools/asset-catalog/cli.ts (BMP→PNG sharp pattern reference — exact-mirror analog from PATTERNS lines 80-83),
      tools/extract-gmd/cli.ts (sharp pipeline reference),
      extracted/client-5-8/sprites/0000-NaviStandD/meta.json (confirm exact field names: frame_w / frame_h / origin / frame_count — the bootstrap reader must match these names),
      extracted/client-5-8/sprites/0027-NaviRunD/meta.json (multi-frame example; verify frame_count = 6 from the directory listing),
      extracted/client-5-8/rooms/0058-BNCentral/tiles.json (extract the list of tileset sprite_ids referenced),
      extracted/client-5-8/rooms/0058-BNCentral/instances.json (extract the list of object_ids; cross-reference each in extracted/client-5-8/objects/<id>/meta.json to find sprite_id),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-08-SUMMARY.md (lint:asset-pipeline contract — bootstrap output flows through unchanged)
    </read_first>
    <behavior>
      - tools/asset-pipeline/cli.ts: registers `bootstrap` subcommand alongside `build` + `postprocess`
      - tools/asset-pipeline/src/bootstrap.ts: exports bootstrap({ sourceRoot, outDir, spriteIds }) → reads each sprite from extracted/, decodes BMP frames via sharp, composites a horizontal strip PNG, writes <id>.png + <id>.json (Aseprite shape) to outDir; deterministic (sorted keys, pinned PNG encoder settings) per Phase 1 D-15
      - bootstrap is idempotent — re-running produces byte-identical output for the same input
      - bootstrap.test.ts: 5+ vitest tests covering: (a) single sprite bootstrap produces correct strip dimensions, (b) frame_count = 6 produces 6 horizontal cells, (c) origin from meta.json is preserved in the output JSON, (d) JSON shape matches Aseprite json-array, (e) determinism (sha256 over output stable across two runs)
      - bootstrap CLI invocation: `pnpm asset-pipeline bootstrap` reads a hard-coded sprite-id list (or accepts a CSV arg) covering the 15 player sprites + bn-central tile/object sprites
      - After bootstrap runs, `pnpm asset-pipeline:build` (existing) packs all of assets/source/sprites/* into atlas-mvp.{png,json} — atlas grows from 06-08's empty/test state to include all real sprites
      - assets/source/sprites/ directory is committed (with .gitkeep + the bootstrapped output files); aseprite/ subdirectory is committed-or-gitignored per planner discretion (CONTEXT D-15) — this plan picks GITIGNORED (the .aseprite source files come later when devs edit; first-pass workflow is bootstrap → ship; aseprite source storage is a v2 ergonomics question, not first-pass)
    </behavior>
    <action>
  **1. tools/asset-pipeline/src/bootstrap.ts** — Tag `// [impl->REQ-AST-01]` at top.

  ```ts
  // [impl->REQ-AST-01]
  // Bootstrap mode (CONTEXT D-14): reads extracted/client-5-8/sprites/<id>/frames/img_*.bmp
  // + meta.json, emits assets/source/sprites/<id>.{png,json} (Aseprite-compatible).
  //
  // Idempotent: re-running with the same input produces byte-identical output
  // (Phase 1 D-15 reproducibility).

  import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
  import { join, resolve } from 'node:path';
  import sharp from 'sharp';

  export interface SpriteMeta {
    name?: string;
    frame_w: number;
    frame_h: number;
    frame_count: number;
    origin_x?: number;
    origin_y?: number;
    // ... other extracted fields preserved verbatim in the output JSON
    [k: string]: unknown;
  }

  export interface BootstrapOptions {
    sourceRoot: string;   // e.g. 'extracted/client-5-8/sprites'
    outDir: string;       // e.g. 'assets/source/sprites'
    spriteIds: string[];  // e.g. ['0000-NaviStandD', '0027-NaviRunD', ...]
  }

  export interface BootstrapResult {
    sprites: Array<{
      id: string;
      framesIn: number;
      pngOut: string;
      jsonOut: string;
      sha256_png: string;
    }>;
  }

  export async function bootstrap(opts: BootstrapOptions): Promise<BootstrapResult> {
    if (!existsSync(opts.outDir)) mkdirSync(opts.outDir, { recursive: true });
    const result: BootstrapResult = { sprites: [] };

    for (const id of [...opts.spriteIds].sort()) {
      const spriteDir = join(opts.sourceRoot, id);
      if (!existsSync(spriteDir)) {
        throw new Error(`bootstrap: sprite directory not found: ${spriteDir}`);
      }
      const metaPath = join(spriteDir, 'meta.json');
      const meta = JSON.parse(readFileSync(metaPath, 'utf-8')) as SpriteMeta;
      const framesDir = join(spriteDir, 'frames');
      const frameFiles = readdirSync(framesDir).filter((f) => f.endsWith('.bmp')).sort();
      if (frameFiles.length !== meta.frame_count) {
        throw new Error(`bootstrap: ${id} meta.frame_count=${meta.frame_count} but found ${frameFiles.length} BMPs`);
      }

      // Composite horizontal strip
      const stripWidth = meta.frame_w * meta.frame_count;
      const stripHeight = meta.frame_h;
      const composite: sharp.OverlayOptions[] = [];
      for (let i = 0; i < frameFiles.length; i++) {
        const bmpBuf = readFileSync(join(framesDir, frameFiles[i]));
        const pngBuf = await sharp(bmpBuf).png({ compressionLevel: 9, palette: false }).toBuffer();
        composite.push({ input: pngBuf, left: i * meta.frame_w, top: 0 });
      }

      const stripBuf = await sharp({
        create: { width: stripWidth, height: stripHeight, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
      }).composite(composite).png({ compressionLevel: 9, palette: false }).toBuffer();

      const pngOut = join(opts.outDir, `${id}.png`);
      writeFileSync(pngOut, stripBuf);

      // Aseprite-compatible JSON sidecar (D-15)
      const frames: Record<string, unknown> = {};
      for (let i = 0; i < meta.frame_count; i++) {
        frames[`${id}_${i}`] = {
          frame: { x: i * meta.frame_w, y: 0, w: meta.frame_w, h: meta.frame_h },
          rotated: false, trimmed: false,
          spriteSourceSize: { x: 0, y: 0, w: meta.frame_w, h: meta.frame_h },
          sourceSize: { w: meta.frame_w, h: meta.frame_h },
        };
      }
      const sidecar = {
        frames,
        meta: {
          app: 'rebno-asset-pipeline-bootstrap',
          version: '1',
          image: `${id}.png`,
          format: 'RGBA8888',
          size: { w: stripWidth, h: stripHeight },
          scale: '1',
          frame_w: meta.frame_w,
          frame_h: meta.frame_h,
          frame_count: meta.frame_count,
          origin: { x: meta.origin_x ?? 0, y: meta.origin_y ?? 0 },
          sprite_id: id,
          source: `extracted/client-5-8/sprites/${id}/`,
        },
      };
      const jsonOut = join(opts.outDir, `${id}.json`);
      // Deterministic deep-sorted JSON (Phase 1 D-15) — use canonical helper.
      // `canonicalStringify` lives in tools/asset-pipeline/src/manifest.ts (06-08 scaffold).
      // If absent, write the 6-line recursive helper inline (see manifest.ts pattern).
      writeFileSync(jsonOut, canonicalStringify(sidecar) + '\n');

      const sha256_png = await sha256(stripBuf);
      result.sprites.push({ id, framesIn: frameFiles.length, pngOut, jsonOut, sha256_png });
    }
    return result;
  }

  async function sha256(buf: Buffer): Promise<string> {
    const { createHash } = await import('node:crypto');
    return createHash('sha256').update(buf).digest('hex');
  }
  ```

  (Determinism note: `JSON.stringify`'s second arg is a replacer function or property-name filter, NOT a sort. Use `canonicalStringify` from `tools/asset-pipeline/src/manifest.ts`. If that helper does not exist yet in 06-08's scaffold, add it as a 6-line recursive sort: walk each object, sort keys, recurse on values, pass through arrays. Intent: same input → byte-identical JSON output.)

  **2. tools/asset-pipeline/cli.ts** — Tag `// [impl->REQ-AST-01]` near the new subcommand.

  ```ts
  // [impl->REQ-AST-01]
  // Add `bootstrap` subcommand alongside existing `build` + `postprocess`.

  import { bootstrap } from './src/bootstrap';

  const PLAYER_SPRITE_IDS = [
    '0000-NaviStandD',
    '0027-NaviRunD',
    '0028-NaviRunR',
    '0029-NaviStandR',
    '0030-NaviStandU',
    '0031-NaviRunU',
    '0032-NaviRunL',
    '0033-NaviStandL',
    '0036-NaviStandDR',
    '0037-NaviRunDR',
    '0038-NaviStandUR',
    '0039-NaviRunUR',
    '0040-NaviStandUL',
    '0041-NaviRunUL',
    '0042-NaviStandDL',
    '0043-NaviRunDL',
  ];

  // Tile + object sprites: derived at bootstrap time from
  // extracted/client-5-8/rooms/0058-BNCentral/tiles.json + instances.json
  // (cross-reference each instance.object_id → extracted/client-5-8/objects/<id>/meta.json
  //  → meta.sprite_id). The executor builds this list dynamically from the
  // extracted JSONs and concatenates with PLAYER_SPRITE_IDS.

  // Add to the existing dispatcher (mirror the build/postprocess registration shape):
  case 'bootstrap': {
    const tileObjectIds = await deriveBnCentralSpriteIds('extracted/client-5-8');
    const allIds = Array.from(new Set([...PLAYER_SPRITE_IDS, ...tileObjectIds])).sort();
    const r = await bootstrap({
      sourceRoot: 'extracted/client-5-8/sprites',
      outDir: 'assets/source/sprites',
      spriteIds: allIds,
    });
    console.log(`bootstrap: ${r.sprites.length} sprites → assets/source/sprites/`);
    break;
  }
  ```

  (Implement `deriveBnCentralSpriteIds` as a small helper inside cli.ts or a sibling module. It reads the room's tiles.json + instances.json and resolves sprite IDs.)

  **3. tools/asset-pipeline/test/bootstrap.test.ts** — Tag `// [unit->REQ-AST-01]`. 5+ vitest tests:

  ```ts
  // [unit->REQ-AST-01]
  import { describe, it, expect, beforeEach } from 'vitest';
  import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
  import { tmpdir } from 'node:os';
  import { join } from 'node:path';
  import { bootstrap } from '../src/bootstrap';

  function makeFixture(root: string, id: string, frame_w: number, frame_h: number, frame_count: number) {
    // Write a minimal extracted/<id>/ tree with meta.json + N solid-color BMPs.
    // Use sharp to generate the BMPs synchronously inside the test (acceptable
    // for unit-test cost; ~milliseconds).
    // ... (executor implements the small fixture builder)
  }

  describe('asset-pipeline bootstrap', () => {
    let tmp: string;
    beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'asset-pipe-')); });

    it('single-frame sprite produces strip with correct dimensions', async () => { /* assert PNG.size === frame_w * frame_h */ });
    it('multi-frame sprite (frame_count=6) produces 6-cell horizontal strip', async () => { /* assert size = 6*frame_w x frame_h */ });
    it('JSON sidecar carries origin from meta.json', async () => { /* assert sidecar.meta.origin.x/y */ });
    it('JSON shape matches Aseprite json-array (frames + meta keys)', async () => { /* assert shape */ });
    it('idempotent — two runs produce byte-identical output (sha256 stable)', async () => { /* run twice, compare sha256 of PNG + JSON */ });
    it('throws clearly when meta.frame_count mismatches BMP count', async () => { /* assert thrown error message */ });
  });
  ```

  **4. assets/source/sprites/.gitkeep** — Empty file. Confirms the directory is committed.

  **5. package.json** — Add npm scripts (alongside the existing asset-pipeline:build / :postprocess from 06-08):

  ```jsonc
  "asset-pipeline:bootstrap": "node tools/asset-pipeline/dist/cli.js bootstrap",
  // (or whatever the existing invocation pattern is — match build / postprocess shape exactly)
  ```

  Then run end-to-end:
  1. `pnpm --filter @rebno/asset-pipeline build` (compiles tools/asset-pipeline TS)
  2. `pnpm asset-pipeline:bootstrap` (produces assets/source/sprites/<id>.{png,json} for all 15 player sprites + bn-central tile/object sprites)
  3. `pnpm asset-pipeline:build` (existing — packs all of assets/source/sprites/* into atlas-mvp.{png,json})
  4. `pnpm asset-pipeline:postprocess` (existing — rewrites manifest paths)
  5. `pnpm lint:asset-pipeline` (existing — sha256 cross-check fires; should pass)

  **Commit assets/source/sprites/*.{png,json}** along with the code changes — these are the bootstrapped output, committed per CONTEXT D-14 ("output committed to git so devs can edit `.aseprite` source files locally").
    </action>
    <verify>
      <automated>pnpm --filter @rebno/asset-pipeline test &amp;&amp; pnpm asset-pipeline:bootstrap &amp;&amp; pnpm asset-pipeline:build &amp;&amp; pnpm asset-pipeline:postprocess &amp;&amp; pnpm lint:asset-pipeline &amp;&amp; node -e "const fs=require('fs'); const ids=['0000-NaviStandD','0027-NaviRunD','0028-NaviRunR','0029-NaviStandR','0030-NaviStandU','0031-NaviRunU','0032-NaviRunL','0033-NaviStandL','0036-NaviStandDR','0037-NaviRunDR','0038-NaviStandUR','0039-NaviRunUR','0040-NaviStandUL','0041-NaviRunUL','0042-NaviStandDL','0043-NaviRunDL']; for (const id of ids) { if (!fs.existsSync('assets/source/sprites/'+id+'.png')) { console.error('MISSING:', id+'.png'); process.exit(1); } if (!fs.existsSync('assets/source/sprites/'+id+'.json')) { console.error('MISSING:', id+'.json'); process.exit(1); } } console.log('all 16 player sprites bootstrapped + atlas built + lint passed');"</automated>
    </verify>
    <done>
      bootstrap subcommand wired; 6+ unit tests pass; all 15 player sprites + bn-central tile/object sprites have .png + .json under assets/source/sprites/; pnpm asset-pipeline:build produces atlas-mvp.{png,json} including the new sprites; pnpm lint:asset-pipeline passes (manifest schema + sha256 integrity); idempotent re-runs are byte-stable.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: Convert 0058-BNCentral → apps/server/rooms/bn-central/000.json + signed; register on server (replaces mvp-lobby)</name>
    <files>
      tools/room-converter/src/bn-central.ts,
      tools/room-converter/test/bn-central.test.ts,
      apps/server/rooms/bn-central/000.json,
      apps/server/rooms/bn-central/000.sig,
      apps/server/src/RebnoRoom.ts,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md
    </files>
    <read_first>
      extracted/client-5-8/rooms/0058-BNCentral/meta.json (room dimensions, room_speed if present),
      extracted/client-5-8/rooms/0058-BNCentral/instances.json (object instance list with x/y/object_id),
      extracted/client-5-8/rooms/0058-BNCentral/tiles.json (tile list with x/y/tileset_id/frame),
      extracted/client-5-8/rooms/0058-BNCentral/backgrounds.json (background image refs),
      extracted/client-5-8/rooms/0058-BNCentral/creation-code.gml (room init code; usually irrelevant for layout but read to confirm),
      apps/server/rooms/mvp-lobby/000.json (canonical SRV-13 schema reference — mirror exactly),
      apps/server/rooms/mvp-lobby/000.sig (signature ritual reference — find the helper that produced it),
      apps/server/src/RebnoRoom.ts (find: matchmaker.defineRoomType('mvp-lobby', ...); replace with bn-central; verify home-portal spawn read path D-07),
      tools/room-converter/src/index.ts (existing entry; mirror its conventions — file structure + cli wiring),
      tools/scripts/lint-room-layout.mjs (validates 000.json + 000.sig — verify the new bn-central files pass)
    </read_first>
    <behavior>
      - tools/room-converter/src/bn-central.ts: exports convertBnCentral({ srcRoot, outRoot }) → reads the 5 source files, builds the SRV-13 layout JSON, writes apps/server/rooms/bn-central/000.json + signs to 000.sig
      - All sprite/object IDs preserved verbatim (object_id stays as the original BNO id like '0042-player' even though the player object is actually 0000-server — this is the room's instance list, not the player avatar; the room may legitimately reference 0042-player for some other instance type, document if so)
      - home_portal spawn point: identified from instances.json (a specific portal/door object) OR fallback to room center; documented in 06-14-ASSET-MANIFEST.md
      - 000.sig produced by the existing Phase 4 D-12 signing helper (use the dev key for local; staging/prod keys ship per Phase 5 D-19 ritual)
      - bn-central.test.ts: 4+ tests — fixture-based conversion produces expected layout shape, sprite IDs cross-reference correctly, signature verifies, idempotent
      - Server: RebnoRoom.ts replaces 'mvp-lobby' with 'bn-central' in matchmaker.defineRoomType (or runs both during transition; final committed state is bn-central is the canonical CLI-08 room — leave mvp-lobby registration if needed for backward compat with any other test, but the e2e + UAT target bn-central)
      - 06-14-ASSET-MANIFEST.md documents: list of sprites bootstrapped, source for each (extracted path), home-portal spawn coordinates + reasoning, any tile/object IDs that couldn't be resolved (with reason)
    </behavior>
    <action>
  **1. tools/room-converter/src/bn-central.ts** — Tag `// [impl->REQ-AST-01] [impl->REQ-CLI-07]`.

  ```ts
  // [impl->REQ-AST-01] [impl->REQ-CLI-07]
  // BN Central room converter — bridges extracted/client-5-8/rooms/0058-BNCentral/*
  // → apps/server/rooms/bn-central/000.json (SRV-13 schema) + signed 000.sig.

  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
  import { join } from 'node:path';
  // import { signRoomLayout } from '@rebno/server/dev-keys';  // or wherever Phase 4 D-12 helper lives

  export interface ConvertOptions {
    srcRoot: string;          // 'extracted/client-5-8/rooms/0058-BNCentral'
    outRoot: string;          // 'apps/server/rooms/bn-central'
    signingKeyPath?: string;  // dev key for local; production via Phase 5 D-19 ritual
  }

  export async function convertBnCentral(opts: ConvertOptions): Promise<{ jsonPath: string; sigPath: string }> {
    const meta = JSON.parse(readFileSync(join(opts.srcRoot, 'meta.json'), 'utf-8'));
    const instances = JSON.parse(readFileSync(join(opts.srcRoot, 'instances.json'), 'utf-8'));
    const tiles = JSON.parse(readFileSync(join(opts.srcRoot, 'tiles.json'), 'utf-8'));
    const backgrounds = JSON.parse(readFileSync(join(opts.srcRoot, 'backgrounds.json'), 'utf-8'));

    // Phase 4 / Phase 6 standard tile sizes (verify against meta.json — BNO original may differ)
    const tile_w = 44;
    const tile_h = 40;

    const layout = {
      room_id: 'bn-central',
      version: 1,
      width_tiles: Math.ceil(meta.width / tile_w),   // adapt to meta.json shape; verify field name
      height_tiles: Math.ceil(meta.height / tile_h),
      tile_w, tile_h,
      background: extractBackground(backgrounds), // map to { sprite_id, tile_x, tile_y } or {color} fallback
      tiles: tiles.map((t: any) => ({
        tileset_sprite_id: resolveSpriteId(t.tileset_id ?? t.background_id),
        x: t.x, y: t.y,
        frame: t.frame ?? 0,
      })),
      instances: instances.map((i: any) => ({
        object_id: i.object_id ?? i.name,    // verify field name from extracted JSON
        x: i.x, y: i.y,
      })),
      spawn_points: deriveSpawnPoints(instances, meta),  // home_portal from a specific object OR room center fallback
    };

    if (!existsSync(opts.outRoot)) mkdirSync(opts.outRoot, { recursive: true });
    const canonical = canonicalJson(layout);  // sorted-keys, deterministic
    const jsonPath = join(opts.outRoot, '000.json');
    writeFileSync(jsonPath, canonical + '\n');

    // Sign — call the existing Phase 4 D-12 helper. If it lives in apps/server/src/room-signing.ts,
    // import it; if it's a CLI script, invoke it via spawnSync. Verify the existing mvp-lobby
    // pipeline and mirror exactly.
    const sigPath = join(opts.outRoot, '000.sig');
    await signLayout(jsonPath, sigPath, opts.signingKeyPath);

    return { jsonPath, sigPath };
  }

  // Helpers — extractBackground / resolveSpriteId / deriveSpawnPoints / canonicalJson / signLayout
  // ... (executor implements per the existing mvp-lobby reference)
  ```

  **Key derivation decisions (executor records in 06-14-ASSET-MANIFEST.md):**

  a) **Tile size**: 44×40 is the Phase 6 standard (CONTEXT D-10 reference). If extracted/0058-BNCentral/meta.json shows a different value (e.g. 16×16), USE THE EXTRACTED VALUE — Phase 6 must adapt to BNO truth, not impose a different tile size. Document the choice.

  b) **Background**: backgrounds.json may reference a sprite ID from extracted/client-5-8/backgrounds/ (different namespace from sprites/). Verify; bootstrap that background asset too if needed (Task 1 was player+tile+object sprites; if backgrounds need their own bootstrap-like step, add it now or scope to a tiny helper).

  c) **home_portal spawn**: Look in instances.json for an obvious portal/door/spawn-marker object. If none, use room-center: `(width/2, height/2)`. Document choice + coordinates.

  d) **Object IDs that don't have a sprite**: some original-BNO objects are invisible (logic-only). Skip in the rendered instances list OR include with a sentinel sprite_id; document.

  **2. tools/room-converter/test/bn-central.test.ts** — Tag `// [unit->REQ-AST-01]`.

  4+ vitest tests using a fixture extracted-room directory:
  - (a) Layout JSON matches SRV-13 schema (validate via lint-room-layout.mjs's schema constants)
  - (b) Tile + instance counts match source JSON inputs
  - (c) home_portal spawn point is present and within room bounds
  - (d) Idempotent — two runs produce byte-identical 000.json
  - (e) Signature verifies against the dev pubkey

  **3. Run the converter** to produce real outputs:
  ```bash
  pnpm --filter @rebno/room-converter build  # if needed
  node tools/room-converter/dist/cli.js bn-central
  # OR a one-off script call to convertBnCentral(...) — verify tools/room-converter's existing CLI shape
  ```

  This produces `apps/server/rooms/bn-central/000.json` and `apps/server/rooms/bn-central/000.sig`. Commit both.

  **4. apps/server/src/RebnoRoom.ts** — Tag `// [impl->REQ-CLI-07]`. Find the existing `matchmaker.defineRoomType('mvp-lobby', ...)` call (or wherever room registration happens). Add a parallel registration for 'bn-central' OR replace mvp-lobby with bn-central. Decision: REPLACE (mvp-lobby was synthetic; bn-central is canonical now). Keep mvp-lobby as a fallback ONLY if any other test depends on it; otherwise remove from production server.

  ```ts
  // [impl->REQ-CLI-07]
  // Replaces synthetic mvp-lobby with first-pass real BN Central room (D-27).
  matchmaker.defineRoomType('bn-central', RebnoRoom, { layoutPath: 'apps/server/rooms/bn-central/000.json' });
  ```

  Verify the home-portal spawn read path (Phase 6 D-07): `onJoin` for a new session reads `spawn_points.home_portal.{x,y}` from the loaded layout. If the existing code reads `mvp-lobby` spawn coordinates, generalize to read from the layout's spawn_points by name.

  **5. .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md** — Doc-only.

  ```md
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 14
  type: doc-only-manifest
  status: complete
  ---

  # First-Pass Real Asset Manifest (D-27)

  [doc->REQ-AST-01] [doc->REQ-CLI-07]

  ## Player sprites bootstrapped (15)

  | Sprite ID | Source | Frames | Origin (x,y) | Notes |
  |-----------|--------|--------|--------------|-------|
  | 0000-NaviStandD | extracted/client-5-8/sprites/0000-NaviStandD/ | 1 | (x,y) | Default-frame baseline for PlayerRenderer |
  | 0027-NaviRunD | ... | 6 | ... | Walk-cycle south |
  | 0028-NaviRunR | ... | ? | ... | Walk-cycle east (R-facing); restored after CONTEXT-line-336 omission |
  | 0029-NaviStandR | ... | 1 | ... | |
  | 0030-NaviStandU | ... | 1 | ... | |
  | 0031-NaviRunU | ... | ? | ... | Walk-cycle north |
  | 0032-NaviRunL | ... | ? | ... | Walk-cycle west |
  | 0033-NaviStandL | ... | 1 | ... | |
  | 0036-NaviStandDR | ... | 1 | ... | Diagonal stand SE |
  | 0037-NaviRunDR | ... | ? | ... | Diagonal walk SE |
  | 0038-NaviStandUR | ... | 1 | ... | Diagonal stand NE |
  | 0039-NaviRunUR | ... | ? | ... | Diagonal walk NE |
  | 0040-NaviStandUL | ... | 1 | ... | Diagonal stand NW |
  | 0041-NaviRunUL | ... | ? | ... | Diagonal walk NW |
  | 0042-NaviStandDL | ... | 1 | ... | Diagonal stand SW |
  | 0043-NaviRunDL | ... | ? | ... | Diagonal walk SW |

  ## Tile sprites bootstrapped (derived from 0058-BNCentral/tiles.json)

  | Sprite ID | Source | Frames | Used for | Notes |
  |-----------|--------|--------|----------|-------|
  | _<derived>_ | _<extracted path>_ | _<n>_ | Floor tile / wall / etc. | |

  ## Object sprites bootstrapped (derived from 0058-BNCentral/instances.json + objects/<id>/meta.json)

  | Sprite ID | From object_id | Source | Notes |
  |-----------|----------------|--------|-------|
  | _<derived>_ | _<obj id>_ | _<extracted path>_ | |

  ## Background

  | Background ID | Source | Notes |
  |---------------|--------|-------|
  | _<derived>_ | extracted/client-5-8/backgrounds/<id>/ | Bootstrapped via _<bootstrap helper>_ |

  ## Room conversion: 0058-BNCentral → bn-central

  - Source room dimensions: _<from meta.json>_ (e.g. 640×480 px)
  - Tile size adopted: 44×40 (Phase 6 D-10 standard) OR _<extracted value if different>_ — RATIONALE: _<...>_
  - Tile count: _<n>_ (from tiles.json)
  - Instance count: _<n>_ (from instances.json)
  - home_portal spawn: (x, y) = _<derived>_; SOURCE: _<which instance/object marked it OR "room center fallback">_
  - Signature: 000.sig produced by _<helper name>_ using _<dev key path>_; verifies against _<pubkey path>_

  ## Decisions made

  _(Document any judgment calls — invisible objects skipped, tile size mismatch, sprite_id couldn't be resolved, etc.)_

  ## Out of scope (Phase 7)

  - Audio (AST-02 — extracted/client-5-8/ has no audio per ADR 0001)
  - Fonts (AST-03)
  - Other 9 extracted rooms (PAR-03)
  - Aseprite source files (devs may add later; bootstrap output is the first-pass committed source)
  ```

  Fill in the `<derived>` placeholders from your actual reading of the extracted JSONs.
    </action>
    <verify>
      <automated>node -e "const fs=require('fs'); if (!fs.existsSync('apps/server/rooms/bn-central/000.json')) { console.error('MISSING 000.json'); process.exit(1); } if (!fs.existsSync('apps/server/rooms/bn-central/000.sig')) { console.error('MISSING 000.sig'); process.exit(1); } const layout=JSON.parse(fs.readFileSync('apps/server/rooms/bn-central/000.json','utf-8')); if (layout.room_id !== 'bn-central') { console.error('room_id wrong:', layout.room_id); process.exit(1); } if (!Array.isArray(layout.tiles) || layout.tiles.length === 0) { console.error('no tiles'); process.exit(1); } if (!layout.spawn_points || !layout.spawn_points.find(s => s.name === 'home_portal')) { console.error('no home_portal spawn'); process.exit(1); } console.log('bn-central layout OK:', layout.tiles.length, 'tiles,', layout.instances.length, 'instances'); const r=fs.readFileSync('apps/server/src/RebnoRoom.ts','utf-8'); if (!r.includes('bn-central')) { console.error('RebnoRoom does not register bn-central'); process.exit(1); } console.log('server registers bn-central'); &amp;&amp; pnpm --filter @rebno/room-converter test &amp;&amp; pnpm trace:check"</automated>
    </verify>
    <done>
      apps/server/rooms/bn-central/000.json + 000.sig committed; SRV-13 schema valid (lint-room-layout.mjs passes); RebnoRoom.ts registers 'bn-central' as the canonical room (mvp-lobby removed or kept as deprecated fallback per discretion); tools/room-converter/src/bn-central.ts shipped with 4+ tests; 06-14-ASSET-MANIFEST.md fully populated. Server boots without error against the new room.
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: Client atlas load + PlayerRenderer real-sprite + RoomRenderer bn-central + e2e assertion update</name>
    <files>
      apps/client/src/scenes/GameScene.ts,
      apps/client/src/assets/atlas-loader.ts,
      apps/client/src/assets/room-renderer.ts,
      apps/client/src/render/PlayerRenderer.ts,
      apps/client/test/e2e/cli-08.e2e.test.ts
    </files>
    <read_first>
      apps/client/src/scenes/GameScene.ts (find: preload(); current room rendering call site; current PlayerRenderer instantiation),
      apps/client/src/assets/atlas-loader.ts (current implementation; understand how it sets Phaser.Textures.FilterMode.NEAREST),
      apps/client/src/assets/room-renderer.ts (find: render(layout) method; current mvp-lobby-specific code paths),
      apps/client/src/render/PlayerRenderer.ts (find: ensureLocal / addRemote — replace blue rectangle with atlas sprite),
      apps/client/test/e2e/cli-08.e2e.test.ts (find: existing room-id assertion if any; existing player-render assertion if any; this plan adds/updates),
      apps/client/vite.config.ts (verify the public/ static dir or asset import path — atlas-mvp.png needs to be served at /atlas-mvp.png in dev),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md (cross-reference sprite IDs)
    </read_first>
    <behavior>
      - GameScene.preload(): this.load.atlas('mvp', '/atlas-mvp.png', '/atlas-mvp.json'); set NEAREST filter on the loaded atlas
      - GameScene.create(): set canvas attribute data-room-id='bn-central' (or read from received layout) so e2e can assert
      - PlayerRenderer.ensureLocal(): replaces blue rectangle with `scene.add.sprite(x, y, 'mvp', '0000-NaviStandD_0')`; honors origin from atlas json sidecar
      - PlayerRenderer.addRemote(): same shape; uses atlas-loaded sprite
      - 8-direction sprite SELECTION wiring is OUT OF SCOPE here (06-16 owns it); this plan ships StandD as the default frame for both self + remotes
      - RoomRenderer.render(layout): handles bn-central layout (tiles + instances + background) using atlas frames where applicable
      - e2e cli-08.e2e.test.ts: adds two assertions — (a) data-room-id === 'bn-central'; (b) canvas pixel sample at player position is NOT solid blue (RGB ≠ (0,0,255)); existing chat + nameplate assertions preserved
    </behavior>
    <action>
  **1. apps/client/src/scenes/GameScene.ts** — Tag `// [impl->REQ-AST-01] [impl->REQ-CLI-07]` near the preload + room-id-set lines.

  ```ts
  // [impl->REQ-AST-01] [impl->REQ-CLI-07]

  preload(): void {
    // Atlas (D-13/D-14/D-17)
    this.load.atlas('mvp', '/atlas-mvp.png', '/atlas-mvp.json');
    // existing preload calls...
  }

  create(): void {
    // existing create()...
    // After atlas loaded, set NEAREST filter (Phase 6 D-12)
    const tex = this.textures.get('mvp');
    if (tex) tex.setFilter(Phaser.Textures.FilterMode.NEAREST);
    // Mark room id for e2e selectors — set on first s2c.room_layout receive in onRoomLayout()
    // (or set immediately if room id is known from join init data)
  }

  private onRoomLayout(layout: RoomLayout): void {
    // existing render call...
    this.roomRenderer.render(layout);
    // Mark on canvas for e2e
    const canvas = this.game.canvas as HTMLCanvasElement | null;
    if (canvas) canvas.setAttribute('data-room-id', layout.room_id);
  }
  ```

  **2. apps/client/src/assets/atlas-loader.ts** — Tag `// [impl->REQ-AST-01]`. Verify it sets NEAREST filter on the 'mvp' atlas after load. If absent, add the wiring.

  **3. apps/client/src/render/PlayerRenderer.ts** — Tag `// [impl->REQ-AST-01]` near the ensureLocal/addRemote methods.

  ```ts
  // [impl->REQ-AST-01]
  // Replace blue rectangle with real atlas sprite. 8-direction sprite SELECTION
  // wiring lives in 06-16 (sprite-state machine). This plan ships StandD as the
  // default frame for self + remotes.

  ensureLocal(scene: Phaser.Scene, x: number, y: number, sessionId: string): Phaser.GameObjects.Sprite {
    if (this.local) return this.local;
    const sprite = scene.add.sprite(x, y, 'mvp', '0000-NaviStandD_0');
    // Honor origin from atlas json sidecar (executor reads atlas-mvp.json frame meta + applies setOrigin)
    this.local = sprite;
    return sprite;
  }

  addRemote(scene: Phaser.Scene, x: number, y: number, sessionId: string, username: string): void {
    const sprite = scene.add.sprite(x, y, 'mvp', '0000-NaviStandD_0');
    // Nameplate wiring stays as-is (D-27a cyan nametag lands in 06-16)
    this.remotes.set(sessionId, sprite);
  }
  ```

  (If PlayerRenderer's existing API differs, adapt — the principle is "atlas frame replaces rectangle.")

  **4. apps/client/src/assets/room-renderer.ts** — Tag `// [impl->REQ-CLI-07]`.

  ```ts
  // [impl->REQ-CLI-07]
  render(layout: RoomLayout): void {
    // Background
    if (layout.background?.sprite_id) {
      this.scene.add.image(0, 0, 'mvp', `${layout.background.sprite_id}_0`).setOrigin(0, 0);
    }
    // Tiles — render via atlas sprites at world coords
    for (const t of layout.tiles) {
      const frameKey = `${t.tileset_sprite_id}_${t.frame ?? 0}`;
      this.scene.add.sprite(t.x, t.y, 'mvp', frameKey).setOrigin(0, 0);
    }
    // Instances — only render those with a corresponding sprite (some are invisible logic objects)
    for (const inst of layout.instances) {
      const spriteId = this.resolveInstanceSprite(inst.object_id);
      if (spriteId) this.scene.add.sprite(inst.x, inst.y, 'mvp', `${spriteId}_0`);
    }
  }
  ```

  (executor implements `resolveInstanceSprite` — a small lookup of object_id → sprite_id from the manifest or a hard-coded table from 06-14-ASSET-MANIFEST.md; if Phaser TilemapLayer is preferred per RESEARCH §room-rendering recommendation, swap to that — both work for SRV-13's tile array shape.)

  **5. apps/client/test/e2e/cli-08.e2e.test.ts** — Tag `// [int->REQ-AST-01] [int->REQ-CLI-07]`.

  Update existing assertions (do NOT rewrite the file; add to it):

  ```ts
  // [int->REQ-AST-01] [int->REQ-CLI-07]
  test('renders bn-central room (replaces synthetic mvp-lobby)', async ({ page }) => {
    await loginAs(page, accountA);
    await waitForGameReady(page);
    // Room-id assertion
    const roomId = await page.locator('canvas[data-game-ready=true]').getAttribute('data-room-id');
    expect(roomId).toBe('bn-central');
  });

  test('player render is not a solid blue rectangle (real atlas sprite loaded)', async ({ page }) => {
    await loginAs(page, accountA);
    await waitForGameReady(page);
    // Sample canvas pixel at the local-player position. Coarse but sufficient.
    const isBlueRect = await page.evaluate(async () => {
      const canvas = document.querySelector('canvas[data-game-ready=true]') as HTMLCanvasElement;
      if (!canvas) return null;
      // Sample at canvas center (server spawns at home_portal which the camera follows;
      // the local player should be near canvas center after camera follow)
      const ctx = canvas.getContext('2d') ?? canvas.getContext('webgl2');
      if (!ctx || !('getImageData' in ctx)) {
        // WebGL canvas — use a different sampling approach via toDataURL or skip
        return 'webgl-skip';
      }
      const px = (ctx as CanvasRenderingContext2D).getImageData(canvas.width / 2, canvas.height / 2, 1, 1).data;
      const [r, g, b, a] = [px[0], px[1], px[2], px[3]];
      // Solid blue marker would be ~(0, 0, 255); real sprite pixels are NOT this
      return r === 0 && g === 0 && b === 255 && a === 255;
    });
    if (isBlueRect === 'webgl-skip') {
      // Phaser uses WebGL by default; if pixel sample isn't reachable, fall back to checking
      // that the 'mvp' texture is loaded by querying window.__rebno or similar dev hook
      const atlasLoaded = await page.evaluate(() => {
        const game = (window as any).__rebno?.game as Phaser.Game | undefined;
        return !!game?.textures?.exists?.('mvp');
      });
      expect(atlasLoaded).toBe(true);
    } else {
      expect(isBlueRect).toBe(false);
    }
  });
  ```

  (executor adapts to actual GameScene Phaser renderer — WebGL pixel sample is awkward; the texture-loaded fallback is the practical assertion. Either is acceptable as a "not blue rectangle" guard.)

  Existing chat + nameplate + reconnect tests in cli-08.e2e.test.ts are preserved unchanged. The room-id selector update may require updating any other test that currently asserts `data-room-id="mvp-lobby"` — search and replace those references.
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client typecheck &amp;&amp; pnpm --filter @rebno/client test:e2e -- --grep "bn-central|atlas|player render"</automated>
    </verify>
    <done>
      GameScene preloads atlas-mvp; sets NEAREST filter; sets data-room-id on canvas. PlayerRenderer renders 0000-NaviStandD_0 atlas frame instead of blue rectangle. RoomRenderer renders BN Central tiles + background + instances from atlas frames. cli-08.e2e.test.ts asserts data-room-id=='bn-central' AND non-blue player render (or atlas-loaded fallback). All updated tests GREEN. Manual smoke (deferred to 06-17): operator opens local dev → sees real BNO sprites in BN Central room.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Extracted assets → committed Aseprite-compatible source | If bootstrap mis-decodes BMP, every downstream artifact is corrupted; mitigated by sha256 cross-check (lint:asset-pipeline) |
  | Bootstrapped source → atlas pack | Phase 1 D-15 reproducibility requires deterministic output; canonical-JSON + sorted spriteIds enforce |
  | Server-emitted bn-central layout → client | Phase 4 D-12 Ed25519 signature gates; client verifies before tilemap render |
  | Client atlas asset path → public/ static mount | D-18 same-Fly-app hosting; atlas-mvp.png served from apps/server/public/; CSP must not block |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-14-01 | Tampering | Adversary swaps atlas-mvp.png with bogus content | mitigate | lint:asset-pipeline cross-checks sha256_png against pipeline-manifest.json; CI runs the lint per verify-phase-6 step 7 |
  | T-06-14-02 | Tampering | Room layout 000.json edited without re-signing | mitigate | Phase 4 D-12 Ed25519 signature; client verify-on-load fails closed; server-side test ensures 000.sig matches 000.json before bootstrap |
  | T-06-14-03 | Disclosure | Extracted-asset filenames committed publicly leak BNO content via path leak | accept | Repo private through Phase 7 (CLAUDE.md hard rule #8); Phase 7 PAR-08 legal-prep checklist gates public flip |
  | T-06-14-04 | DoS | Atlas grows beyond 1024×1024 fallback → Phaser texture limit on lower-spec GPUs | mitigate | Atlas budget per CONTEXT D-17 (target 512×512, fallback 1024×1024); 06-14-ASSET-MANIFEST.md records final dimensions; if exceeded, split atlas |
  | T-06-14-05 | Repudiation | Sprite ID resolution unclear (which extracted file → which atlas frame) | mitigate | 06-14-ASSET-MANIFEST.md tables record every sprite_id → extracted/path → atlas frame; auditable |
  </threat_model>

  <verification>
  - `pnpm --filter @rebno/asset-pipeline test` exits 0 (bootstrap unit tests + existing tests)
  - `pnpm asset-pipeline:bootstrap && pnpm asset-pipeline:build && pnpm asset-pipeline:postprocess && pnpm lint:asset-pipeline` exits 0 end-to-end
  - `pnpm --filter @rebno/room-converter test` exits 0 (bn-central converter tests)
  - `node tools/room-converter/dist/cli.js bn-central` produces 000.json + 000.sig at apps/server/rooms/bn-central/
  - `pnpm --filter @rebno/server test` exits 0 (server boots with bn-central registered)
  - `pnpm --filter @rebno/client typecheck` exits 0
  - `pnpm --filter @rebno/client test:e2e` exits 0 (existing + new bn-central + non-blue-render assertions)
  - 06-14-ASSET-MANIFEST.md exists and has all `<derived>` placeholders filled
  - assets/source/sprites/<id>.{png,json} committed for all 15 player sprites + bn-central tile/object sprites
  - `pnpm trace:check` reports `[impl->REQ-AST-01] [impl->REQ-CLI-07]` tags satisfied
  </verification>

  <success_criteria>
  - UAT Finding #4 (D-27) closed: real BNO assets land end-to-end (sprites + room + atlas + server registration + client render)
  - mvp-lobby (synthetic) replaced with bn-central (canonical first-pass real room — the actual latest online lobby in BNO per addendum line 388)
  - Player object naming gotcha (0000-server) and room naming gotcha (0058-BNCentral, NOT 0004-Online_Lobby) honored
  - 8-direction sprite-state machine + cyan nametag (D-27a) explicitly DEFERRED to 06-16 — this plan ships StandD as default frame baseline
  - First-pass scope EXCLUDES audio + fonts + other 9 rooms (Phase 7) per addendum line 340
  - Tags `[impl->REQ-AST-01] [impl->REQ-CLI-07]` (per gap-closure addendum line 363)
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-SUMMARY.md` recording:
  (a) Final atlas-mvp dimensions (px × px) + total sprite count packed
  (b) Final bn-central layout dimensions (width_tiles × height_tiles, tile_w × tile_h) + tile/instance counts
  (c) home_portal spawn coordinates + which extracted source identified them
  (d) Any sprite_id that couldn't be resolved (with reason — invisible logic object, missing extracted asset, etc.)
  (e) Confirmation `pnpm verify:phase-6` passes end-to-end after Wave 11+12 plans land
  (f) Pointer to 06-16 for D-27a nametag + 8-direction sprite-state wiring
  (g) Trace tag verification snippet for REQ-AST-01 + REQ-CLI-07 impl + doc + int stages
  </output>
  </content>
  </invoke>```

  ### Plan 06-15
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 15
  type: execute
  wave: 11
  depends_on: [06-12]
  files_modified:
    - apps/client/src/scenes/BootScene.ts
    - apps/client/src/scenes/LoginScene.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/net/InputDispatcher.ts
    - apps/client/src/__test__/game-scene.test.ts
    - apps/client/src/__test__/login-scene.test.ts
    - apps/client/src/__test__/input-dispatcher.test.ts
    - apps/client/test/e2e/cookie-reload.e2e.test.ts
    - apps/client/test/e2e/ws-kill-reconnect.e2e.test.ts
  autonomous: true
  requirements: [CLI-09, CLI-04]
  must_haves:
    truths:
      - "Cookie auto-login path threads Better-Auth session.token through BootScene → LoginSceneInitData → GameSceneInitData (Option A — three edits per 06-12-DEBUG-FINDINGS.md §4)"
      - "GameScene self-heals on missing token by calling getSession() in create() and adopting session.token if init-data sessionToken is undefined (Option B — defence in depth, complementary to A)"
      - "GameScene.connect() no longer silently early-returns on the cookie path: with token present, joinRebnoRoom is called → bindHandlers fires → players.onAdd / onRoomLayout / chat all wire up → canvas renders content"
      - "Stale InputDispatcher.room reference fixed: after silent_reauth, dispatcher.room === GameScene.room (not the closed/garbage-collected old room object) — addresses §5 secondary defect from 06-12-DEBUG-FINDINGS"
      - "Two new Playwright e2e files (cookie-reload + ws-kill-reconnect) cover both reconnect paths from 06-HUMAN-UAT.md Test 1 reload-after-login + Test 5 WS-kill-then-reconnect — both assert canvas[data-game-ready=true] re-renders within 5s + a chat round-trip works post-reconnect"
      - "Three new vitest unit files lock the regression: GameScene.test (cookie path token threading + Option B self-heal), LoginScene.test (fast-path forwards sessionToken), InputDispatcher.test (room ref refreshed after silent_reauth)"
      - "No server-side changes — server bearer-vs-cookie posture is correct per 06-12-DEBUG-FINDINGS §7"
    artifacts:
      - path: apps/client/src/scenes/BootScene.ts
        provides: "Forwards session.token into LoginSceneInitData alongside username (Option A edit 1 of 3)"
        contains: "sessionToken"
      - path: apps/client/src/scenes/LoginScene.ts
        provides: "LoginSceneInitData accepts sessionToken; renderFastPath forwards { sessionToken, username } to scene.start('GameScene', ...) (Option A edits 2 + 3 of 3)"
        contains: "sessionToken"
      - path: apps/client/src/scenes/GameScene.ts
        provides: "create() self-heals on missing sessionToken via getSession() (Option B); connect() now reaches joinRebnoRoom on cookie path; InputDispatcher room ref refreshed on every reconnect"
        contains: "sessionToken"
      - path: apps/client/src/net/InputDispatcher.ts
        provides: "Adds setRoom(room) method (or reassigns the room field via the existing API surface) so GameScene can refresh the dispatcher's room ref after silent_reauth"
        contains: "setRoom"
      - path: apps/client/src/__test__/game-scene.test.ts
        provides: "Adds cookie-path test: scene.init({ username }) with no sessionToken → mocked getSession returns token → assert joinRebnoRoom IS called with the token"
        contains: "cookie path"
      - path: apps/client/src/__test__/login-scene.test.ts
        provides: "Adds fast-path test: assert scene.start('GameScene', ...) is called with { sessionToken, username } when LoginScene receives fastPath: true + sessionToken"
        contains: "fast-path"
      - path: apps/client/src/__test__/input-dispatcher.test.ts
        provides: "Adds silent-reauth test: dispatcher.room === gameScene.room after a simulated reconnect cycle"
        contains: "silent_reauth"
      - path: apps/client/test/e2e/cookie-reload.e2e.test.ts
        provides: "Playwright e2e — login + reload → canvas[data-game-ready=true] within 5s + chat line renderable"
        contains: "reload"
      - path: apps/client/test/e2e/ws-kill-reconnect.e2e.test.ts
        provides: "Playwright e2e — login + page.evaluate(__rebno.room.connection.close()) → SDK auto-reconnects within grace → canvas[data-game-ready=true] still truthy + chat round-trips"
        contains: "ws-kill"
    key_links:
      - from: apps/client/src/scenes/BootScene.ts
        to: apps/client/src/auth/client.ts (getSession returns SessionShape.token)
        via: "const session = await getSession(); → forward session.token alongside session.user.username"
        pattern: "session.token"
      - from: apps/client/src/scenes/LoginScene.ts
        to: apps/client/src/scenes/GameScene.ts
        via: "scene.start('GameScene', { sessionToken, username, mustForceReset }) — fast path matches form-submit shape (LoginScene.ts:173-177)"
        pattern: "sessionToken"
      - from: apps/client/src/scenes/GameScene.ts (refreshed room ref)
        to: apps/client/src/net/InputDispatcher.ts
        via: "this.inputDispatcher.setRoom(this.room) on every reconnect (refresh-not-recreate; preserves any pending input buffer state)"
        pattern: "setRoom|inputDispatcher.room"
      - from: apps/client/test/e2e/cookie-reload.e2e.test.ts + ws-kill-reconnect.e2e.test.ts
        to: apps/client/test/e2e/fixtures.ts
        via: "loginAs + waitForGameReady fixtures — reuse 06-08 patterns; the new tests extend, not replace, the cli-08.e2e.test.ts family"
        pattern: "loginAs|waitForGameReady"
  ---

  <objective>
  Close UAT Finding #2 (D-25) and the secondary defect (§5 in 06-12-DEBUG-FINDINGS.md): the cookie auto-login path drops the Better-Auth `session_token` between BootScene → LoginScene fast-path → GameScene, causing GameScene.connect() to silently early-return at the empty-token guard. Apply Option A (three edits threading the token through scene-init data) AND Option B (GameScene self-heal via getSession() in create()) — they're complementary, ship both. Also fold in the secondary defect: `InputDispatcher` keeps a stale `room` reference after silent_reauth (the `if (!this.inputDispatcher)` guard prevents re-pointing at the new room object) — refresh the dispatcher's room ref on every reconnect.

  Lock the regression with five new tests:
  1. Vitest GameScene unit (cookie path: init without sessionToken → getSession self-heal → joinRebnoRoom IS called)
  2. Vitest LoginScene unit (fast-path forwards sessionToken to GameScene)
  3. Vitest InputDispatcher unit (room ref refreshed after silent_reauth — `dispatcher.room === gameScene.room`)
  4. Playwright cookie-reload e2e (login → reload → canvas re-renders + chat works)
  5. Playwright ws-kill-reconnect e2e (login → kill WS → SDK auto-reconnects within grace → canvas still truthy + chat round-trips)

  Purpose: per 06-12-DEBUG-FINDINGS.md (the input contract for this plan, produced by 06-12). Server-side bearer-vs-cookie posture is CORRECT — do NOT touch `apps/server/src/RebnoRoom.ts` or any server bearer logic; the bug is purely client-side data flow per §7 of the debug doc.

  Output: 4 client-source edits + 1 dispatcher API tweak + 3 vitest files + 2 Playwright files. Per gap-closure addendum line 361 — `[impl/int->REQ-CLI-09] [impl->REQ-CLI-04]`.
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md
  @.planning/debug/reconnect-blank-render.md
  @apps/client/src/scenes/BootScene.ts
  @apps/client/src/scenes/LoginScene.ts
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/auth/client.ts
  @apps/client/src/net/colyseus-client.ts
  @apps/client/src/net/reconnect.ts
  @apps/client/src/net/InputDispatcher.ts
  @apps/client/test/e2e/cli-08.e2e.test.ts
  @apps/client/test/e2e/fixtures.ts

  <interfaces>
  <!-- Verbatim from 06-12-DEBUG-FINDINGS.md §3 §4 §5 — do not re-derive. -->

  Affected files (06-12 §3) — edit by 06-15:
    - apps/client/src/scenes/BootScene.ts (line 78-95) — Option A edit 1
    - apps/client/src/scenes/LoginScene.ts (line 80-116, esp. 106-109) — Option A edits 2 + 3
    - apps/client/src/scenes/GameScene.ts (line 191-192) — Option B self-heal in create()
    - apps/client/src/scenes/GameScene.ts (line 238-252) — secondary defect: refresh InputDispatcher.room
    - apps/client/src/net/InputDispatcher.ts — add setRoom method (or expose room as settable)

  Affected files (06-12 §3) — read-only / DO NOT touch:
    - apps/client/src/auth/client.ts (84-108) — getSession already returns token; consume verbatim
    - apps/client/src/net/colyseus-client.ts (82-99) — cached-reconnect path; do not modify
    - apps/server/src/RebnoRoom.ts (267-269) — server bearer-only contract is CORRECT; do not modify

  Option A (06-12 §4): three edits in priority order:
    1. BootScene.create line 88-95: forward session.token into LoginSceneInitData
    2. LoginScene.init / LoginSceneInitData line 26-51: accept sessionToken?: string; store on instance
    3. LoginScene.renderFastPath line 106-109: pass { sessionToken, username } to scene.start('GameScene', ...)

  Option B (06-12 §4): in GameScene.create, if this.sessionToken is undefined, call getSession() and adopt session.token. Combine with A — they're complementary.

  Secondary defect (06-12 §5):
    Current code in GameScene.ts:238-252 — `if (!this.inputDispatcher)` guard creates dispatcher once;
    never re-points at new this.room after silent_reauth.
    Fix: change to either always-recreate, or call this.inputDispatcher.setRoom(this.room) on the
    not-first reconnect path.

  Required regression tests (06-12 §6) — five total:
    1. Unit (game-scene.test.ts): cookie path token-threading + Option B self-heal
    2. Unit (login-scene.test.ts): fast-path forwards sessionToken
    3. Unit (input-dispatcher.test.ts): silent_reauth — dispatcher.room === gameScene.room
    4. e2e (cookie-reload.e2e.test.ts): login → reload → re-renders + chat
    5. e2e (ws-kill-reconnect.e2e.test.ts): kill WS → reconnect → still renders + chat round-trips

  LoginSceneInitData shape (existing, find at LoginScene.ts:26-51):
    interface LoginSceneInitData {
      fastPath?: boolean;
      username?: string;
      // ADD: sessionToken?: string;
    }

  GameSceneInitData shape (existing, find at GameScene.ts top):
    interface GameSceneInitData {
      sessionToken?: string;       // already present per LoginScene form-submit path
      username?: string;
      mustForceReset?: boolean;
    }

  InputDispatcher API (existing, find at apps/client/src/net/InputDispatcher.ts):
    class InputDispatcher {
      constructor(public room: Room, ...);
      // ADD: setRoom(room: Room): void { this.room = room; /* preserve any internal buffer state */ }
    }

  E2E fixtures (existing, apps/client/test/e2e/fixtures.ts):
    loginAs(page, account)              — drives form submit
    waitForGameReady(page)              — waits for canvas[data-game-ready=true]
    accountA / accountB                 — UAT seed accounts
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: Option A token-threading edits (BootScene + LoginScene) + Option B self-heal (GameScene) + LoginScene unit test</name>
    <files>
      apps/client/src/scenes/BootScene.ts,
      apps/client/src/scenes/LoginScene.ts,
      apps/client/src/scenes/GameScene.ts,
      apps/client/src/__test__/login-scene.test.ts,
      apps/client/src/__test__/game-scene.test.ts
    </files>
    <read_first>
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md (whole file — input contract),
      apps/client/src/scenes/BootScene.ts (lines 78-95 — current cookie-fetch + scene.start to LoginScene),
      apps/client/src/scenes/LoginScene.ts (lines 26-51 init data shape; lines 80-116 fast-path render; lines 173-177 form-submit-path scene.start as the canonical reference),
      apps/client/src/scenes/GameScene.ts (lines 191-192 connect early-return; line 238-252 InputDispatcher; init data shape + create() lifecycle),
      apps/client/src/auth/client.ts (lines 84-108 — getSession returns SessionShape with token),
      apps/client/src/__test__/game-scene.test.ts (existing test harness shape — copy imports + describe/it pattern; mock createAuthClient or getSession appropriately),
      apps/client/src/__test__/login-scene.test.ts (existing if any; if absent, model after game-scene.test.ts)
    </read_first>
    <behavior>
      - BootScene: when getSession() returns a non-null session, scene.start('LoginScene', { fastPath: true, username, sessionToken: session.token }) — adds the third field
      - LoginSceneInitData type adds sessionToken?: string; LoginScene stores it on the instance in init()
      - LoginScene.renderFastPath (the timer that transitions to GameScene at line 106-109): scene.start('GameScene', { sessionToken: this.sessionToken, username: this.username }) — matches form-submit-path shape
      - GameScene.create(): if this.sessionToken is undefined AND init data carried { username }, call getSession() asynchronously; on resolve, adopt session.token into this.sessionToken; then call connect(). This is Option B — defensive against future scene-init shape drift
      - GameScene.connect early-return at line 191-192 stays as a defensive guard but should now never fire on the cookie path
      - Vitest tests: LoginScene fast-path forwards sessionToken; GameScene cookie-path self-heal — getSession mock returns token + assert joinRebnoRoom IS called with that token
    </behavior>
    <action>
  **1. apps/client/src/scenes/BootScene.ts** — Tag `// [impl->REQ-CLI-09] [impl->REQ-CLI-04]` near the cookie-fetch block. Find lines 78-95:

  Current:
  ```ts
  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 });
  }
  ```

  Change to:
  ```ts
  // [impl->REQ-CLI-09] [impl->REQ-CLI-04]
  // Option A: forward session.token alongside username so LoginScene fast-path can
  // pass it to GameScene (otherwise GameScene.connect() silently early-returns at
  // the !sessionToken guard — UAT Finding #2 / D-25).
  const session = await getSession().catch(() => null);
  ...
  if (session) {
    this.scene.start('LoginScene', {
      fastPath: true,
      username: session.user.username,
      sessionToken: session.token,
    });
  } else {
    this.scene.start('LoginScene', { fastPath: false });
  }
  ```

  **2. apps/client/src/scenes/LoginScene.ts** — Tag `// [impl->REQ-CLI-09] [impl->REQ-CLI-04]` near the init data type + at the renderFastPath site.

  Find LoginSceneInitData (lines 26-51 area):
  ```ts
  interface LoginSceneInitData {
    fastPath?: boolean;
    username?: string;
    // ADD:
    sessionToken?: string;
  }
  ```

  Find init() method — store sessionToken on the instance:
  ```ts
  init(data: LoginSceneInitData = {}): void {
    this.fastPath = !!data.fastPath;
    this.username = data.username;
    this.sessionToken = data.sessionToken;  // ADD
    // ... existing init body
  }
  ```

  Find renderFastPath / fastPathTimer (lines 106-109):
  ```ts
  this.fastPathTimer = this.time.delayedCall(500, () => {
    spinner.destroy();
    // OPTION A edit 3: forward sessionToken (matches form-submit-path shape at line 173-177)
    this.scene.start('GameScene', {
      sessionToken: this.sessionToken,
      username: this.username,
    });
  });
  ```

  **3. apps/client/src/scenes/GameScene.ts** — Tag `// [impl->REQ-CLI-09] [impl->REQ-CLI-04]` near create() + the early-return guard.

  In create() — Option B self-heal:
  ```ts
  create(): void {
    // ... existing chatHud/banner/escMenu mount ...
    // [impl->REQ-CLI-09] [impl->REQ-CLI-04]
    // Option B: if init data did not carry sessionToken (e.g. future scene-init refactor
    // breaks the cookie path again), self-heal by re-fetching the session and adopting
    // its token. Defence-in-depth alongside Option A.
    if (!this.sessionToken && this.username) {
      void this.recoverSessionTokenAndConnect();
    } else {
      void this.connect(env.wssUrl);
    }
  }

  private async recoverSessionTokenAndConnect(): Promise<void> {
    try {
      const session = await getSession();
      if (session?.token) {
        this.sessionToken = session.token;
      }
    } catch (err) {
      console.warn('[GameScene] recoverSessionTokenAndConnect: getSession failed', err);
    }
    await this.connect(env.wssUrl);
  }
  ```

  Leave the existing `if (!this.sessionToken) return;` guard at line 191-192 as a defensive check — Option B feeds the token before connect() is called, so the guard now never fires on the cookie path.

  (Adapt to the actual create() control flow — if create() already invokes connect() at the bottom, gate that call behind the same async-recovery branch. The intent: connect() always sees a token by the time it runs on the cookie path.)

  **4. apps/client/src/__test__/login-scene.test.ts** — Tag `// [unit->REQ-CLI-09]`. New file or extend existing.

  ```ts
  // [unit->REQ-CLI-09]
  import { describe, it, expect, vi, beforeEach } from 'vitest';
  import { LoginScene } from '../scenes/LoginScene';

  describe('LoginScene fast-path token threading (regression for UAT Finding #2)', () => {
    it('forwards sessionToken to GameScene when fastPath + sessionToken provided', () => {
      const scene = new LoginScene();
      // Mock the Phaser scene API surface needed
      const sceneStartSpy = vi.fn();
      (scene as any).scene = { start: sceneStartSpy };
      (scene as any).time = { delayedCall: (_ms: number, cb: () => void) => cb() }; // execute immediately
      (scene as any).add = { text: () => ({ destroy: vi.fn() }) }; // minimal stub for spinner

      scene.init({ fastPath: true, username: 'me', sessionToken: 'tok-123' });
      // call the fast-path render method (or invoke create() if fast path runs from there)
      (scene as any).renderFastPath?.() ?? scene.create?.();

      expect(sceneStartSpy).toHaveBeenCalledWith('GameScene', expect.objectContaining({
        sessionToken: 'tok-123',
        username: 'me',
      }));
    });

    it('does NOT forward sessionToken when fastPath is false (form-submit path takes over)', () => {
      const scene = new LoginScene();
      const sceneStartSpy = vi.fn();
      (scene as any).scene = { start: sceneStartSpy };
      scene.init({ fastPath: false });
      // form-submit path is exercised by other tests; here just assert fast-path doesn't fire
      expect(sceneStartSpy).not.toHaveBeenCalled();
    });
  });
  ```

  (Adapt the mocked scene API to LoginScene's actual Phaser dependencies — the executor reads the file and stubs only what's needed.)

  **5. apps/client/src/__test__/game-scene.test.ts** — Add cookie-path test. Tag `// [unit->REQ-CLI-09] [unit->REQ-CLI-04]`.

  ```ts
  // [unit->REQ-CLI-09] [unit->REQ-CLI-04]
  import { describe, it, expect, vi } from 'vitest';
  import { GameScene } from '../scenes/GameScene';
  import * as authClient from '../auth/client';
  import * as colyseusClient from '../net/colyseus-client';

  describe('GameScene cookie-path self-heal (Option B regression)', () => {
    it('init({ username }) without sessionToken → getSession returns token → joinRebnoRoom called with token', async () => {
      const joinSpy = vi.spyOn(colyseusClient, 'joinRebnoRoom').mockResolvedValue({ /* mock Room */ } as any);
      vi.spyOn(authClient, 'getSession').mockResolvedValue({ user: { username: 'me' }, token: 'recovered-tok' } as any);

      const scene = new GameScene();
      // Stub Phaser surface as needed
      (scene as any).cameras = { main: { setBackgroundColor: vi.fn() } };
      (scene as any).load = { atlas: vi.fn() };
      (scene as any).add = { sprite: vi.fn() };

      scene.init({ username: 'me' }); // no sessionToken — cookie path drift simulation
      await scene.create();

      // Allow any pending microtasks (recoverSessionTokenAndConnect) to resolve
      await new Promise((r) => setTimeout(r, 50));

      expect(authClient.getSession).toHaveBeenCalled();
      expect(joinSpy).toHaveBeenCalledWith(
        expect.anything(),
        expect.objectContaining({ token: 'recovered-tok' }),
      );
    });

    it('init({ sessionToken, username }) → joinRebnoRoom called immediately without getSession', async () => {
      const joinSpy = vi.spyOn(colyseusClient, 'joinRebnoRoom').mockResolvedValue({ /* mock Room */ } as any);
      const sessionSpy = vi.spyOn(authClient, 'getSession');

      const scene = new GameScene();
      (scene as any).cameras = { main: { setBackgroundColor: vi.fn() } };
      (scene as any).load = { atlas: vi.fn() };
      (scene as any).add = { sprite: vi.fn() };

      scene.init({ sessionToken: 'tok-direct', username: 'me' });
      await scene.create();
      await new Promise((r) => setTimeout(r, 50));

      expect(sessionSpy).not.toHaveBeenCalled(); // Option A path — no recovery needed
      expect(joinSpy).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ token: 'tok-direct' }));
    });
  });
  ```

  (Adjust mocks to match the actual joinRebnoRoom + getSession signatures.)
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test -- src/__test__/login-scene.test.ts src/__test__/game-scene.test.ts &amp;&amp; pnpm --filter @rebno/client typecheck</automated>
    </verify>
    <done>
      BootScene forwards session.token; LoginScene init data accepts sessionToken + fast-path forwards it; GameScene create() self-heals via getSession when sessionToken absent. Two vitest test files lock both Option A and Option B paths. typecheck green. Cookie auto-login no longer renders blank canvas (manual smoke deferred to Task 3 e2e + 06-17 UAT).
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: InputDispatcher.setRoom + GameScene refresh on every reconnect + dispatcher unit test</name>
    <files>
      apps/client/src/net/InputDispatcher.ts,
      apps/client/src/scenes/GameScene.ts,
      apps/client/src/__test__/input-dispatcher.test.ts
    </files>
    <read_first>
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md §5 (secondary defect — verbatim),
      apps/client/src/net/InputDispatcher.ts (current shape — find the room field; check whether it's exposed as constructor-only or settable),
      apps/client/src/scenes/GameScene.ts (lines 238-252 — current `if (!this.inputDispatcher)` guard)
    </read_first>
    <behavior>
      - InputDispatcher exposes setRoom(room) — refreshes the internal room reference without recreating the dispatcher (preserves any pending input buffer / sequence counter state)
      - GameScene.connect (or wherever the post-reconnect re-bind happens, around line 238-252): replaces `if (!this.inputDispatcher) { ... new InputDispatcher(this.room, ...) }` with: if first connect → create; if subsequent reconnect → setRoom(this.room)
      - Unit test: simulate two-step lifecycle (first connect → dispatch creation; silent_reauth → setRoom) and assert dispatcher.room === gameScene.room (referential equality on the new room object)
    </behavior>
    <action>
  **1. apps/client/src/net/InputDispatcher.ts** — Tag `// [impl->REQ-CLI-09] [impl->REQ-CLI-04]` near the new method.

  ```ts
  // [impl->REQ-CLI-09] [impl->REQ-CLI-04]
  export class InputDispatcher {
    // ... existing fields including `private _room: Room` (or `public room: Room`) ...

    constructor(room: Room, /* ... */) {
      this._room = room;
      // ... existing constructor body ...
    }

    /** Refresh the room reference after a silent_reauth without recreating the
     *  dispatcher — preserves any internal sequence counter / buffer state.
     *  Closes UAT Finding #2 secondary defect (06-12-DEBUG-FINDINGS §5). */
    setRoom(room: Room): void {
      this._room = room;
    }

    /** Public read for tests */
    get room(): Room { return this._room; }

    // ... existing dispatch() etc. — all internal references go through this._room
  }
  ```

  (If InputDispatcher already exposes `room` as a public readonly field, change to a private + getter+setter without breaking existing callers. Tests rely on `.room` read access.)

  **2. apps/client/src/scenes/GameScene.ts** — Tag `// [impl->REQ-CLI-09]` near the InputDispatcher block (lines 238-252).

  ```ts
  // [impl->REQ-CLI-09]
  // Refresh dispatcher's room ref after silent_reauth. Without this, the
  // `if (!this.inputDispatcher)` guard left a closed/garbage-collected room
  // reference inside the dispatcher → movement intents went to /dev/null
  // (06-12-DEBUG-FINDINGS §5 secondary defect, in scope here per CONTEXT
  // addendum line 361).
  if (!this.inputDispatcher) {
    this.inputDispatcher = new InputDispatcher(this.room, stubPrediction);
  } else {
    this.inputDispatcher.setRoom(this.room);
  }
  ```

  **3. apps/client/src/__test__/input-dispatcher.test.ts** — New or extended. Tag `// [unit->REQ-CLI-09]`.

  ```ts
  // [unit->REQ-CLI-09]
  import { describe, it, expect } from 'vitest';
  import { InputDispatcher } from '../net/InputDispatcher';

  describe('InputDispatcher.setRoom (silent_reauth fix — 06-12-DEBUG-FINDINGS §5)', () => {
    it('exposes setRoom that refreshes the internal room reference', () => {
      const room1 = { send: () => {}, sessionId: 'r1' } as any;
      const room2 = { send: () => {}, sessionId: 'r2' } as any;
      const stubPrediction = { /* minimal stub */ } as any;
      const d = new InputDispatcher(room1, stubPrediction);
      expect(d.room).toBe(room1);
      d.setRoom(room2);
      expect(d.room).toBe(room2);
    });

    it('preserves internal state across setRoom (no buffer reset)', () => {
      // If InputDispatcher carries a sequence counter or input buffer, the
      // executor adds an assertion here that it survives the setRoom call.
      const room1 = { send: () => {}, sessionId: 'r1' } as any;
      const room2 = { send: () => {}, sessionId: 'r2' } as any;
      const d = new InputDispatcher(room1, {} as any);
      // Simulate one input dispatch (advance internal seq counter if exposed)
      if ((d as any).dispatch) (d as any).dispatch({ axes: { x: 1, y: 0 } });
      const seqBefore = (d as any).seq ?? null;
      d.setRoom(room2);
      const seqAfter = (d as any).seq ?? null;
      if (seqBefore !== null) expect(seqAfter).toBe(seqBefore);
    });
  });
  ```

  (Adapt mocks to InputDispatcher's actual constructor signature.)
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test -- src/__test__/input-dispatcher.test.ts &amp;&amp; pnpm --filter @rebno/client typecheck</automated>
    </verify>
    <done>
      InputDispatcher.setRoom shipped + 2 vitest tests GREEN. GameScene's reconnect path uses setRoom on subsequent connects (refresh) instead of leaving a stale ref. typecheck green. Movement after silent_reauth now reaches the new room object (manual smoke deferred to Task 3 e2e).
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: Two new Playwright e2e files — cookie-reload + ws-kill-reconnect</name>
    <files>
      apps/client/test/e2e/cookie-reload.e2e.test.ts,
      apps/client/test/e2e/ws-kill-reconnect.e2e.test.ts
    </files>
    <read_first>
      apps/client/test/e2e/cli-08.e2e.test.ts (canonical e2e shape — copy describe/test imports + loginAs + waitForGameReady fixture usage),
      apps/client/test/e2e/fixtures.ts (loginAs / waitForGameReady / accountA / inviteSuffix; reuse all),
      apps/client/playwright.config.ts (verify auto-discovery picks up *.e2e.test.ts; both modes — local webServer + STAGING_URL — work),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-DEBUG-FINDINGS.md §6 (test sketches 3 + 4 — verbatim assertion list)
    </read_first>
    <behavior>
      - cookie-reload.e2e.test.ts: login + assert canvas[data-game-ready=true] → page.reload() → re-assert canvas[data-game-ready=true] within 5s → send a chat message → assert [data-chat-line] appears
      - ws-kill-reconnect.e2e.test.ts: login + assert canvas[data-game-ready=true] → page.evaluate close the WS → wait for SDK auto-reconnect (within Phase 4 D-22 grace = 10s) → re-assert canvas[data-game-ready=true] still truthy → send a chat message + assert round-trip
      - Both tests use existing fixtures; no new account seeding; runs in both local + STAGING_URL modes per playwright.config.ts
      - Tests are timing-tolerant (use waitForSelector with timeout, not arbitrary sleep)
    </behavior>
    <action>
  **1. apps/client/test/e2e/cookie-reload.e2e.test.ts** — Tag `// [int->REQ-CLI-09] [int->REQ-CLI-04]`.

  ```ts
  // [int->REQ-CLI-09] [int->REQ-CLI-04]
  // Regression guard for UAT Finding #2 (D-25): cookie auto-login renders blank.
  // Per 06-12-DEBUG-FINDINGS §6 test #3.

  import { test, expect } from '@playwright/test';
  import { loginAs, waitForGameReady, accountA } from './fixtures';

  test.describe('Cookie auto-login fast-path (regression for UAT Finding #2)', () => {
    test('login → reload → GameScene re-renders within 5s + chat works', async ({ page }) => {
      await loginAs(page, accountA);
      await waitForGameReady(page);

      // Reload — exercises the BootScene → cookie probe → LoginScene fast-path → GameScene chain
      await page.reload();

      // Must re-render within 5s (Option A token threading + Option B self-heal)
      await expect(page.locator('canvas[data-game-ready=true]')).toBeVisible({ timeout: 5000 });

      // Chat must work post-reload (proves bindHandlers fired → onMessage('chat') wired up)
      await page.keyboard.press('Enter');
      await page.keyboard.type('hello-after-reload');
      await page.keyboard.press('Enter');
      await expect(page.locator('[data-chat-line]').filter({ hasText: 'hello-after-reload' })).toBeVisible({ timeout: 5000 });
    });
  });
  ```

  **2. apps/client/test/e2e/ws-kill-reconnect.e2e.test.ts** — Tag `// [int->REQ-CLI-09] [int->REQ-CLI-04]`.

  ```ts
  // [int->REQ-CLI-09] [int->REQ-CLI-04]
  // Regression guard for UAT Finding #2 secondary path + UAT Test 5.
  // Per 06-12-DEBUG-FINDINGS §6 test #4.

  import { test, expect } from '@playwright/test';
  import { loginAs, waitForGameReady, accountA } from './fixtures';

  test.describe('WS-kill within grace → SDK auto-reconnect (regression for UAT Finding #2 + Test 5)', () => {
    test('kill WS → SDK reconnects within grace → canvas still truthy + chat round-trips', async ({ page }) => {
      await loginAs(page, accountA);
      await waitForGameReady(page);

      // Kill the WS — Colyseus SDK should auto-reconnect within 10s grace per Phase 4 D-22
      await page.evaluate(() => {
        const r = (window as any).__rebno?.room;
        if (r?.connection?.transport?.ws?.close) r.connection.transport.ws.close();
        else if (r?.connection?.close) r.connection.close();
      });

      // Reconnect banner appears briefly; then disappears on successful reconnect
      // canvas[data-game-ready=true] must remain truthy throughout the within-grace path
      // (the SDK preserves the same Room object — state callbacks survive)
      // Wait up to 12s for any reconnect-related transient to settle
      await page.waitForTimeout(2000); // allow banner to surface
      await expect(page.locator('canvas[data-game-ready=true]')).toBeVisible({ timeout: 12000 });

      // Verify movement intents still reach the room (closes secondary defect §5)
      // by exercising chat round-trip — chat goes through room.send too
      await page.keyboard.press('Enter');
      await page.keyboard.type('hello-after-reconnect');
      await page.keyboard.press('Enter');
      await expect(page.locator('[data-chat-line]').filter({ hasText: 'hello-after-reconnect' })).toBeVisible({ timeout: 5000 });
    });
  });
  ```

  (Both tests rely on the existing `__rebno.room` dev-hook globals — verify these are exposed in the client bundle for the test environment; they may already be wired per 06-08 SUMMARY for the existing reconnect e2e.)
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test:e2e -- --grep "Cookie auto-login|WS-kill"</automated>
    </verify>
    <done>
      Both new e2e files pass GREEN locally (against the dev-mode webServer per playwright.config.ts). Cookie-reload and WS-kill paths both render the canvas + round-trip chat. Three regression scenarios from 06-12-DEBUG-FINDINGS §6 (tests 3, 4, and the implicit movement-after-reconnect proof) are now machine-checked.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Cookie auto-login → GameScene WS auth | Bearer-only contract preserved — token must transit init data correctly OR be self-recovered via getSession; never trust a missing token to mean "anonymous" |
  | Silent_reauth → InputDispatcher | Old room object is closed; sending to it = silent message loss; setRoom refresh enforces same-room invariant |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-15-01 | Spoofing | Adversary forges init data with someone else's sessionToken | accept | Server-side bearer validation in onAuth (RebnoRoom.ts:267-269) is the trust boundary; client init data is ephemeral process state, no cross-user vector |
  | T-06-15-02 | DoS | getSession() self-heal hangs → GameScene never connects → UX deadlock | mitigate | Option B uses try/catch + eventual fallback to defensive early-return; reconnect banner surfaces if connect() fails after recovery |
  | T-06-15-03 | Tampering | Future PR removes setRoom call → silent_reauth re-introduces stale room ref | mitigate | input-dispatcher.test.ts pins setRoom existence + behavior; ws-kill-reconnect.e2e.test.ts pins the end-to-end behavior; trace:check holds tags |
  | T-06-15-04 | Repudiation | "We don't know which path the bug shipped on" | mitigate | Two distinct e2e files (cookie-reload + ws-kill-reconnect) match UAT reproducer steps verbatim; report logs preserve evidence |
  </threat_model>

  <verification>
  - `pnpm --filter @rebno/client test` exits 0 (LoginScene + GameScene + InputDispatcher unit tests added; nothing else regresses)
  - `pnpm --filter @rebno/client test:e2e` exits 0 (existing cli-08 + login-contrast + logout + new cookie-reload + ws-kill-reconnect)
  - `pnpm --filter @rebno/client typecheck` exits 0
  - Manual smoke (deferred to 06-17): operator logs in, reloads, sees content; operator logs in, kills WS via DevTools, sees content remain
  - `pnpm verify:phase-6` passes end-to-end after Wave 11+12 land
  - `pnpm trace:check` reports `[impl->REQ-CLI-09] [impl->REQ-CLI-04] [unit->REQ-CLI-09] [int->REQ-CLI-09]` tags satisfied
  - ZERO modifications to apps/server/src/RebnoRoom.ts (server bearer-only contract preserved per 06-12 §7)
  </verification>

  <success_criteria>
  - UAT Finding #2 (D-25) closed: cookie auto-login + reload + WS-kill all render the canvas with content + chat works
  - §5 secondary defect (stale InputDispatcher.room after silent_reauth) closed: dispatcher always points at current room
  - Both Option A (token threading — three edits) AND Option B (getSession self-heal) shipped — defence in depth
  - Five regression tests landed (3 vitest + 2 Playwright) — pinning cookie path, fast-path forwarding, dispatcher refresh, reload-render, ws-kill-render
  - ZERO server-side changes — bearer-only WS auth preserved per 06-12-DEBUG-FINDINGS §7
  - Tags `[impl/int->REQ-CLI-09] [impl->REQ-CLI-04]` (per gap-closure addendum line 361)
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-15-SUMMARY.md` recording:
  (a) Three Option A edit locations confirmed (BootScene, LoginScene init data, LoginScene fast-path)
  (b) Option B self-heal path confirmed (GameScene.create + recoverSessionTokenAndConnect helper or equivalent)
  (c) InputDispatcher API change (setRoom signature)
  (d) Test results: 3 vitest GREEN + 2 Playwright GREEN (paste counts)
  (e) Confirmation that apps/server/src/RebnoRoom.ts is untouched
  (f) Trace tag verification snippet for REQ-CLI-09 + REQ-CLI-04 across impl, unit, int stages
  </output>
  </content>
  </invoke>```

  ### Plan 06-16
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 16
  type: execute
  wave: 11
  depends_on: [06-13, 06-14]
  files_modified:
    - apps/client/src/render/SpriteStateMachine.ts
    - apps/client/src/render/PlayerRenderer.ts
    - apps/client/src/render/Nameplate.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/__test__/sprite-state-machine.test.ts
    - apps/client/src/__test__/nameplate.test.ts
    - apps/client/test/e2e/cli-08.e2e.test.ts
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SPRITE-STATE-DERIVATION.md
  autonomous: true
  requirements: [CLI-04, CLI-07]
  must_haves:
    truths:
      - "8-direction facing is derived deterministically from the velocity vector (x, y) — pure function vec2 → ('D' | 'U' | 'L' | 'R' | 'DR' | 'DL' | 'UR' | 'UL'); zero-velocity preserves the last facing direction (no flicker on stop)"
      - "Walk-cycle phase advances by step counter at TICK_RATE_HZ (from 06-13 constants); cycle length matches BNO frame_count for the relevant Run sprite (NaviRunD = 6 frames per 06-14 manifest)"
      - "Stand vs Run sprite selection: when |velocity| > 0 → Run<DIR> with cycle phase; when |velocity| === 0 → Stand<DIR> single frame"
      - "All 16 player atlas frames from 06-14 are addressable (Stand × 8: 0000-StandD, 0029-StandR, 0030-StandU, 0033-StandL, 0036-StandDR, 0038-StandUR, 0040-StandUL, 0042-StandDL; Run × 8: 0027-RunD, 0028-RunR, 0031-RunU, 0032-RunL, 0037-RunDR, 0039-RunUR, 0041-RunUL, 0043-RunDL)"
      - "Cyan nametag (D-27a) renders above player sprite head — anchored top-center, follows movement, visible for self + remotes; cyan color from extracted 0000-server Draw.gml (verify before locking; UI-SPEC §Color #22D3EE accent is the fallback if no exact value extractable)"
      - "Frame-perfect to BNO: 0000-server Step + KeyPress events are the canonical reference for facing-on-input semantics (do they update facing on keydown OR on next Step?); the SpriteStateMachine matches this timing"
      - "Updated cli-08.e2e.test.ts asserts: when player moves in direction X, the sprite frame is the corresponding RunX_<phase> frame (not StandD); when stopped, it's the StandX (last-direction) frame; nameplate text is visible above player at all times"
    artifacts:
      - path: apps/client/src/render/SpriteStateMachine.ts
        provides: "Pure function: deriveFrame(velX, velY, lastFacing, cyclePhase) → { frameKey: string, newFacing: Direction }; consumed by PlayerRenderer per tick"
        contains: "deriveFrame"
      - path: apps/client/src/render/PlayerRenderer.ts
        provides: "Per-tick: read velocity → call SpriteStateMachine.deriveFrame → sprite.setFrame(frameKey); applies to self + remotes (remotes use input_axes from PlayerState per Phase 6 D-08)"
        contains: "deriveFrame"
      - path: apps/client/src/render/Nameplate.ts
        provides: "Phaser Text per remote (and self) above sprite head; cyan color; follows position each frame; mirror DOM `<div data-nameplate>` for Playwright per UI-SPEC surface inventory"
        contains: "Nameplate"
      - path: apps/client/src/__test__/sprite-state-machine.test.ts
        provides: "Pure-function tests: 8 direction quadrants, zero-velocity preserves last facing, cycle phase increments correctly, Stand vs Run switch on velocity threshold"
        contains: "deriveFrame"
      - path: apps/client/src/__test__/nameplate.test.ts
        provides: "Nameplate tests: cyan color (#22D3EE or extracted exact value), top-center anchor, follows position, DOM mirror node carries username"
        contains: "Nameplate"
      - path: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SPRITE-STATE-DERIVATION.md
        provides: "Doc-only: maps each of 8 facing directions to the atlas frame; documents BNO timing semantics (facing-on-keydown vs on-next-step); records cyan exact value derivation from 0000-server Draw.gml or fallback"
        contains: "0000-server"
    key_links:
      - from: apps/client/src/render/SpriteStateMachine.ts
        to: apps/client/src/render/PlayerRenderer.ts
        via: "PlayerRenderer.update(dt) calls deriveFrame(...) each tick → sprite.setFrame(frameKey)"
        pattern: "deriveFrame|setFrame"
      - from: apps/client/src/render/PlayerRenderer.ts
        to: packages/game-logic constants (TICK_RATE_HZ from 06-13)
        via: "cycle phase advance uses TICK_RATE_HZ for deterministic timing"
        pattern: "TICK_RATE_HZ"
      - from: apps/client/src/render/PlayerRenderer.ts
        to: PlayerState.input_axes (Phase 6 D-08 schema field)
        via: "remote players use input_axes (broadcast from server per D-08) as the velocity vector input to deriveFrame"
        pattern: "input_axes"
      - from: apps/client/src/render/Nameplate.ts
        to: PlayerState.username
        via: "Nameplate.setText(player.username); positioned above sprite each frame"
        pattern: "username"
      - from: apps/client/test/e2e/cli-08.e2e.test.ts
        to: data-player-frame attribute (or similar dev-hook)
        via: "Phaser sprite frame name surfaced via dev-hook OR canvas pixel-sample comparison; e2e asserts Run<DIR> frame after move"
        pattern: "data-player-frame|RunD"
  ---

  <objective>
  Close UAT Finding #3b (D-26 Plan B) and ship D-27a nametag: build the 8-direction sprite-state machine that drives PlayerRenderer's frame selection from the velocity vector + walk-cycle phase, mapped onto the player atlas frames bootstrapped in 06-14, using the deterministic numeric constants (TICK_RATE_HZ etc.) from 06-13. Add the cyan nametag rendered above each player sprite (self + remotes), anchored top-center, following movement.

  Why this combines two findings into one plan: D-27a explicitly says the nametag belongs to the per-player render path (the natural home is wherever 8-direction sprite selection lives — same module). 06-16 owns that module; bundling cuts a useless plan-boundary in half. CONTEXT addendum line 339 lock-in confirms this scoping.

  Frame-perfect to BNO: read `extracted/client-5-8/objects/0000-server/events/Step.gml` + `Keyboard-37/38/39/40.gml` + `KeyRelease-*.gml` + `Draw.gml` to confirm facing-on-input timing semantics (does facing update on keydown OR on next Step tick? does Run start on tick 0 or tick 1?) and extract the exact cyan color used by the original draw call (the UI-SPEC §Color #22D3EE accent is the fallback if 0000-server Draw.gml does not specify an exact text-color value).

  Per CONTEXT addendum line 362 — `[impl/unit->REQ-CLI-04]` (movement prediction; sprite-state); D-27a nametag also touches CLI-07 (room rendering, since nameplate sits in the room space).

  Output: 1 new pure-function module (SpriteStateMachine) + 1 modified renderer (PlayerRenderer) + 1 new UI module (Nameplate) + GameScene wiring + 2 vitest unit files + cli-08.e2e.test.ts assertion update + derivation doc.
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-PATTERNS.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md
  @apps/client/src/render/PlayerRenderer.ts
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/__test__/player-renderer.test.ts
  @packages/game-logic/src/constants.ts
  @packages/protocol/src/state.ts
  @extracted/client-5-8/objects/0000-server/events/Step.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-37.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-38.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-39.gml
  @extracted/client-5-8/objects/0000-server/events/Keyboard-40.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-37.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-38.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-39.gml
  @extracted/client-5-8/objects/0000-server/events/KeyRelease-40.gml
  @extracted/client-5-8/objects/0000-server/events/Draw.gml

  <interfaces>
  <!-- Atlas frame keys produced by 06-14 (15 player sprite IDs × frame_count) -->

  Standing frames (1 frame each — frameKey suffix _0):
    '0000-NaviStandD_0'  — facing DOWN
    '0029-NaviStandR_0'  — facing RIGHT
    '0030-NaviStandU_0'  — facing UP
    '0033-NaviStandL_0'  — facing LEFT
    '0036-NaviStandDR_0' — facing DOWN-RIGHT (SE)
    '0038-NaviStandUR_0' — facing UP-RIGHT (NE)
    '0040-NaviStandUL_0' — facing UP-LEFT (NW)
    '0042-NaviStandDL_0' — facing DOWN-LEFT (SW)

  Running frames (frame_count varies — typically 6; verify against 06-14 manifest):
    '0027-NaviRunD_<n>'  — n in [0..frame_count-1]
    '0028-NaviRunR_<n>'  — R-facing run (sprite exists in extracted/; restored 2026-05-10)
    '0031-NaviRunU_<n>'
    '0032-NaviRunL_<n>'
    '0037-NaviRunDR_<n>'
    '0039-NaviRunUR_<n>'
    '0041-NaviRunUL_<n>'
    '0043-NaviRunDL_<n>'

  Direction enum (lock at top of SpriteStateMachine.ts):
    type Direction = 'D' | 'U' | 'L' | 'R' | 'DR' | 'DL' | 'UR' | 'UL';

  Velocity → direction mapping (8-quadrant) — pure function:
    vec2 (vx, vy) →
      if (vx, vy) ≈ (0, 0) → preserve lastFacing
      else: angle = atan2(-vy, vx);  // -vy because screen y grows down
            map to nearest of 8 octants (each 45° wide):
              R   = [-22.5°, +22.5°]
              UR  = [+22.5°, +67.5°]
              U   = [+67.5°, +112.5°]
              UL  = [+112.5°, +157.5°]
              L   = [+157.5°, +180°] ∪ [-180°, -157.5°]
              DL  = [-157.5°, -112.5°]
              D   = [-112.5°, -67.5°]
              DR  = [-67.5°, -22.5°]
    (BNO 0000-server may use a different convention — verify against KeyPress events; e.g. some games use direction = `point_direction(0,0,hsp,vsp)` and quadrant via floor(direction/45). Match BNO if the implementation differs from this textbook formula.)

  Cycle phase advance:
    isRunning = |vec2| > 0
    if isRunning: cyclePhase = (cyclePhase + 1) mod frame_count_for(direction)
    if !isRunning: cyclePhase = 0  (or preserve — BNO behavior: most likely reset to 0 on stand)

  State output:
    if isRunning:  frameKey = `${runSpriteId(facing)}_${cyclePhase}`
    if !isRunning: frameKey = `${standSpriteId(facing)}_0`

  D-27a Nametag (UI-SPEC §Nameplate verbatim):
    - Phaser canvas Text per player (PERFORMANCE: cheaper than DOM at 50+ players, fine here)
    - Hidden DOM `<div data-nameplate data-player-id="<sessionId>">` mirror for Playwright selectors per UI-SPEC surface inventory line 23
    - Color: cyan — derive from 0000-server Draw.gml `draw_set_color(...)` call if present;
             fallback: UI-SPEC §Color accent #22D3EE
    - Font: monospace, 16px, weight 400 per UI-SPEC §Typography (or whatever Phaser's atlas font supports; if no font extracted in Phase 6, use the system monospace stack)
    - Anchor: top-center, 4px above sprite head (UI-SPEC spacing exception line 63: "Nameplate-to-sprite gap = 4px in canvas pixels")
    - Updates: each frame in PlayerRenderer.update(); positions follow sprite

  Per Phase 6 D-08: PlayerState carries `input_axes` for remote players → SpriteStateMachine consumes input_axes for remotes (so their sprite-state is derived deterministically, even during snapshot interpolation per D-11).

  Tagging:
    apps/client/src/render/SpriteStateMachine.ts → // [impl->REQ-CLI-04]
    apps/client/src/render/Nameplate.ts → // [impl->REQ-CLI-07] (nametag in room rendering)
    apps/client/src/render/PlayerRenderer.ts (header) → // [impl->REQ-CLI-04] [impl->REQ-CLI-07]
    apps/client/src/__test__/sprite-state-machine.test.ts → // [unit->REQ-CLI-04]
    apps/client/src/__test__/nameplate.test.ts → // [unit->REQ-CLI-07]
    apps/client/test/e2e/cli-08.e2e.test.ts (new assertions) → // [int->REQ-CLI-04]
    06-16-SPRITE-STATE-DERIVATION.md → [doc->REQ-CLI-04]
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: SpriteStateMachine pure module + 8-direction derivation doc + comprehensive unit tests</name>
    <files>
      apps/client/src/render/SpriteStateMachine.ts,
      apps/client/src/__test__/sprite-state-machine.test.ts,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SPRITE-STATE-DERIVATION.md
    </files>
    <read_first>
      extracted/client-5-8/objects/0000-server/events/Step.gml (canonical mover — find: how does it set sprite_index based on hsp/vsp? what variable carries facing?),
      extracted/client-5-8/objects/0000-server/events/Keyboard-37.gml + 38.gml + 39.gml + 40.gml (held-direction handlers — when does facing update? on each tick? once on keydown?),
      extracted/client-5-8/objects/0000-server/events/KeyRelease-37.gml + 38.gml + 39.gml + 40.gml (key-up — does facing reset to D? preserve last? other?),
      extracted/client-5-8/objects/0000-server/events/Draw.gml (sprite drawing call — does the original use draw_sprite_ext with a specific sprite_index? does it use image_speed for animation? this informs cycle-advance semantics),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-ASSET-MANIFEST.md (which sprite IDs exist + frame counts; all 16 player sprites present including 0028-NaviRunR),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-MOVEMENT-DERIVATION.md (TICK_RATE_HZ + SUB_PIXEL_ACCUMULATOR_MODE — informs cycle phase tick advance),
      packages/game-logic/src/constants.ts (TICK_RATE_HZ value to import for tests)
    </read_first>
    <behavior>
      - SpriteStateMachine.deriveFrame is pure: same input → same output; no Date.now, no Math.random, no I/O
      - Test 1: each of 8 cardinal/diagonal velocity directions produces the corresponding facing
      - Test 2: zero-velocity preserves lastFacing (no flicker)
      - Test 3: |velocity| > 0 returns Run<DIR>_<phase>; |velocity| === 0 returns Stand<DIR>_0
      - Test 4: cycle phase wraps mod frame_count after frame_count ticks
      - Test 5: BNO timing semantics — facing updates on the same tick as keydown OR on next Step (whatever 0000-server does — derivation doc records the truth)
      - Test 6: R-direction running maps to 0028-NaviRunR (sprite exists; verifies the full 8-direction Run coverage with no fallbacks)
    </behavior>
    <action>
  **1. Read the source GML.** Note observations. Common patterns to look for:

  | GML pattern | Meaning |
  |-------------|---------|
  | `sprite_index = NaviStandR;` (in Keyboard-39 = right) | Facing updates on keydown directly |
  | `direction = 0; sprite_index = sNaviRunR; image_speed = 0.5;` | Facing + animation speed set on input |
  | `if hsp != 0 || vsp != 0 { ... animate ... } else { sprite_index = sNaviStand<dir>; }` (in Step) | Stand vs Run derived from velocity in Step event |
  | `image_index += image_speed;` | GM5 auto-advances animation; image_speed is per-tick |

  **2. Write 06-16-SPRITE-STATE-DERIVATION.md** documenting:

  ```md
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 16
  type: doc-only-derivation
  status: complete
  ---

  # 8-Direction Sprite-State Machine — Derivation from Extracted Client 5-8 GML

  [doc->REQ-CLI-04]

  ## Source GML

  | File | What it informs |
  |------|-----------------|
  | `extracted/client-5-8/objects/0000-server/events/Step.gml` | Per-tick sprite_index assignment based on hsp/vsp |
  | `extracted/client-5-8/objects/0000-server/events/Keyboard-37/38/39/40.gml` | Held-direction → does sprite_index update HERE or in Step? |
  | `extracted/client-5-8/objects/0000-server/events/KeyRelease-*.gml` | On key-up: does sprite snap to Stand<lastDir>? |
  | `extracted/client-5-8/objects/0000-server/events/Draw.gml` | draw_sprite_ext call; cyan text-color reference (D-27a nametag) |

  ## Direction mapping (8 quadrants)

  | Velocity | atan2(-vy, vx) range | Facing | Stand sprite | Run sprite | Frame count |
  |----------|----------------------|--------|--------------|------------|-------------|
  | (+, 0)   | -22.5° to +22.5°     | R      | 0029-NaviStandR | 0028-NaviRunR | <n> |
  | (+, -)   | +22.5° to +67.5°     | UR     | 0038-NaviStandUR | 0039-NaviRunUR | <n> |
  | (0, -)   | +67.5° to +112.5°    | U      | 0030-NaviStandU | 0031-NaviRunU | <n> |
  | (-, -)   | +112.5° to +157.5°   | UL     | 0040-NaviStandUL | 0041-NaviRunUL | <n> |
  | (-, 0)   | ±157.5° to ±180°     | L      | 0033-NaviStandL | 0032-NaviRunL | <n> |
  | (-, +)   | -157.5° to -112.5°   | DL     | 0042-NaviStandDL | 0043-NaviRunDL | <n> |
  | (0, +)   | -112.5° to -67.5°    | D      | 0000-NaviStandD | 0027-NaviRunD | <n> |
  | (+, +)   | -67.5° to -22.5°     | DR     | 0036-NaviStandDR | 0037-NaviRunDR | <n> |

  ## Decisions

  ### Q1: ~~Missing 0028-NaviRunR~~ — RESOLVED 2026-05-10

  Sprite verified present at `extracted/client-5-8/sprites/0028-NaviRunR/` (has `meta.json` + `frames/`). Originally omitted from CONTEXT addendum line 336; plan-checker caught the gap. CONTEXT + 06-14 + this plan all updated to include 0028 across the full 8-direction Run set. No fallback strategy needed.

  ### Q2: Facing update timing — on keydown or on next Step?

  _(Read the Keyboard-* events. If they set `sprite_index` directly → facing-on-keydown. If they only set hsp/vsp and Step does sprite_index from velocity → facing-on-step.)_

  Decision: _<select + cite GML line>_

  ### Q3: Cycle phase advance rate

  _(Read Draw.gml or Step.gml for `image_speed = N`. GM5 image_speed is "frames per game tick" — image_speed=0.5 means advance one frame every 2 ticks.)_

  Cycle advance rule: cyclePhase += <derived> per tick. At TICK_RATE_HZ=<from 06-13> Hz, this means a full cycle takes <n> ms.

  ### Q4: KeyRelease facing behavior

  _(Does it preserve lastFacing? Reset to D? Other?)_

  Decision: _<derived>_

  ## Cyan nametag derivation (D-27a)

  _(Read Draw.gml — does it have a `draw_set_color(...)` call? Does it draw text? GM5 colors: c_aqua = 0xFFFF00, c_cyan = same. Hex BGR → RGB conversion required.)_

  Extracted color: _<value or "no draw_text call in 0000-server Draw.gml — using UI-SPEC §Color accent #22D3EE per fallback (CONTEXT addendum line 339)">_

  ## Out of scope (Phase 7)

  - Custom emote sprites (likely Phase 7 PAR-04)
  - Death / damage animations (Phase 7)
  - Per-character color schemes (Phase 7 PAR-06)
  ```

  **3. apps/client/src/render/SpriteStateMachine.ts** — Tag `// [impl->REQ-CLI-04]`. Pure module.

  ```ts
  // [impl->REQ-CLI-04]
  // 8-direction sprite-state machine. Pure function: vec2 + lastFacing + cyclePhase
  // → next frame key. No I/O, no Date.now, no Math.random — safe inside game-logic
  // purity rules and trivially testable.
  //
  // Derivation: see .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SPRITE-STATE-DERIVATION.md

  export type Direction = 'D' | 'U' | 'L' | 'R' | 'DR' | 'DL' | 'UR' | 'UL';

  const STAND_FRAME: Record<Direction, string> = {
    D:  '0000-NaviStandD_0',
    U:  '0030-NaviStandU_0',
    L:  '0033-NaviStandL_0',
    R:  '0029-NaviStandR_0',
    DR: '0036-NaviStandDR_0',
    DL: '0042-NaviStandDL_0',
    UR: '0038-NaviStandUR_0',
    UL: '0040-NaviStandUL_0',
  };

  const RUN_SPRITE_ID: Record<Direction, string> = {
    D:  '0027-NaviRunD',
    U:  '0031-NaviRunU',
    L:  '0032-NaviRunL',
    R:  '0028-NaviRunR',
    DR: '0037-NaviRunDR',
    DL: '0043-NaviRunDL',
    UR: '0039-NaviRunUR',
    UL: '0041-NaviRunUL',
  };

  // Frame counts from 06-14-ASSET-MANIFEST.md (verified at executor time)
  const RUN_FRAME_COUNT: Record<Direction, number> = {
    D: 6, U: <n>, L: <n>, R: <n>, DR: <n>, DL: <n>, UR: <n>, UL: <n>,
  };

  const VELOCITY_THRESHOLD = 0.001; // |v| ≤ this = stationary; tune if needed (sub-pixel accumulator may produce tiny non-zero velocities while standing)

  export function deriveFacing(vx: number, vy: number, lastFacing: Direction): Direction {
    if (Math.hypot(vx, vy) <= VELOCITY_THRESHOLD) return lastFacing;
    // Screen y grows down; angle in degrees, +x = right = 0°, +y_screen = down = -90°
    const angle = Math.atan2(-vy, vx) * (180 / Math.PI);
    // Normalize to [-180, 180]
    // Octant mapping (each 45° wide, centered on cardinal/diagonal axes)
    if (angle >= -22.5 && angle < 22.5)   return 'R';
    if (angle >=  22.5 && angle < 67.5)   return 'UR';
    if (angle >=  67.5 && angle < 112.5)  return 'U';
    if (angle >= 112.5 && angle < 157.5)  return 'UL';
    if (angle >= 157.5 || angle < -157.5) return 'L';
    if (angle >= -157.5 && angle < -112.5) return 'DL';
    if (angle >= -112.5 && angle < -67.5)  return 'D';
    return 'DR'; // angle >= -67.5 && angle < -22.5
  }

  export interface SpriteState {
    facing: Direction;
    cyclePhase: number;
    isRunning: boolean;
    frameKey: string;
  }

  export function deriveFrame(
    vx: number, vy: number,
    lastFacing: Direction,
    cyclePhase: number,
  ): SpriteState {
    const isRunning = Math.hypot(vx, vy) > VELOCITY_THRESHOLD;
    const facing = deriveFacing(vx, vy, lastFacing);
    if (!isRunning) {
      return { facing, cyclePhase: 0, isRunning: false, frameKey: STAND_FRAME[facing] };
    }
    const fc = RUN_FRAME_COUNT[facing] ?? 1;
    const newPhase = (cyclePhase + 1) % fc;
    return { facing, cyclePhase: newPhase, isRunning: true, frameKey: `${RUN_SPRITE_ID[facing]}_${newPhase}` };
  }
  ```

  **4. apps/client/src/__test__/sprite-state-machine.test.ts** — Tag `// [unit->REQ-CLI-04]`.

  ```ts
  // [unit->REQ-CLI-04]
  import { describe, it, expect } from 'vitest';
  import { deriveFacing, deriveFrame, type Direction } from '../render/SpriteStateMachine';

  describe('deriveFacing — 8 quadrants', () => {
    it.each([
      [1, 0, 'R'], [1, -1, 'UR'], [0, -1, 'U'], [-1, -1, 'UL'],
      [-1, 0, 'L'], [-1, 1, 'DL'], [0, 1, 'D'], [1, 1, 'DR'],
    ] as Array<[number, number, Direction]>)('vec(%d, %d) → %s', (vx, vy, expected) => {
      expect(deriveFacing(vx, vy, 'D')).toBe(expected);
    });

    it('zero velocity preserves lastFacing (no flicker)', () => {
      expect(deriveFacing(0, 0, 'UR')).toBe('UR');
      expect(deriveFacing(0, 0, 'L')).toBe('L');
    });

    it('sub-threshold velocity also preserves lastFacing', () => {
      expect(deriveFacing(0.0001, 0.0001, 'D')).toBe('D');
    });
  });

  describe('deriveFrame — Stand vs Run', () => {
    it('stationary → Stand<DIR>_0 frame, cycle reset to 0', () => {
      const r = deriveFrame(0, 0, 'D', 5);
      expect(r.isRunning).toBe(false);
      expect(r.frameKey).toBe('0000-NaviStandD_0');
      expect(r.cyclePhase).toBe(0);
    });

    it('moving → Run<DIR>_<phase> frame, cycle advances', () => {
      const r = deriveFrame(0, 1, 'D', 0);  // moving down
      expect(r.isRunning).toBe(true);
      expect(r.facing).toBe('D');
      expect(r.frameKey).toMatch(/^0027-NaviRunD_\d+$/);
      expect(r.cyclePhase).toBe(1);
    });

    it('cycle wraps after frame_count ticks', () => {
      let phase = 0;
      let last: Direction = 'D';
      for (let i = 0; i < 12; i++) {
        const r = deriveFrame(0, 1, last, phase);
        phase = r.cyclePhase;
        last = r.facing;
      }
      // After 12 ticks (assuming frame_count=6), we've wrapped twice
      expect(phase).toBe(0);
    });
  });
  ```

  (Update the cycle-wrap test's expected count once you confirm the frame_count from 06-14.)
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test -- src/__test__/sprite-state-machine.test.ts</automated>
    </verify>
    <done>
      SpriteStateMachine.ts shipped with pure deriveFacing + deriveFrame; 11+ vitest tests GREEN covering 8 directions + zero-velocity preservation + Stand-Run switch + cycle wrap. 06-16-SPRITE-STATE-DERIVATION.md fully populated with all 4 decision points (Q1 missing RunR, Q2 facing timing, Q3 cycle advance, Q4 KeyRelease behavior) + cyan derivation. No GameScene wiring yet (Task 2).
    </done>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: PlayerRenderer wires SpriteStateMachine + Nameplate (cyan, top-center) + GameScene wiring</name>
    <files>
      apps/client/src/render/PlayerRenderer.ts,
      apps/client/src/render/Nameplate.ts,
      apps/client/src/scenes/GameScene.ts,
      apps/client/src/__test__/nameplate.test.ts
    </files>
    <read_first>
      apps/client/src/render/PlayerRenderer.ts (current state — should now render 0000-NaviStandD per 06-14; this plan adds dynamic frame selection),
      apps/client/src/scenes/GameScene.ts (find: per-tick update loop where local + remote sprites are positioned; PlayerRenderer.update(dt) call site),
      packages/protocol/src/state.ts (PlayerState shape — confirm input_axes is on the schema per Phase 6 D-08; that's the velocity input for remotes),
      packages/game-logic/src/constants.ts (import TICK_RATE_HZ for cycle-phase advance),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-UI-SPEC.md §Color (line 91 — accent #22D3EE) + §Spacing (line 63 — Nameplate-to-sprite gap = 4px) + §Typography (16px / 400 / monospace),
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SPRITE-STATE-DERIVATION.md (cyan exact value derivation)
    </read_first>
    <behavior>
      - PlayerRenderer carries per-player state: { sprite, facing, cyclePhase, nameplate }
      - Each tick: read velocity (local: from PredictionEngine; remote: from PlayerState.input_axes per Phase 6 D-08); call deriveFrame(vx, vy, facing, cyclePhase); update sprite.setFrame; update local facing + cyclePhase
      - Nameplate is a Phaser.GameObjects.Text per player, color = derived cyan (06-16-SPRITE-STATE-DERIVATION.md), font = 'monospace' 16px, anchored top-center 4px above sprite head
      - Hidden DOM mirror `<div data-nameplate data-player-id="<sessionId>">username</div>` per UI-SPEC surface inventory line 23 — for Playwright selectors
      - Nameplate position updates each frame to follow sprite
      - Self also gets a nameplate (D-27a says self + remotes both)
    </behavior>
    <action>
  **1. apps/client/src/render/Nameplate.ts** — New file. Tag `// [impl->REQ-CLI-07]`.

  ```ts
  // [impl->REQ-CLI-07]
  // D-27a: cyan nametag above each player sprite (self + remotes).
  // Phaser canvas Text + hidden DOM mirror for Playwright selectors (UI-SPEC line 23).

  const NAMETAG_COLOR = '#22D3EE';  // UI-SPEC §Color accent fallback; if 06-16-SPRITE-STATE-DERIVATION.md extracted exact BNO cyan, use that instead
  const NAMETAG_FONT_SIZE = '16px';
  const NAMETAG_FONT_FAMILY = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace';
  const NAMETAG_OFFSET_Y = 4; // px above sprite head per UI-SPEC §Spacing line 63

  export class Nameplate {
    private text: Phaser.GameObjects.Text;
    private domMirror: HTMLDivElement | null = null;

    constructor(scene: Phaser.Scene, sessionId: string, username: string) {
      this.text = scene.add.text(0, 0, username, {
        fontFamily: NAMETAG_FONT_FAMILY,
        fontSize: NAMETAG_FONT_SIZE,
        color: NAMETAG_COLOR,
      }).setOrigin(0.5, 1);  // top-center anchor (origin x=0.5 center, y=1 bottom-of-text-box → text sits above the anchor point)

      // DOM mirror for Playwright selectors
      const overlay = document.getElementById('dom-overlay');
      if (overlay) {
        const mirror = document.createElement('div');
        mirror.setAttribute('data-nameplate', '');
        mirror.setAttribute('data-player-id', sessionId);
        mirror.textContent = username;
        mirror.style.display = 'none'; // selector-only; not visible UI
        overlay.appendChild(mirror);
        this.domMirror = mirror;
      }
    }

    /** Update position to follow the sprite. Call each frame. */
    follow(spriteX: number, spriteY: number, spriteHeight: number): void {
      this.text.setPosition(spriteX, spriteY - spriteHeight / 2 - NAMETAG_OFFSET_Y);
    }

    setText(username: string): void {
      this.text.setText(username);
      if (this.domMirror) this.domMirror.textContent = username;
    }

    destroy(): void {
      this.text.destroy();
      this.domMirror?.remove();
      this.domMirror = null;
    }
  }
  ```

  **2. apps/client/src/render/PlayerRenderer.ts** — Tag `// [impl->REQ-CLI-04] [impl->REQ-CLI-07]` near the new state-machine + nameplate wiring.

  Augment the per-player record:
  ```ts
  import { deriveFrame, type Direction } from './SpriteStateMachine';
  import { Nameplate } from './Nameplate';

  interface PlayerRenderState {
    sprite: Phaser.GameObjects.Sprite;
    nameplate: Nameplate;
    facing: Direction;
    cyclePhase: number;
  }

  private local: PlayerRenderState | null = null;
  private remotes = new Map<string, PlayerRenderState>();

  ensureLocal(scene: Phaser.Scene, x: number, y: number, sessionId: string, username: string): PlayerRenderState {
    if (this.local) return this.local;
    const sprite = scene.add.sprite(x, y, 'mvp', '0000-NaviStandD_0');
    const nameplate = new Nameplate(scene, sessionId, username);
    this.local = { sprite, nameplate, facing: 'D', cyclePhase: 0 };
    return this.local;
  }

  addRemote(scene: Phaser.Scene, x: number, y: number, sessionId: string, username: string): void {
    const sprite = scene.add.sprite(x, y, 'mvp', '0000-NaviStandD_0');
    const nameplate = new Nameplate(scene, sessionId, username);
    this.remotes.set(sessionId, { sprite, nameplate, facing: 'D', cyclePhase: 0 });
  }

  /** Per-tick update — call from GameScene.update(). */
  updateLocal(vx: number, vy: number, x: number, y: number): void {
    if (!this.local) return;
    const state = deriveFrame(vx, vy, this.local.facing, this.local.cyclePhase);
    this.local.sprite.setFrame(state.frameKey);
    this.local.sprite.setPosition(x, y);
    this.local.facing = state.facing;
    this.local.cyclePhase = state.cyclePhase;
    this.local.nameplate.follow(x, y, this.local.sprite.height);
  }

  updateRemote(sessionId: string, vx: number, vy: number, x: number, y: number): void {
    const r = this.remotes.get(sessionId);
    if (!r) return;
    const state = deriveFrame(vx, vy, r.facing, r.cyclePhase);
    r.sprite.setFrame(state.frameKey);
    r.sprite.setPosition(x, y);
    r.facing = state.facing;
    r.cyclePhase = state.cyclePhase;
    r.nameplate.follow(x, y, r.sprite.height);
  }
  ```

  **3. apps/client/src/scenes/GameScene.ts** — Tag `// [impl->REQ-CLI-04] [impl->REQ-CLI-07]` near the update loop.

  Wire the per-tick calls. The local-player velocity comes from PredictionEngine (Phase 6 06-XX); the remote-player velocity comes from PlayerState.input_axes per D-08:

  ```ts
  // [impl->REQ-CLI-04] [impl->REQ-CLI-07]
  update(time: number, dt: number): void {
    // ... existing prediction step ...

    // Local player render
    if (this.predictedState) {
      const { x, y, vx, vy } = this.predictedState.players.local;
      this.playerRenderer.updateLocal(vx, vy, x, y);
    }

    // Remote players render — read input_axes from server-broadcast PlayerState
    for (const [sessionId, p] of this.room?.state.players ?? []) {
      if (sessionId === this.room?.sessionId) continue; // skip local
      this.playerRenderer.updateRemote(sessionId, p.input_axes?.x ?? 0, p.input_axes?.y ?? 0, p.x, p.y);
    }
  }
  ```

  (Adapt to the actual PredictionEngine + PlayerState API shapes — the executor reads them and wires accordingly.)

  **4. apps/client/src/__test__/nameplate.test.ts** — Tag `// [unit->REQ-CLI-07]`.

  ```ts
  // [unit->REQ-CLI-07]
  import { describe, it, expect, beforeEach, vi } from 'vitest';
  import { Nameplate } from '../render/Nameplate';

  describe('Nameplate (D-27a)', () => {
    let scene: any;
    beforeEach(() => {
      document.body.innerHTML = '<div id="dom-overlay"></div>';
      scene = {
        add: {
          text: vi.fn().mockReturnValue({
            setOrigin: vi.fn().mockReturnThis(),
            setPosition: vi.fn().mockReturnThis(),
            setText: vi.fn().mockReturnThis(),
            destroy: vi.fn(),
          }),
        },
      };
    });

    it('uses cyan color (#22D3EE accent or extracted exact value)', () => {
      new Nameplate(scene, 'abc', 'me');
      const args = (scene.add.text as any).mock.calls[0][3];
      expect(args.color).toMatch(/#22D3EE|<extracted-exact-value-from-06-16-derivation>/i);
    });

    it('uses monospace font at 16px (UI-SPEC §Typography)', () => {
      new Nameplate(scene, 'abc', 'me');
      const args = (scene.add.text as any).mock.calls[0][3];
      expect(args.fontSize).toBe('16px');
      expect(args.fontFamily).toMatch(/monospace/i);
    });

    it('top-center anchor (setOrigin(0.5, 1))', () => {
      new Nameplate(scene, 'abc', 'me');
      const textObj = (scene.add.text as any).mock.results[0].value;
      expect(textObj.setOrigin).toHaveBeenCalledWith(0.5, 1);
    });

    it('creates DOM mirror with data-nameplate + data-player-id', () => {
      new Nameplate(scene, 'session-xyz', 'alice');
      const mirror = document.querySelector('[data-nameplate][data-player-id="session-xyz"]');
      expect(mirror).not.toBeNull();
      expect(mirror!.textContent).toBe('alice');
    });

    it('follow() positions nameplate above sprite', () => {
      const np = new Nameplate(scene, 'abc', 'me');
      np.follow(100, 200, 32);
      const textObj = (scene.add.text as any).mock.results[0].value;
      // Expected: x=100, y=200 - 32/2 - 4 = 180
      expect(textObj.setPosition).toHaveBeenCalledWith(100, 180);
    });

    it('destroy() cleans up DOM mirror + Phaser text', () => {
      const np = new Nameplate(scene, 'abc', 'me');
      np.destroy();
      expect(document.querySelectorAll('[data-nameplate]').length).toBe(0);
    });
  });
  ```

  **5. apps/client/test/e2e/cli-08.e2e.test.ts** — Add (don't replace) frame-selection + nameplate assertions:

  ```ts
  // [int->REQ-CLI-04] [int->REQ-CLI-07]
  test('player sprite frame switches to Run<DIR> on movement, back to Stand<DIR> on stop', async ({ page }) => {
    await loginAs(page, accountA);
    await waitForGameReady(page);
    // Press right (D key in WASD or ArrowRight) for 1s
    await page.keyboard.down('d');
    await page.waitForTimeout(500);
    // Read the current frame name via dev hook (executor exposes window.__rebno.localFrame for tests)
    const frameWhileRunning = await page.evaluate(() => (window as any).__rebno?.localFrame);
    expect(frameWhileRunning).toMatch(/^0028-NaviRunR_\d+$/); // R-facing run frame (cycle phase varies)
    await page.keyboard.up('d');
    await page.waitForTimeout(200);
    const frameAfterStop = await page.evaluate(() => (window as any).__rebno?.localFrame);
    expect(frameAfterStop).toMatch(/^(0029-NaviStandR|0000-NaviStandD)/); // Stand frame after stop
  });

  test('nameplate visible above each player sprite (DOM mirror)', async ({ page }) => {
    await loginAs(page, accountA);
    await waitForGameReady(page);
    const nameplates = await page.locator('[data-nameplate]').count();
    expect(nameplates).toBeGreaterThan(0);
  });
  ```

  (executor adds the dev-hook `window.__rebno.localFrame = state.frameKey` inside PlayerRenderer.updateLocal for testability — gated by import.meta.env.DEV or DEV_HOOKS=1 to avoid prod surface.)
    </action>
    <verify>
      <automated>pnpm --filter @rebno/client test -- src/__test__/nameplate.test.ts &amp;&amp; pnpm --filter @rebno/client typecheck &amp;&amp; pnpm --filter @rebno/client test:e2e -- --grep "Run<DIR>|nameplate"</automated>
    </verify>
    <done>
      Nameplate.ts shipped with cyan color + top-center anchor + DOM mirror; 6 vitest tests GREEN. PlayerRenderer wires SpriteStateMachine.deriveFrame for both local + remote players using PlayerState.input_axes for remotes. GameScene update() loop calls updateLocal + updateRemote each tick. cli-08.e2e.test.ts asserts frame switches Run↔Stand + nameplate DOM mirror present. typecheck green.
    </done>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Server-broadcast input_axes → client deriveFrame | Phase 6 D-08 schema field is server-authoritative; client uses verbatim for remote sprite-state — no spoofing vector |
  | GML-derived sprite IDs → atlas frame keys | If 06-14 manifest disagrees with SpriteStateMachine sprite-ID table, sprite.setFrame throws → caught in vitest frame-key test |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-16-01 | Tampering | Adversary spoofs nameplate text via DOM injection | accept | DOM mirror is dev-tool-only (display:none); even if visible, server-broadcast username is the source of truth and re-render overwrites |
  | T-06-16-02 | DoS | Cycle-phase advance per frame at 60Hz creates GC pressure with many remotes | mitigate | Pure function returns small object; no allocations per tick beyond the SpriteState record (executor uses object pooling if profiler shows it matters; not premature) |
  | T-06-16-03 | Repudiation | "Sprite frame doesn't match BNO" without evidence | mitigate | 06-16-SPRITE-STATE-DERIVATION.md cites GML lines + UAT video evidence at 06-17 confirms |
  | T-06-16-04 | Information disclosure | Username DOM mirror exposes other players' usernames in DevTools | accept | Usernames are public per server broadcast already; mirror is parity, not new exposure |
  </threat_model>

  <verification>
  - `pnpm --filter @rebno/client test -- src/__test__/sprite-state-machine.test.ts src/__test__/nameplate.test.ts` exits 0 (17+ tests)
  - `pnpm --filter @rebno/client typecheck` exits 0
  - `pnpm --filter @rebno/client test:e2e` exits 0 (existing + new frame-switch + nameplate assertions)
  - 06-16-SPRITE-STATE-DERIVATION.md exists with all 4 decisions + cyan derivation populated
  - `pnpm verify:phase-6` passes after Wave 12 lands
  - `pnpm trace:check` reports `[impl/unit->REQ-CLI-04]` + `[impl->REQ-CLI-07]` tags satisfied
  - Manual smoke (deferred to 06-17 UAT video): sprites face correct direction; nameplate visible cyan above heads
  </verification>

  <success_criteria>
  - UAT Finding #3b (D-26 Plan B) closed: 8-direction sprite-state machine derived from extracted GML + atlas frames from 06-14
  - D-27a nametag (cyan, top-center, follows movement, self + remotes) shipped per CONTEXT addendum line 339
  - Pure SpriteStateMachine module — testable + reusable
  - Frame timing matches BNO (Q2 facing-on-keydown vs on-step decision recorded + implemented)
  - Tags `[impl/unit->REQ-CLI-04]` (per gap-closure addendum line 362) + `[impl->REQ-CLI-07]` for nametag in room-render
  - All 8 directions reachable via player movement; full 8-direction Run coverage including 0028-NaviRunR
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SUMMARY.md` recording:
  (a) Q1-Q4 decisions from 06-16-SPRITE-STATE-DERIVATION.md (one-line each)
  (b) Final RUN_FRAME_COUNT table per direction (paste from SpriteStateMachine.ts)
  (c) Cyan nametag color used (extracted exact value OR UI-SPEC accent fallback)
  (d) DOM mirror selector strategy verified working in Playwright
  (e) Confirmation 06-17 UAT can capture an authentic BNO-fidelity video on first take
  (f) Trace tag verification snippet for REQ-CLI-04 + REQ-CLI-07 across impl + unit + int stages
  </output>
  </content>
  </invoke>```

  ### Plan 06-17
  ```markdown
  ---
  phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
  plan: 17
  type: execute
  wave: 12
  depends_on: [06-10, 06-11, 06-12, 06-13, 06-14, 06-15, 06-16]
  files_modified:
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CLI-08-milestone.mp4
  autonomous: false
  requirements: [CLI-08, CLI-01, CLI-02, CLI-03, CLI-04, CLI-05, CLI-06, CLI-07, CLI-09, AST-01]
  must_haves:
    truths:
      - "06-HUMAN-UAT.md is re-run verbatim — same 5 tests, same instructions — by operator decidel against the freshly-deployed staging build that includes all gap-closure plans 06-10..06-16"
      - "Test 1 (HiDPI nearest-neighbor): operator can READ the login form (06-10 fix) AND the GameScene shows real BNO sprites (06-14) — pixel-correctness sweep is now meaningful and assessed across 4K Retina + 1080p + Windows 125%/150%"
      - "Test 2 (CLI-08 milestone): operator captures real `06-CLI-08-milestone.mp4` showing two players in BN Central, walking with frame-perfect 8-direction sprites (06-13 + 06-16), exchanging chat — the 'we shipped CLI-08' artifact"
      - "Test 3 (Aseprite round-trip): operator exercises the bootstrap → build → render dev-loop with one of the real bootstrapped sprites from 06-14 (e.g. modify 0000-NaviStandD.png in-place, run pnpm asset-pipeline:build, reload, see the change) — no need for .aseprite source if D-15 deferred those"
      - "Test 4 (Browser password manager + autofill): operator can sign out via the new Esc menu (06-11) → Chrome 'Save password?' fires → reload → autofill works"
      - "Test 5 (Reconnect grace banner UX): operator kills WS via DevTools (06-15 fixes the post-reconnect blank canvas) → banner appears + clears + content remains rendered + position preserved within grace + home portal spawn past grace"
      - "All 5 Status fields show ✅ Pass; the 06-HUMAN-UAT.md ## Findings table from 2026-05-10 is RESOLVED (each finding row updated with closure evidence + gap-closure plan number)"
      - "06-CLI-08-milestone.mp4 committed to .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/ (Git LFS or external link if file > 50 MB; either is per CLAUDE.md hard rule #8 acceptable)"
      - "Phase 6 status flips from IN_PROGRESS to APPROVED in 06-HUMAN-UAT.md front-matter; CLI-08 is officially shipped"
    artifacts:
      - path: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
        provides: "Re-executed UAT — all 5 tests Pass; ## Findings table updated with closure evidence per row; sign-off table green; status APPROVED"
        contains: "APPROVED"
      - path: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CLI-08-milestone.mp4
        provides: "The CLI-08 milestone artifact — Capcom-derived BNO has had ~25 years between original and rebuild; this video is the proof"
        contains: ""
    key_links:
      - from: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md (post-update)
        to: 06-10..06-16 SUMMARY files
        via: "## Findings table closure column references each gap-closure plan + summary doc"
        pattern: "06-1[0-6]-SUMMARY"
      - from: .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
        to: 06-CLI-08-milestone.mp4
        via: "Test 2 Video field references the committed file path"
        pattern: "06-CLI-08-milestone"
      - from: 06-HUMAN-UAT.md status APPROVED
        to: /gsd-verify-work 06
        via: "Phase 6 close-out: /gsd-verify-work consumes both the automated verify-phase-6 (06-09) and the manual UAT artifact"
        pattern: "APPROVED"
  ---

  <objective>
  Close the gap-closure cycle: re-run `06-HUMAN-UAT.md` verbatim against the freshly-deployed staging build that includes all gap-closure plans 06-10 through 06-16; capture the real `06-CLI-08-milestone.mp4` (the milestone artifact for "we shipped CLI-08"); update the UAT doc's ## Findings table with closure evidence for each of the 5 findings from the 2026-05-10 run; flip Phase 6 status to APPROVED.

  Purpose: per CONTEXT addendum line 353 — Wave 12, sequential, all 7 prior gap-closure plans must ship before this runs. This is the final acceptance test for CLI-08, the rebuild's go/no-go milestone (CLAUDE.md hard rule #8 + ROADMAP). The original UAT video at `uat-test-1-2.mp4` was captured on a synthetic mvp-lobby with blue rectangles; this plan replaces it with a real-asset, real-room, real-sprite, real-reconnect-recovery video.

  Per gap-closure addendum line 364 — `[doc->REQ-CLI-08]` (final milestone artifact).

  Output: updated 06-HUMAN-UAT.md (5 tests Pass + closure table + APPROVED status) + committed 06-CLI-08-milestone.mp4. After this plan ships and the checkpoint resolves green, Phase 6 closes and Phase 7 unblocks.
  </objective>

  <execution_context>
  @$HOME/.ccs/instances/bigscreen/get-shit-done/workflows/execute-plan.md
  @$HOME/.ccs/instances/bigscreen/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/PROJECT.md
  @./CLAUDE.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  @.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-09-PLAN.md
  @.planning/phases/05-deploy/05-HUMAN-UAT.md

  <interfaces>
  <!-- This plan deliberately consumes ZERO source-file interfaces. -->
  <!-- It re-executes a 2026-05-10 UAT script against the gap-closure deployment. -->

  The 5 UAT tests are LOCKED verbatim by 06-HUMAN-UAT.md (do not change instructions; only update Operator Notes + Status + ## Findings closure column).

  Pre-flight checks (operator + Claude before re-running UAT):
  1. All gap-closure plans 06-10..06-16 SUMMARY docs exist and report green
  2. `pnpm verify:phase-6` (06-09 composite gate) exits 0 end-to-end
  3. Staging deploy is green: `https://staging.rebno.decidel.com/?invite=$STAGING_INVITE_TOKEN` returns the new client bundle
  4. Two seeded UAT accounts (uat_a + uat_b) work — verified by 06-08's Playwright e2e in CI

  Deployment ritual (Phase 6 D-18 + Phase 5 D-11):
  - `git push` to a branch → CI runs deploy-staging.yml → builds client + server image → flyctl deploy → Playwright post-deploy gate → if green, merge to main → prod deploy on main push (gated by Phase 5 ritual)
  - Operator verifies the deployed bundle includes all gap-closure changes (check the JS bundle hash in DevTools Network panel matches the latest commit's expected hash)

  Closure evidence per finding (to be filled in 06-HUMAN-UAT.md ## Findings table):
  - Finding #1 (login text invisible) → 06-10-PLAN.md / 06-10-SUMMARY.md + 06-10-PLAN.md's contrast Playwright assertion
  - Finding #2 (reconnect blank GameScene) → 06-12-DEBUG-FINDINGS.md + 06-15-PLAN.md / 06-15-SUMMARY.md + cookie-reload + ws-kill-reconnect e2e tests
  - Finding #3 (movement doesn't match BNO) → 06-13-PLAN.md / 06-13-SUMMARY.md (numerics) + 06-16-PLAN.md / 06-16-SUMMARY.md (sprite-state)
  - Finding #4 (no real BNO assets) → 06-14-PLAN.md / 06-14-SUMMARY.md + 06-14-ASSET-MANIFEST.md
  - Finding #5 (no logout) → 06-11-PLAN.md / 06-11-SUMMARY.md

  Video file size handling (CLAUDE.md hard rule #8 — repo private through Phase 7):
  - Up to 50 MB: commit directly
  - 50-100 MB: Git LFS via `.gitattributes` track for *.mp4
  - > 100 MB: external private location + reference in UAT doc
  </interfaces>
  </context>

  <tasks>

  <task type="auto">
    <name>Task 1: Pre-UAT verification — confirm all gap-closure plans landed and verify-phase-6 is green</name>
    <files>
      (read-only verification — no file edits)
    </files>
    <read_first>
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-SUMMARY.md,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-11-SUMMARY.md,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-SUMMARY.md,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-SUMMARY.md,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-SUMMARY.md,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-15-SUMMARY.md,
      .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SUMMARY.md
    </read_first>
    <action>
  **1. Confirm all 7 prior SUMMARY docs exist and report green** (each summary's "What was built" + "verification" sections):

  ```bash
  for n in 10 11 12 13 14 15 16; do
    if [ ! -f ".planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-${n}-SUMMARY.md" ]; then
      echo "MISSING SUMMARY: 06-${n}"; exit 1
    fi
  done
  echo "All 7 SUMMARYs present"
  ```

  **2. Run the composite verify gate end-to-end**:
  ```bash
  pnpm verify:phase-6
  ```

  Must exit 0. If any step fails, STOP and route the failure back to the responsible plan (use the failure label to identify which plan owns the broken step):
  - Step 4 (Asset-pipeline: build) → 06-14
  - Step 7 (Lint: asset-pipeline) → 06-14
  - Step 8 (Workspace: test) → unit test failure; trace to source plan via test name
  - Step 9 (Client: e2e cli-08 smoke) → 06-15 or 06-16 depending on failing assertion
  - Step 10 (Traceable-reqs: check) → tagging gap; identify missing tag

  **3. Verify staging deployment is current**:
  ```bash
  # Check the deployed bundle matches the latest commit
  curl -sI https://staging.rebno.decidel.com/ | head
  # Visual confirmation: open https://staging.rebno.decidel.com/?invite=$STAGING_INVITE_TOKEN
  # in a fresh Chrome incognito window; confirm the login form is readable (06-10 fix landed),
  # the Esc menu opens (06-11 landed), the cookie reload doesn't blank (06-15 landed),
  # and the GameScene shows BNCentral with real sprites (06-14 + 06-16 landed)
  ```

  **4. Confirm UAT seed accounts are usable**:
  ```bash
  # uat_a + uat_b should already exist (per 06-08 Playwright e2e using these accounts daily)
  # If they're stale (e.g. password rotation), seed via the legacy migrator + change-password rituals
  ```

  If anything red, STOP — file a follow-up ticket / route back to the responsible plan. If all green, proceed to Task 2 checkpoint.
    </action>
    <verify>
      <automated>node -e "const fs=require('fs'); const ns=[10,11,12,13,14,15,16]; for (const n of ns) { const p='.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-'+n+'-SUMMARY.md'; if (!fs.existsSync(p)) { console.error('MISSING:', p); process.exit(1); } } console.log('all 7 SUMMARY docs present'); &amp;&amp; pnpm verify:phase-6"</automated>
    </verify>
    <done>
      All 7 prior SUMMARY docs present + green; pnpm verify:phase-6 exits 0; staging deployment confirmed current with all gap-closure changes; UAT seed accounts usable. Operator + Claude both confident the build is ready for human UAT re-run.
    </done>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <name>Task 2: Operator re-runs all 5 UAT tests against staging; captures 06-CLI-08-milestone.mp4; updates 06-HUMAN-UAT.md to APPROVED</name>
    <what-built>
  All 7 gap-closure plans (06-10..06-16) shipped. Staging deployed with the gap-closure bundle. Operator can now re-execute the 5 manual UAT tests verbatim from `06-HUMAN-UAT.md` (the same script that failed on 2026-05-10) and capture the milestone video. This task is fundamentally manual — videos, real-DPI hardware sweeps, and password-manager integration cannot be headless-driven.

  **Pre-checkpoint: Claude pre-edits 06-HUMAN-UAT.md to clear the prior FAIL state**

  (see how-to-verify for the exact pre-edit shape)
    </what-built>
    <how-to-verify>
  **Step 0 (Claude — pre-checkpoint pre-edit):**

  Update `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md` BEFORE the operator runs UAT:

  a) Front-matter: `status: pending` (was `failed`); `last_updated: <today YYYY-MM-DD>`; add `gap_closure_plans: [06-10, 06-11, 06-12, 06-13, 06-14, 06-15, 06-16]`

  b) Inside each Test (1-5): clear the prior **Operator Notes**, **Screenshots / Evidence**, **Status** to a fresh `⬜ Pending` state — preserve the original instructions verbatim. The 2026-05-10 failure context belongs in the ## Findings table (see (c)) for historical record, not in the per-test fields.

  c) Update the ## Findings table — KEEP the original 2026-05-10 finding rows for traceability but ADD a "Closed by" column:

  ```md
  | # | Finding | Severity | Closed by |
  |---|---------|----------|-----------|
  | 1 | Login screen text invisible | high | 06-10-PLAN.md (LoginScene contrast tokens + Playwright contrast assertion) — see 06-10-SUMMARY.md |
  | 2 | Reconnect / cookie auto-login renders blank | critical | 06-12-DEBUG-FINDINGS.md root-cause + 06-15-PLAN.md (Option A token threading + Option B getSession self-heal + InputDispatcher.setRoom + 2 e2e tests) — see 06-15-SUMMARY.md |
  | 3 | Movement doesn't match BNO | critical | 06-13-PLAN.md (constants from extracted GML + pinning tests) + 06-16-PLAN.md (8-direction sprite-state machine + cyan nametag D-27a) — see 06-13-SUMMARY.md + 06-16-SUMMARY.md |
  | 4 | No real BNO assets | critical | 06-14-PLAN.md (asset-pipeline bootstrap + 15 player sprites + BNCentral room layout from 0058-BNCentral, replaces synthetic mvp-lobby) — see 06-14-SUMMARY.md + 06-14-ASSET-MANIFEST.md |
  | 5 | No logout | medium | 06-11-PLAN.md (Esc menu — Esc + game-view click triggers; Logout/Resume/Settings; cookie + Colyseus room cleared) — see 06-11-SUMMARY.md |
  ```

  d) Update the Sign-Off table to fresh ⬜ for all 5 rows; clear the bottom approval line.

  e) Append a `## Re-run history` section:
  ```md
  ## Re-run history

  | Date | Operator | Outcome | Notes |
  |------|----------|---------|-------|
  | 2026-05-10 | decidel | FAIL (5 findings) | Original UAT — see Sign-Off + ## Findings |
  | <YYYY-MM-DD> | decidel | _<pending>_ | Re-run after gap-closure plans 06-10..06-16 |
  ```

  **Step 1 (operator — verbatim re-run):**

  Operator opens `06-HUMAN-UAT.md` and executes Test 1 → Test 2 → Test 3 → Test 4 → Test 5 in order against `https://staging.rebno.decidel.com/?invite=$STAGING_INVITE_TOKEN` exactly as the doc specifies. For each:
  - Fill in **Operator Notes** with what was observed
  - Capture **Screenshots / Evidence** (paths in the phase directory)
  - Mark **Status** ✅ Pass or ⬛ Fail (with reason)

  **Critical: Test 2 captures the milestone video.**

  Operator records both Chrome browser windows during the alice-bob (uat_a-uat_b) movement + chat scenario. Save as `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CLI-08-milestone.mp4`. File size handling:
  - ≤ 50 MB: commit directly via `git add .planning/.../06-CLI-08-milestone.mp4`
  - 50-100 MB: enable Git LFS first (`git lfs install && git lfs track "*.mp4"`); commit `.gitattributes` + the file
  - > 100 MB: trim to ≤ 100 MB OR upload to a private location (Drive / S3) and put the link in the Test 2 Video field

  The video should clearly show:
  - Both windows side-by-side
  - BN Central room with real BNO sprites + tiles + background (NOT blue rectangles)
  - 8-direction sprite-state animation (not just StandD throughout)
  - Cyan nametags above each player's head
  - Bidirectional chat round-trip (alice's message appears in bob's window + vice versa)
  - Total duration: 30 seconds to 2 minutes is sufficient

  **Step 2 (operator — re-run history + sign-off):**

  After all 5 tests pass:
  - Fill the `## Re-run history` row for today with `outcome: PASS (5/5 tests green)` + brief notes
  - Update the Sign-Off table: each row → ✅
  - Update the bottom: `**Operator approval:** decidel, <YYYY-MM-DD> — APPROVED — CLI-08 milestone shipped.`
  - Update front-matter: `status: approved`

  If ANY test fails:
  - Mark the failing test ⬛ with reason in Operator Notes
  - DO NOT mark Phase 6 approved
  - Route back to the responsible gap-closure plan owner; file a follow-up plan or fix in place
  - The checkpoint resume signal becomes `partial` (see resume-signal section)
    </how-to-verify>
    <resume-signal>
  Type `approved — CLI-08 shipped, UAT 5/5 green, milestone video committed at <path-or-link>` when:
  - All 5 tests in 06-HUMAN-UAT.md show ✅ Pass with operator notes + evidence
  - 06-CLI-08-milestone.mp4 is committed (or externally linked + referenced in Test 2 Video field)
  - Front-matter status: approved
  - Sign-Off table all green
  - Re-run history row populated for today

  Or type `partial — UAT N/5 green, blocked on <test number(s)> due to <reason>` if any test fails. Detail the failure mode in the resume message; this triggers a follow-up gap-closure plan (06-18+) or a re-deploy. Phase 6 status remains IN_PROGRESS.

  Or type `pending — UAT scheduled for <date>` if operator is not ready to run UAT immediately; the checkpoint stays open until the operator returns with a result.
    </resume-signal>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Operator's UAT execution → committed evidence | Operator could falsely claim a green run; mitigated by required screenshot + video evidence + Re-run history audit trail |
  | Milestone video → public exposure if repo flips public | CLAUDE.md hard rule #8 — repo private through Phase 7; video carries no PII (test accounts only); legal-prep checklist (Phase 7 PAR-08) reviews ALL committed media before any public flip |
  | Phase 6 close-out → unblocks Phase 7 | If UAT is approved without genuinely meeting CLI-08 ("two players walk + chat over deployed server, bug-free"), Phase 7 starts on rotten foundation — mitigated by Sign-Off table requiring all 5 ticked + video evidence reviewable |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06-17-01 | Repudiation | Operator approves UAT but evidence is missing/insufficient | mitigate | Sign-Off table requires the Test 2 video file path; ## Findings closure column requires per-finding plan reference; /gsd-verify-work 06 reviews the file at close-out |
  | T-06-17-02 | Disclosure | Milestone video accidentally leaked publicly (e.g. dropped on a pastebin) | mitigate | Operator instructed to keep video in private location only; CLAUDE.md hard rule #8 enforced by Phase 7 PAR-08 legal-prep checklist before any public flip |
  | T-06-17-03 | DoS | Operator unavailable for UAT for an extended period | accept | Phase 6 close-out is the operator's responsibility; the orchestrator will surface the pending checkpoint indefinitely; this is a planning-cycle delay, not a security DoS |
  | T-06-17-04 | Tampering | Someone edits 06-HUMAN-UAT.md to mark APPROVED without running the tests | accept | Solo-developer workflow; trust model is operator integrity; Re-run history audit trail makes drift visible |
  </threat_model>

  <verification>
  - All 7 SUMMARY docs (06-10..06-16) present and green
  - `pnpm verify:phase-6` exits 0 end-to-end
  - Staging deployment current with gap-closure bundle
  - After checkpoint: 06-HUMAN-UAT.md status = approved + Sign-Off all ✅ + Test 2 video exists at the documented path + ## Findings closure column populated for all 5 findings
  - After checkpoint: `git log --oneline -- .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md` shows the re-run commit
  - `pnpm trace:check` reports `[doc->REQ-CLI-08]` doc-stage tag satisfied; all 10 Phase 6 REQ-IDs at expected stages
  - /gsd-verify-work 06 ready to run as the formal phase close-out
  </verification>

  <success_criteria>
  - UAT failure cycle from 2026-05-10 closed: 5/5 tests Pass on the gap-closure deployment
  - 06-CLI-08-milestone.mp4 captures the rebuild's go/no-go evidence (~25 years from original to rebuild — this is the artifact)
  - Phase 6 status APPROVED — CLI-08 hard milestone shipped
  - All 5 findings from the original UAT have closure-evidence rows in the ## Findings table pointing to the gap-closure plans + summaries
  - Phase 7 unblocked: Phase 6 → Phase 7 PAR-* gate is now passable (subject to /gsd-verify-work 06 formal close-out)
  - Tags `[doc->REQ-CLI-08]` (per gap-closure addendum line 364) satisfied
  - All 10 Phase 6 REQ-IDs (CLI-01..09 + AST-01) hit their required stages per traceable-reqs
  </success_criteria>

  <output>
  After completion (checkpoint resolved green), create `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-17-SUMMARY.md` recording:
  (a) UAT re-run date + operator + outcome (5/5 PASS)
  (b) Path/link to 06-CLI-08-milestone.mp4 + file size + storage method (direct commit / Git LFS / external link)
  (c) Per-test brief outcome notes (1 sentence each, or paste the operator's notes verbatim from 06-HUMAN-UAT.md)
  (d) Closure mapping confirmed: Finding 1 → 06-10, Finding 2 → 06-12 + 06-15, Finding 3 → 06-13 + 06-16, Finding 4 → 06-14, Finding 5 → 06-11
  (e) Phase 6 status APPROVED; CLI-08 milestone shipped; Phase 7 unblocked
  (f) Pointer to next: run `/gsd-verify-work 06` for formal phase close-out → then `/gsd-discuss-phase 7` to begin PAR-*

  After 06-17 ships, the gap-closure cycle for Phase 6 is complete. The repo carries:
  - The original 2026-05-10 UAT failure (preserved in ## Findings + ## Re-run history)
  - The 7 gap-closure plans (06-10..06-16) that closed each finding
  - The re-executed UAT with all 5 tests Pass
  - The milestone video — the proof
  </output>
  </content>
  </invoke>```

  ## Review Instructions

  For the **set of 8 gap-closure plans**, provide:
  1. **Summary** — one paragraph overall assessment
  2. **Strengths** — what's well-designed
  3. **Concerns** — HIGH/MEDIUM/LOW severity, per plan or cross-plan
  4. **Suggestions** — concrete improvements
  5. **Risk Assessment** — LOW/MEDIUM/HIGH for closing CLI-08, with justification

  Pay particular attention to: wave sequencing (Wave 8: 06-10||06-11 → Wave 9: 06-12||06-13 → Wave 10: 06-14 → Wave 11: 06-15||06-16 → Wave 12: 06-17), whether the planned fixes actually root-cause the UAT defects, scope discipline (gap-closure is meant to be narrow), and security/correctness regressions versus the shipped Wave 1-7 work.

  Output markdown.


