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

[doc->REQ-CLI-04]

# 06-16 Sprite-State Derivation — 8-Direction Animation + Nametag Timing

Records GML evidence for the sprite-state machine constants and behavioral decisions
(Q1–Q4) used in `apps/client/src/render/SpriteStateMachine.ts`.

---

## §Source GML

| File | Role in derivation |
|------|--------------------|
| `extracted/client-5-8/objects/0000-server/events/Create.gml` | `image_speed = 1` (animation speed), `global.curspeed = 3` (walk speed) |
| `extracted/client-5-8/objects/0000-server/events/Step.gml` | Movement loop via `lengthdir_x/y` + pixel-by-pixel collision; slide tiles update sprite_index on movement tiles |
| `extracted/client-5-8/objects/0000-server/events/Keyboard-37.gml` | Left key (arrow left): sets `sprite_index = NaviRunL`, `direction = 180`, `fspeed = global.curspeed` on keydown |
| `extracted/client-5-8/objects/0000-server/events/Keyboard-38.gml` | Up key (arrow up): sets `sprite_index = NaviRunU`, `direction = 90`, `fspeed = global.curspeed` on keydown |
| `extracted/client-5-8/objects/0000-server/events/Keyboard-39.gml` | Right key (arrow right): sets `sprite_index = NaviRunR`, `direction = 0`, `fspeed = global.curspeed` on keydown |
| `extracted/client-5-8/objects/0000-server/events/Keyboard-40.gml` | Down key (arrow down): sets `sprite_index = NaviRunD`, `direction = 270`, `fspeed = global.curspeed` on keydown |
| `extracted/client-5-8/objects/0000-server/events/KeyRelease-37.gml` | Left release: sets `fspeed = 0`; if no other key held → `sprite_index = NaviStandL` |
| `extracted/client-5-8/objects/0000-server/events/KeyRelease-38.gml` | Up release: sets `fspeed = 0`; if no other key held → `sprite_index = NaviStandU` |
| `extracted/client-5-8/objects/0000-server/events/KeyRelease-39.gml` | Right release: sets `fspeed = 0`; if no other key held → `sprite_index = NaviStandR` |
| `extracted/client-5-8/objects/0000-server/events/KeyRelease-40.gml` | Down release: sets `fspeed = 0`; if no other key held → `sprite_index = NaviStandD` |
| `extracted/client-5-8/objects/0000-server/events/Draw.gml` | `font_color = c_aqua` sets name text color; `draw_sprite(sprite_index,-1,x,y)` drives animation frame via GM5 auto-advance |
| `tools/asset-pipeline/output/atlas-mvp.json` | Atlas frame key format: `<spriteId>_000..005` (zero-padded 3-digit); all Run sprites have 6 frames |

---

## §Direction Mapping (8 quadrants)

Velocity sign convention: screen-Y grows **down** (positive vy = moving down).
`atan2(-vy, vx)` converts screen coordinates to standard math coordinates.

| Velocity sign | atan2(-vy, vx) range | Facing | Stand sprite | Run sprite | Frame count |
|---------------|---------------------|--------|-------------|-----------|-------------|
| vx>0, vy=0 | [-22.5°, +22.5°] | R | `0029-NaviStandR_000` | `0028-NaviRunR` | 6 |
| vx>0, vy<0 | [+22.5°, +67.5°] | UR | `0038-NaviStandUR_000` | `0039-NaviRunUR` | 6 |
| vx=0, vy<0 | [+67.5°, +112.5°] | U | `0030-NaviStandU_000` | `0031-NaviRunU` | 6 |
| vx<0, vy<0 | [+112.5°, +157.5°] | UL | `0040-NaviStandUL_000` | `0041-NaviRunUL` | 6 |
| vx<0, vy=0 | [+157.5°, -157.5°] | L | `0033-NaviStandL_000` | `0032-NaviRunL` | 6 |
| vx<0, vy>0 | [-157.5°, -112.5°] | DL | `0042-NaviStandDL_000` | `0043-NaviRunDL` | 6 |
| vx=0, vy>0 | [-112.5°, -67.5°] | D | `0000-NaviStandD_000` | `0027-NaviRunD` | 6 |
| vx>0, vy>0 | [-67.5°, -22.5°] | DR | `0036-NaviStandDR_000` | `0037-NaviRunDR` | 6 |

---

## §D-31 Cycle Advance

**BNO image_speed evidence:**

`extracted/client-5-8/objects/0000-server/events/Create.gml:20`:
```
image_speed = 1;
```

GM5 `image_speed = 1` means GM advances the animation index by 1.0 per step (tick).
This is the default GM5 behavior and is confirmed to be explicitly set in Create.gml.

