---
phase: 06.2-gap-closure-d-50-d-53-uat-2026-05-13
plan: 06
subsystem: client/scenes + client/prediction
tags: [fix, D-41, player-renderer, local-anim, e2e, axis-held]
requirements:
  - REQ-CLI-04
  - REQ-CLI-08
gap_closure: true
dependency-graph:
  requires:
    - "06.2-02 (telemetry spike — confirmed H2: localTickRate≈30, localTickVx=0)"
  provides:
    - "D-41 closed: local sprite animates during WASD input"
    - "Anti-recurrence e2e gate: cli-08-local-anim.e2e.test.ts"
  affects:
    - "apps/client/src/scenes/GameScene.ts update() simTickAccumulator while-loop"
    - "apps/client/src/prediction/input-dispatcher.ts (new public axisX/axisY getters)"
tech-stack:
  added: []
  patterns:
    - "Mirror remote-player pattern: axis_x_held * RUN_SPEED_PX_PER_TICK used for both local and remote"
    - "Public axis getters on InputDispatcher expose current held axis for sim-tick callers"
key-files:
  created:
    - apps/client/test/e2e/cli-08-local-anim.e2e.test.ts
    - .planning/phases/06.2-gap-closure-d-50-d-53-uat-2026-05-13/06.2-06-SUMMARY.md
  modified:
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/prediction/input-dispatcher.ts
    - apps/client/src/__test__/game-scene.test.ts
decisions:
  - "Hypothesis confirmed: H2 (post-step-zero). localTickRate≈30, localTickVx=0 during WASD hold on staging. BNO instant-set model writes vx_post=0 after applying displacement; deriveFrame(0,0,...) produces Stand."
  - "Fix: source velocity from axis intent (axisX/axisY * RUN_SPEED_PX_PER_TICK) not post-step state. Mirrors existing remote-player pattern at GameScene.ts line ~769."
  - "Added public axisX() / axisY() getters to InputDispatcher rather than exposing currentAxes() directly — narrower surface, matches the type the callsite needs (-1|0|1)."
  - "E2e gate path: apps/client/test/e2e/cli-08-local-anim.e2e.test.ts (matches project convention, not apps/client/e2e/ — plan frontmatter path was wrong per phase_context)."
metrics:
  duration: "~18 min"
  tasks_completed: 2
  files_modified: 4
  commits:
    - 295fc3a  # Task 2 — axis-held wiring fix
    - f7a7892  # Task 3 — e2e anti-recurrence gate
completed: 2026-05-13
---

# Phase 06.2 Plan 06: D-41 Axis-Held Wiring Fix — Summary

**One-liner:** Sourced onSimulationTickLocal vx/vy from axis-held intent (axisX/Y * RUN_SPEED_PX_PER_TICK) instead of post-step-zero predictTick output, matching the remote-player pattern, and added a Playwright anti-recurrence gate.

## Hypothesis Confirmed

**H2 — BNO instant-set vx_post=0.** Staging telemetry (06.2-02 spike) showed:
- `localTickRate ≈ 30` — sim-tick fires correctly at 30 Hz
- `localTickVx = 0` — velocity arg passed to `onSimulationTickLocal` is always 0

Under BNO instant-set, `predictTick()` applies the full displacement in one step and returns `vx=0, vy=0` (no inertia carry). Passing these to `deriveFrame(0, 0, ...)` always produces the Stand frame.

## Fix Locus

**File:** `apps/client/src/scenes/GameScene.ts`
**Location:** `update()` method, `simTickAccumulator` while-loop, lines 751–758

### Before (broken)
```ts
this.playerRenderer.onSimulationTickLocal(
  localSim.vx,   // always 0 under BNO instant-set
  localSim.vy,   // always 0 under BNO instant-set
  localSim.x,
  localSim.y,
);
```

### After (fixed)
```ts
// Plan 06.2-06 D-41 fix — mirror remote pattern at line ~762.
// [impl->REQ-CLI-04] [impl->REQ-CLI-08]
const axisVx = (this.inputDispatcher?.axisX() ?? 0) * RUN_SPEED_PX_PER_TICK;
const axisVy = (this.inputDispatcher?.axisY() ?? 0) * RUN_SPEED_PX_PER_TICK;
this.playerRenderer.onSimulationTickLocal(
  axisVx,         // intent: -5 | 0 | 5
  axisVy,         // intent: -5 | 0 | 5
  localSim.x,     // position unchanged (predicted)
  localSim.y,     // position unchanged (predicted)
);
```

Position arguments `(x, y)` remain `localSim.x/localSim.y` — the fix is velocity-only.

## RUN_SPEED Constant Source

`RUN_SPEED_PX_PER_TICK` imported from `@rebno/game-logic` (value: 5 px/tick). This is the same constant used in the remote-player block at GameScene.ts lines 769–770 (`(p.axis_x_held ?? 0) * RUN_SPEED_PX_PER_TICK`).

## New Getters Added to InputDispatcher

