---
phase: "06-client-rebuild-mvp-gate-cli-08-hard-milestone"
plan: "16"
subsystem: "client-render"
tags: ["sprite-state", "animation", "nametag", "D-31", "D-27a", "8-direction"]
dependency_graph:
  requires: ["06-13", "06-14", "06-15"]
  provides: ["SpriteStateMachine", "PlayerRenderer-D31", "Nameplate-D27a"]
  affects: ["GameScene", "PlayerRenderer", "Nameplate"]
tech_stack:
  added: ["SpriteStateMachine.ts (pure function module)"]
  patterns:
    - "D-31: simulation-tick (30 Hz) driven animation — NOT render-frame (60 FPS)"
    - "Tick accumulator pattern in GameScene.update() for fixed-rate sim ticks"
    - "Pure function state machine with threaded state (tickAccumulator, cyclePhase)"
    - "D-35: __rebno.localFrame hook for deterministic Playwright e2e"
key_files:
  created:
    - apps/client/src/render/SpriteStateMachine.ts
    - apps/client/src/__test__/sprite-state-machine.test.ts
    - apps/client/test/e2e/sprite-state.e2e.test.ts
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SPRITE-STATE-DERIVATION.md
  modified:
    - 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
decisions:
  - "Q1 (RESOLVED): 0028-NaviRunR confirmed present in atlas (6 frames) — no fallback needed"
  - "Q2: Facing updates on keydown (same tick) — Keyboard-39.gml sets sprite_index directly"
  - "Q3: TICKS_PER_FRAME_ADVANCE=1 from Create.gml image_speed=1"
  - "Q4: KeyRelease preserves last-pressed direction in Stand sprite"
  - "Cyan: #22D3EE locked (UI-SPEC accent) — c_aqua=#00FFFF documented as origin"
  - "Atlas frame keys: zero-padded 3-digit suffix (_000.._005) — all Run sprites 6 frames"
metrics:
  duration: "~35 minutes"
  completed: "2026-05-10"
  tasks: 2
  files: 8
---

# Phase 6 Plan 16: 8-Direction Sprite-State Machine + Cyan Nametag Summary

**One-liner:** Pure 8-direction SpriteStateMachine driven per simulation tick (D-31 / TICK_RATE_HZ=30) with 0028-NaviRunR confirmed + cyan #22D3EE nametag (D-27a) following sprite movement.

---

## Tasks Completed

| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | SpriteStateMachine + derivation doc + unit tests | 6b74e8e | SpriteStateMachine.ts, sprite-state-machine.test.ts, 06-16-SPRITE-STATE-DERIVATION.md |
| 2 | PlayerRenderer D-31 + Nameplate D-27a + GameScene + e2e | 765e009 | PlayerRenderer.ts, Nameplate.ts, GameScene.ts, nameplate.test.ts, sprite-state.e2e.test.ts |

---

## Q1-Q4 Decisions (from 06-16-SPRITE-STATE-DERIVATION.md)

| Decision | Resolution |
|----------|-----------|
| **Q1** ~~Missing 0028-NaviRunR~~ | RESOLVED — sprite confirmed in atlas-mvp.json with 6 frames (000..005). `RUN_SPRITE_ID.R = '0028-NaviRunR'` |
| **Q2** Facing update timing | Updates on **keydown** (same tick). Keyboard-39.gml sets `sprite_index = NaviRunR` directly on keydown — no deferral to next Step. `deriveFacing` is called from velocity on the same tick input arrives. |
| **Q3** Cycle advance rate | `TICKS_PER_FRAME_ADVANCE = 1` derived from `Create.gml:20: image_speed = 1`. One frame advance per simulation tick. |
| **Q4** KeyRelease facing behavior | **Last-pressed direction preserved.** KeyRelease-39.gml sets `sprite_index = NaviStandR` on release (not NaviStandD). Zero-velocity returns `STAND_FRAME[lastFacing]`. |

---

## D-31 Result: TICKS_PER_FRAME_ADVANCE

```
BNO source: Create.gml:20 — image_speed = 1
TICKS_PER_FRAME_ADVANCE = 1
TICK_RATE_HZ = 30 Hz
Cycle rate = 30 / 6 frames = 5 fps per direction
Full cycle duration = 200 ms
```

Without D-31 enforcement (60 FPS render-frame advance): 10 fps = 2× BNO speed. D-31 prevents this via `simTickAccumulator` in GameScene.update().

---

## RUN_FRAME_COUNT Table (all 8 directions)

| Direction | Sprite ID | Frame Count | Atlas keys |
|-----------|-----------|-------------|------------|
| D | 0027-NaviRunD | 6 | _000.._005 |
| R | 0028-NaviRunR | 6 | _000.._005 |
| U | 0031-NaviRunU | 6 | _000.._005 |
| L | 0032-NaviRunL | 6 | _000.._005 |
| DR | 0037-NaviRunDR | 6 | _000.._005 |
| UR | 0039-NaviRunUR | 6 | _000.._005 |
| UL | 0041-NaviRunUL | 6 | _000.._005 |
| DL | 0043-NaviRunDL | 6 | _000.._005 |

---

## Cyan Nametag Color

**Final value: `#22D3EE`** (UI-SPEC accent, Tailwind cyan-400)

BNO source: `Draw.gml:2 — font_color = c_aqua` → GM5 `c_aqua = #00FFFF` (pure cyan).