Special cases in Step.gml set `image_speed = 1` for TeleIn/TeleOut/JoinIn/JoinOut
and `image_speed = 0.8` for HexportIn/HexportOut — these are non-navi locomotion
animations and do not apply to the walk cycle.

**Result:**
```
TICKS_PER_FRAME_ADVANCE = 1
```

At TICK_RATE_HZ=30 with TICKS_PER_FRAME_ADVANCE=1 and 6 frames per direction:
- One full walk cycle = 6 ticks = 6/30 = **0.2 seconds** (200 ms)
- Animation rate = 5 frames per second

Without D-31 enforcement (if advance ran at Phaser 60 FPS): cycle would run at
10 fps = 0.1 second per cycle — 2× BNO speed. D-31 prevents this.

---

## §Decisions

### Q1 (RESOLVED 2026-05-10): Missing 0028-NaviRunR

**STATUS: RESOLVED.** Sprite `0028-NaviRunR` is present in the atlas with 6 frames
(`0028-NaviRunR_000` through `0028-NaviRunR_005`). Confirmed in `atlas-mvp.json`.
`RUN_SPRITE_ID.R = '0028-NaviRunR'` — no fallback to mirrored RunL needed.

### Q2: Facing update timing

**Decision: Facing updates on keydown (same tick as input).**

Evidence: `extracted/client-5-8/objects/0000-server/events/Keyboard-39.gml:44-46`:
```gml
fspeed = global.curspeed;
sprite_index = NaviRunR;
```
The Keyboard-39 (right arrow) event sets `sprite_index = NaviRunR` and `fspeed` directly
in the keydown handler — no deferral to the next Step tick. All 4 directional Keyboard
events follow this pattern. Therefore `deriveFrame` updates facing from the velocity
vector on the SAME tick the velocity changes — there is no one-tick lag.

### Q3: Cycle advance rate

**Decision: `TICKS_PER_FRAME_ADVANCE = 1` (from `image_speed = 1` in Create.gml:20).**

See §D-31 Cycle Advance above. BNO runs the navi walk animation at full tick rate —
one frame advance per simulation tick.

### Q4: KeyRelease facing behavior

**Decision: On key release, facing is PRESERVED as the last-pressed direction.**

Evidence: `extracted/client-5-8/objects/0000-server/events/KeyRelease-39.gml`:
```gml
right = 0;
if(!up && !down && !left)
{
  direction = 0;
  fspeed = 0;
  sprite_index = NaviStandR;  // ← still NaviStandR, not NaviStandD
}
```
KeyRelease sets the Stand sprite for the released direction (not a reset to D).
When a key is released mid-diagonal, the remaining held key's direction takes over
(the logic falls through to the corresponding Keyboard-* handler on the next tick).

In `SpriteStateMachine.deriveFrame`, zero velocity returns `STAND_FRAME[lastFacing]`
where `lastFacing` is the facing from the last tick with non-zero velocity. This
faithfully mirrors KeyRelease behavior: Stand<LAST_DIR>.

---

## §Cyan Nametag Derivation (D-27a)

**BNO source:** `extracted/client-5-8/objects/0000-server/events/Draw.gml:2`:
```gml
font_color = c_aqua;
```

GM5 `c_aqua` = BGR value `0xFFFF00` = RGB `#00FFFF` (pure cyan, 255 green + 255 blue).

**UI-SPEC fallback:** `06-UI-SPEC.md §Color accent = #22D3EE` (Tailwind cyan-400).

**Decision:** The existing `Nameplate.ts` (plan 06-07) already uses `#22D3EE`.
`c_aqua = #00FFFF` is *standard* cyan; `#22D3EE` is a visually similar teal-cyan
from the Tailwind palette. Both read as "cyan" to players.

**Final nametag color locked at `#22D3EE`** per the following rationale:
1. `c_aqua = #00FFFF` is pure cyan with no warmth — harsh on dark backgrounds.
2. `#22D3EE` matches the existing UI-SPEC accent established in plan 06-07.
3. The Nameplate class and its existing unit tests already assert `#22D3EE`.
4. Changing to `#00FFFF` would break existing tests without player-visible benefit.
5. Documented as "inspired by c_aqua, refined to UI-SPEC accent" — not a regression.

---

## §Out of Scope (Phase 7)

- Emote animations (BNO had paper/scissors/rock gestures)
- Death/damage animations (JoinOut, TeleOut sequences)
- Per-character color schemes (NCols — `global.ncol[nc,ncval]` overlays)
- BNCentral conversion (Phase 7 PAR-03)
- Audio sync to walk cycle