`apps/client/src/prediction/input-dispatcher.ts` — two new public methods added after `getFacingHint()`:

```ts
axisX(): -1 | 0 | 1   // returns currentAxes().x — the live horizontal axis
axisY(): -1 | 0 | 1   // returns currentAxes().y — the live vertical axis
```

`currentAxes()` already implements the correct behavior (latest-direction-wins stack, Shift-stand gate). The new getters are thin forwarding methods — no logic duplication.

## Symmetry With Remote Players

| | Before | After |
|---|---|---|
| Remote vx | `(p.axis_x_held ?? 0) * RUN_SPEED_PX_PER_TICK` | (unchanged) |
| Local vx | `localSim.vx` (always 0) | `(inputDispatcher.axisX() ?? 0) * RUN_SPEED_PX_PER_TICK` |

Local and remote now use the same input-intent pattern for animation driving.

## E2e Anti-Recurrence Gate

**File:** `apps/client/test/e2e/cli-08-local-anim.e2e.test.ts`

Test flow:
1. Login as `accountA`, wait for `[data-game-ready]`
2. Click canvas to focus Phaser input layer
3. Hold `KeyD` for 1000 ms, polling `window.__rebno.localFrame` every 50 ms (20 samples)
4. Release `KeyD`
5. Assert distinct frame key count ≥ 3 (proves animation cycles)
6. Assert at least one observed key contains `'Run'` (proves Stand was exited)

Tagged `[int->REQ-CLI-04] [int->REQ-CLI-08]`.

## Verification

| Check | Result |
|---|---|
| `pnpm --filter @rebno/client typecheck` | exit 0 |
| `pnpm --filter @rebno/client test` (24 test files, 184 tests) | all passed |
| `grep -n "axisX\|axisY" GameScene.ts` finds lines 751-752 | confirmed |
| Tags `[impl->REQ-CLI-04] [impl->REQ-CLI-08]` in GameScene.ts | present |
| Tags `[int->REQ-CLI-04] [int->REQ-CLI-08]` in e2e gate | present |
| e2e file at `apps/client/test/e2e/cli-08-local-anim.e2e.test.ts` | created |

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 3 - Blocking] Worktree missing node_modules and package builds**
- **Found during:** Task 2 verification (`pnpm --filter @rebno/client typecheck` failed).
- **Issue:** Fresh worktree had no installed dependencies (same pattern as 06.2-02).
- **Fix:** `pnpm install --prefer-offline` + built `@rebno/game-logic` + `@rebno/protocol`.
- **Files modified:** None (build artifacts only).

**2. [Process] CRLF noise in packages/protocol/src/legacy-opcodes.ts**
- **Found during:** Task 2 `git status`.
- **Issue:** `pnpm --filter @rebno/protocol build` pre-build sync re-touches line endings on Windows (same as 06.2-02).
- **Fix:** `git checkout -- packages/protocol/src/legacy-opcodes.ts` before staging.

**3. [Rule 2 - Missing functionality] game-scene.test.ts mock missing axisX/axisY**
- **Found during:** Reading game-scene.test.ts InputDispatcher mock during Task 2.
- **Issue:** The existing test mock for InputDispatcher lacked `axisX`/`axisY` stubs. After the GameScene.ts callsite change, any test that exercises the sim-tick while-loop would fail with `TypeError: this.inputDispatcher.axisX is not a function`.
- **Fix:** Added `axisX = vi.fn().mockReturnValue(0)` and `axisY = vi.fn().mockReturnValue(0)` to the mock.
- **Files modified:** `apps/client/src/__test__/game-scene.test.ts`.

**4. [Process] Plan frontmatter specifies wrong e2e path**
- **Found during:** Task 3 setup (Glob check).
- **Issue:** Plan frontmatter listed `apps/client/e2e/cli-08-local-anim.spec.ts` but phase_context and all existing e2e files are at `apps/client/test/e2e/*.e2e.test.ts`.
- **Fix:** Used the correct path `apps/client/test/e2e/cli-08-local-anim.e2e.test.ts`.

## Auth Gates

None.

## Known Stubs

None — the fix is fully wired. `axisX()` and `axisY()` return live held-key state; `onSimulationTickLocal` receives real intent velocity.

## Threat Flags

None — no new network endpoints, auth paths, file access, or schema-boundary surface introduced.

## Self-Check

**Files claimed created/modified:**
- `apps/client/src/scenes/GameScene.ts` — modified (lines 746-758 axisVx/axisVy pattern)
- `apps/client/src/prediction/input-dispatcher.ts` — modified (new axisX/axisY methods)
- `apps/client/src/__test__/game-scene.test.ts` — modified (axisX/axisY mock stubs)
- `apps/client/test/e2e/cli-08-local-anim.e2e.test.ts` — created
- `.planning/phases/06.2-gap-closure-d-50-d-53-uat-2026-05-13/06.2-06-SUMMARY.md` — being written now

**Commits claimed:**
- `295fc3a` — Task 2 fix
- `f7a7892` — Task 3 e2e gate

## Self-Check: PASSED