Decision: `#22D3EE` is kept (matches existing Nameplate implementation from plan 06-07 and existing test assertions). `c_aqua = #00FFFF` is documented as the BNO origin; the UI-SPEC accent is the project-locked value for consistency.

---

## DOM Mirror Strategy

Nameplate creates a hidden DOM div:
```html
<div data-nameplate="alice" data-player-id="<sessionId>" data-x-coord="440" data-y-coord="396" style="...display:none...">alice</div>
```

Playwright selectors:
- `[data-nameplate]` — count > 0 verifies at least one player nameplate exists
- `[data-nameplate][data-player-id="<id>"]` — per-player drill-down
- `[data-nameplate]` textContent — username assertion

---

## window.__rebno.localFrame Hook (D-35)

`PlayerRenderer.onSimulationTickLocal()` writes per sim tick (gated by `import.meta.env.DEV`):
```
__rebno.localFrame = '0028-NaviRunR_003'  // (example while running right)
__rebno.localFrame = '0029-NaviStandR_000' // (after stopping, facing preserved)
```

e2e assertions use `page.evaluate(() => __rebno?.localFrame)` — no canvas pixel sampling.

---

## Deviations from Plan

**1. [Rule 1 - Bug] Test logic fix for D-31 accumulator test**
- **Found during:** Task 1 (RED phase)
- **Issue:** Test loop calculated `initialPhase` after 1 tick, then expected an additional advance after 0 more ticks — logic was off by one.
- **Fix:** Rewrote to start from phase=0, acc=0 and assert phase=1 after TICKS_PER_FRAME_ADVANCE total calls.
- **Files modified:** `apps/client/src/__test__/sprite-state-machine.test.ts`

**2. [Rule 2 - Missing feature] TypeScript strict cast fix for globalThis**
- **Found during:** Task 2 (typecheck)
- **Issue:** `(globalThis as Record<string, ...>)` failed TS strict mode — `globalThis.eval` signature incompatible with index signature.
- **Fix:** Used `globalThis as any` with eslint-disable comment.
- **Files modified:** `apps/client/src/render/PlayerRenderer.ts`

**3. [Rule 2 - Atlas format discovery] Frame keys use 3-digit padding, not 1-digit**
- **Found during:** Task 1 research (atlas-mvp.json inspection)
- **Issue:** Plan interfaces spec'd `_0`, `_1` frame suffixes; actual atlas uses `_000`, `_001`.
- **Fix:** All SpriteStateMachine constants and frame key generation use 3-digit zero-padded format matching atlas reality.
- **Impact:** All tests assert `/^0028-NaviRunR_\d{3}$/` regex pattern.

**4. [Rule 2 - Frame count correction] All Run sprites have 6 frames (not 2)**
- **Found during:** Task 1 research (atlas-mvp.json inspection)
- **Issue:** 06-14-ASSET-MANIFEST.md says "2 frames" for Run sprites; actual atlas has 6 frames (000..005) for all 8 directions.
- **Fix:** `RUN_FRAME_COUNT` set to 6 for all directions, matching the actual atlas.

---

## Known Stubs

None. SpriteStateMachine frame keys are derived from the real atlas (atlas-mvp.json). PlayerRenderer wires real frames. Nameplate shows real username from Colyseus state.

---

## Threat Flags

None introduced beyond plan's threat model (T-06-16-01..05 covered in PLAN.md).

---

## Trace Tag Verification

```
[OK] REQ-CLI-04  required: [doc, impl, unit]  stages: +doc +impl +unit +int
[OK] REQ-CLI-07  required: [doc, impl, unit]  stages: +doc +impl +unit +int
```

Tags placed:
- `[impl->REQ-CLI-04]` in SpriteStateMachine.ts, PlayerRenderer.ts
- `[impl->REQ-CLI-07]` in Nameplate.ts, PlayerRenderer.ts, GameScene.ts
- `[unit->REQ-CLI-04]` in sprite-state-machine.test.ts
- `[unit->REQ-CLI-07]` in nameplate.test.ts
- `[int->REQ-CLI-04] [int->REQ-CLI-07]` in sprite-state.e2e.test.ts
- `[doc->REQ-CLI-04]` in 06-16-SPRITE-STATE-DERIVATION.md

---

## Test Summary

| Suite | Tests | Status |
|-------|-------|--------|
| sprite-state-machine.test.ts | 31 | GREEN |
| nameplate.test.ts | 10 | GREEN |
| All other unit tests | 109 | GREEN |
| **Total unit tests** | **150** | **GREEN** |
| sprite-state.e2e.test.ts | 3 | Pending (requires live server) |
| typecheck | — | CLEAN |

---

## 06-17 UAT Readiness

06-17 UAT video can capture first-take BNO-fidelity because:
- All 8 directions covered with correct sprite IDs
- 0028-NaviRunR confirmed (not mirrored RunL)
- Animation runs at BNO-faithful 5 fps (not 2× at 10 fps)
- Cyan nametag visible above each player sprite
- Stand frame preserved on stop (facing not reset to D)
- Frame timing matches BNO timing semantics (facing-on-keydown)

---

## Self-Check: PASSED

Files exist:
- `apps/client/src/render/SpriteStateMachine.ts` FOUND
- `apps/client/src/__test__/sprite-state-machine.test.ts` FOUND
- `apps/client/test/e2e/sprite-state.e2e.test.ts` FOUND
- `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-SPRITE-STATE-DERIVATION.md` FOUND

Commits exist:
- `6b74e8e` Task 1 FOUND
- `765e009` Task 2 FOUND
