---
phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
plan: 06
subsystem: client / prediction + reconciliation + extrapolation + input dispatch
tags:
  - "[doc->REQ-CLI-04]"
dependency_graph:
  requires:
    - "Wave 0 client scaffold (06-01) — apps/client workspace + Vite + RED test stubs"
    - "Wave 2 asset-pipeline (06-02) — D-17 atlas (not strictly required by this plan but referenced)"
    - "Wave 2 protocol amendments (06-03) — PROTOCOL_VERSION 2 + cInputSchema D-09 shape + heldInputs + axis_x_held/axis_y_held broadcast (D-08)"
    - "Wave 3 boot+login (06-04) — apps/client/src/scenes scaffolding + main.ts game config"
  provides:
    - "packages/game-logic/src/accumulator.ts — advanceAccumulator + TICK_MS hoisted from apps/server (shared client/server simulation)"
    - "apps/client/src/prediction/predictor.ts — PredictionEngine (input ring buffer + step()-driven local sim + replay + reset detection)"
    - "apps/client/src/prediction/reconciler.ts — ReconcileEngine (D-10 22 px threshold-gated lerp/snap)"
    - "apps/client/src/prediction/extrapolator.ts — RemoteExtrapolator (D-11 snapshot interp + step()-driven extrapolation + 250 ms cap)"
    - "apps/client/src/prediction/input-dispatcher.ts — InputDispatcher (D-09 event-driven c2s.input + 15 s heartbeat + pauseMovement chat-HUD hook)"
  affects:
    - "apps/server/src/RebnoRoom.ts — local advanceAccumulator definition replaced with re-export from @rebno/game-logic"
    - "packages/game-logic/src/index.ts — exports advanceAccumulator + TICK_MS + AccumulatorResult type"
tech-stack:
  added: []
  patterns:
    - "Pure-purity hoist — advanceAccumulator moved into packages/game-logic so client/server share byte-identical fixed-timestep accumulator"
    - "Server back-compat re-export — apps/server/src/RebnoRoom.ts re-exports advanceAccumulator + TICK_MS from @rebno/game-logic so existing imports (`./RebnoRoom.js`) keep compiling without churn"
    - "Sprite adapter interface for reconciler — ReconcilerSpriteAdapter decouples ReconcileEngine from Phaser; tests inject a mock; GameScene supplies an adapter wrapping Phaser sprite + this.tweens.add()"
    - "Press-order stack for latest-direction-wins input semantics (xOrder/yOrder arrays, most-recent-pressed-first)"
    - "Server-reset detection — PredictionEngine.applyServerSnapshot detects rolling-back last_input_seq and clears unacked + accepts snapshot ground-truth without replay (RESEARCH §Pitfall 2)"
key-files:
  created:
    - packages/game-logic/src/accumulator.ts
    - packages/game-logic/test/accumulator.test.ts
    - apps/client/src/prediction/predictor.ts
    - apps/client/src/prediction/reconciler.ts
    - apps/client/src/prediction/extrapolator.ts
    - apps/client/src/prediction/input-dispatcher.ts
    - apps/client/src/__test__/input-dispatcher.test.ts
  modified:
    - packages/game-logic/src/index.ts
    - apps/server/src/RebnoRoom.ts
    - apps/client/src/__test__/prediction.test.ts
    - apps/client/src/__test__/reconciler.test.ts
    - apps/client/src/__test__/extrapolation.test.ts
    - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/deferred-items.md
decisions:
  - "D-10 divergence threshold LOCKED at 22 px — half a 44×40 tile. Phase 5 staging soak telemetry NOT YET CAPTURED (RESEARCH §Open Q4); revisit after first staging deploy operator UAT. The constant is named DIVERGENCE_THRESHOLD_PX so the future tune is a one-line change."
  - "D-11 EXTRAPOLATION_CAP_MS LOCKED at 250 ms — past cap, sprite freezes at last extrapolated position. The deterministic step() against pinned signed-room layout prevents wall-clipping during extrapolation."
  - "advanceAccumulator + TICK_MS HOISTED into packages/game-logic. apps/server/src/RebnoRoom.ts re-exports both so the existing tick-accumulator.test.ts and onMessageHandlers.ts imports stay green (8/8 server accumulator tests still pass)."
  - "ReconcileEngine uses Math.hypot (not Math.sqrt(dx*dx+dy*dy)) — Math.hypot is the IEEE-754-friendly form per MDN and avoids underflow on tiny deltas."
  - "PredictionEngine ring buffer cap MAX_UNACKED_INPUTS = 256 (≈12.8 s at 20 Hz) — comfortably exceeds the server's 10 s allowReconnection grace (SRV-06). At cap, oldest entries drop and the next server snapshot catches us up via replay."
  - "RemoteExtrapolator three-regime sample(): interpolation → stale-clamp (≤200 ms) → step()-driven extrapolation (>200 ms, capped at 250 ms). The interpolation regime requires TWO snapshots to bracket; with one snapshot the stale-clamp branch always wins."
  - "InputDispatcher latest-direction-wins via press-order stack — when both 'D' and 'A' are held, the most-recently pressed key dictates axis_x; on its keyup, the still-held opposite resurfaces. Browser key-repeat (auto-fire keydown while held) is filtered by the heldKeys Set."
  - "InputDispatcher 15 s heartbeat REAFFIRMS only when held axes are non-zero — silent player ({0,0}) skips the timer's send so we don't waste bandwidth or quota under D-22 (10/s burst 15)."
  - "InputDispatcher.pauseMovement() is the chat-HUD integration hook for plan 06-07 — clears held state and sends a SINGLE {0,0} input so the server's heldInputs map zeroes the axes (D-09 contract: server doesn't infer release from absence of events; only an explicit zero-axes payload zeroes)."
metrics:
  duration: "~25 minutes"
  completed: "2026-05-10"
  tests_added: 27
  tests_passing: 33
  files_created: 7
  files_modified: 6
---

# Phase 6 Plan 06: Client-Side Prediction (CLI-04) Summary

Closes CLI-04 at the component level. PredictionEngine + ReconcileEngine + RemoteExtrapolator + InputDispatcher implemented and unit-tested against `@rebno/game-logic` `step()`. `advanceAccumulator` hoisted into `packages/game-logic` so client and server share a single byte-identical fixed-timestep accumulator. D-10 threshold locked at 22 px, D-11 cap locked at 250 ms, D-09 event-driven c2s.input shape consumed end-to-end with 15 s heartbeat reaffirmation.

## Commits

| Hash    | Subject                                                                       |
|---------|-------------------------------------------------------------------------------|
| 2bdc1a3 | refactor(06-06): hoist advanceAccumulator into @rebno/game-logic              |
| 452b69b | feat(06-06): PredictionEngine + ReconcileEngine + RemoteExtrapolator (CLI-04) |
| e0a0f20 | feat(06-06): InputDispatcher (D-09 event-driven c2s.input + heartbeat)        |

## Tasks Completed

### Task 1 — Hoist + PredictionEngine + ReconcileEngine + RemoteExtrapolator (commits 2bdc1a3, 452b69b)

**accumulator hoist (commit 2bdc1a3):**

- New `packages/game-logic/src/accumulator.ts` exports `advanceAccumulator(acc, realDt, tickMs?)` + `TICK_MS = 50` + `AccumulatorResult` type. Pure (no `Date.now`/`Math.random`/I/O).
- `packages/game-logic/src/index.ts` re-exports the new symbols.
- `apps/server/src/RebnoRoom.ts` replaces its inline `advanceAccumulator` definition with `import { advanceAccumulator, TICK_MS as GAME_LOGIC_TICK_MS } from '@rebno/game-logic'` and re-exports both names (`export { advanceAccumulator }`, `export const TICK_MS = GAME_LOGIC_TICK_MS`) so existing imports from `./RebnoRoom.js` keep working without churn.
- 7 new tests in `packages/game-logic/test/accumulator.test.ts`. Existing 8 server-side tests in `apps/server/test/tick-accumulator.test.ts` still pass against the re-exported function.

**predictor.ts (commit 452b69b):**

- `PredictionEngine` class with input ring buffer (`MAX_UNACKED_INPUTS = 256`), `enqueueInput`, `predictTick`, `applyServerSnapshot`, getters for local state / unacked count / next seq.
- Replay logic: on snapshot, drop unacked inputs ≤ `snap.last_input_seq`, then re-run `step()` from snapshot forward applying remaining unacked inputs in seq order.
- Server-reset detection (RESEARCH §Pitfall 2): if `snap.last_input_seq < lastSeqAcked` → clear unacked + accept snapshot as ground truth; re-key `nextSeq = snap.last_input_seq + 1`.

**reconciler.ts (commit 452b69b):**

- `ReconcileEngine` consuming `PredictionEngine` + `ReconcilerSpriteAdapter` (interface for sprite mutations) + `() => RoomLayout`.
- `onServerSnapshot(snap)` computes pre-apply divergence via `Math.hypot`, hands snapshot to PredictionEngine (which manages its own replay), then either `setPosition(predicted.x, predicted.y)` (≥22 px) or `tweenTo(predicted.x, predicted.y, 100ms)` (<22 px).
- `DIVERGENCE_THRESHOLD_PX = 22` and `LERP_DURATION_MS = 100` exported as named constants.

**extrapolator.ts (commit 452b69b):**

- `RemoteExtrapolator` with internal snapshot ring buffer (`SNAPSHOT_RING_SIZE = 16`) and three-regime `sample(displayTimeMs)`:
  1. **Interpolation** — bracketing-snapshot lerp toward `displayTime - 100 ms` (D-11 backbuffer).
  2. **Stale clamp** — when `elapsedSinceRecentMs ≤ STALE_THRESHOLD_MS = 200`, freeze at most-recent snapshot.
  3. **Step()-driven extrapolation** — past stale, advance one `TICK_MS` per `sample()` call applying broadcast `axis_x_held` / `axis_y_held`. Capped at `EXTRAPOLATION_CAP_MS = 250` past stale, then `frozen: true`.
- `pushSnapshot()` invalidates any in-progress extrapolation — new snapshot is ground truth.

**Tests (commit 452b69b, all GREEN, replaced Wave-0 RED stubs):**

- `prediction.test.ts` — 6 tests
- `reconciler.test.ts` — 4 tests
- `extrapolation.test.ts` — 7 tests

### Task 2 — InputDispatcher (commit e0a0f20)

- `InputDispatcher` consumes `Pick<Room, 'send'>` + `PredictionEngine` + optional `isWorldMutationAllowed` predicate. `attachTo(target)` binds `keydown`/`keyup` listeners and returns a teardown closure. `onKey(key, kind)` is the synchronous test entry point so unit tests can skip DOM event synthesis.
- D-09 wire shape per `cInputSchema`: `{ type:'input', seq, axes:{x,y}, buttons_down, buttons_up, monotonic_at_ms }`. Identity is NOT on the wire — server tags from `client.auth.account_id`.
- Latest-direction-wins via press-order stacks (`xOrder`, `yOrder`, most-recent-pressed-first). Browser key-repeat ignored via `heldKeys` Set.
- 15 s heartbeat (`HEARTBEAT_INTERVAL_MS = 15_000`) reaffirms held axes only when non-zero (silent player skips). `pauseMovement()` chat-HUD hook clears held state and sends a single `{0,0}` input so server's `heldInputs` map zeroes the axes.
- `dispose()` clears the heartbeat interval.
- 10 unit tests in `apps/client/src/__test__/input-dispatcher.test.ts` — keydown/keyup, latest-direction-wins, heartbeat reaffirm, silent-player no-op, dispose, non-movement filter, key-repeat guard, pauseMovement, attachTo bind/teardown, payload shape.

## Verification

- `pnpm --filter @rebno/game-logic test` → 21/21 (4 files, includes new accumulator suite)
- `pnpm --filter @rebno/client test` → 43/43 (8 files; was 16 before plan, +27 new tests across the 4 plan-06-06 modules)
- `pnpm --filter @rebno/client typecheck` → exit 0
- `pnpm --filter @rebno/server typecheck` → exit 0
- `apps/server/test/tick-accumulator.test.ts` → 8/8 (verifies hoist did not regress server simulation)
- `pnpm trace:check` → REQ-CLI-04 status `[OK]` with stages `+doc +impl +unit +int` (the +int comes from prior plans; this plan filled +impl/+unit).
- Verification greps:
  - `grep -c 'DIVERGENCE_THRESHOLD_PX = 22' apps/client/src/prediction/reconciler.ts` → 1
  - `grep -c 'EXTRAPOLATION_CAP_MS = 250' apps/client/src/prediction/extrapolator.ts` → 1
  - `grep -c 'HEARTBEAT_INTERVAL_MS' apps/client/src/prediction/input-dispatcher.ts` → 2
  - `grep -c 'advanceAccumulator' apps/server/src/RebnoRoom.ts` → 4 (import + re-export + 2 callsites)

## Deviations from Plan

### Auto-fixed Issues

**1. `[Rule 3 — blocking]` Worktree base mis-aligned at agent startup**
- **Found during:** Task 1 environment bring-up
- **Issue:** The agent's worktree was initially at base `02ec391` (Phase 5 docs commit) which predates Phase 6 entirely; `apps/client/` and the prediction RED-stub tests didn't exist. Plan-06-06 frontmatter assumes plan 06-04 outputs (`apps/client/src/scenes/`, `apps/client/src/__test__/{prediction,reconciler,extrapolation}.test.ts`) are present. The orchestrator prompt explicitly stated "Base = main `1eb921a`".
- **Fix:** `git reset --hard 1eb921a` in the worktree to align with the orchestrator's stated base. This was a one-shot setup-time correction (per the `<worktree_branch_check>` allow-list, the destructive op is permitted at startup before any task work). Subsequent `pnpm install --frozen-lockfile` + workspace package builds (`@rebno/protocol`, `@rebno/game-logic`, `@rebno/db`) restored the build graph.
- **Files modified:** worktree branch HEAD only; no source files.
- **Commit:** none (pre-task setup).

**2. `[deferred]` Pre-existing CRLF strip bug in `tools/scripts/lint-game-logic-purity.mjs`**
- **Found during:** Task 1 verification gate
- **Issue:** `pnpm lint:game-logic-purity` reports `Math.random` violations in `packages/game-logic/src/{rng.ts,step.ts}`, but those occurrences are entirely inside `// ...` comment lines that document "no Math.random anywhere". The strip regex `/\/\/.*$/` doesn't match because Windows CRLF leaves a `\r` before the `$` anchor — `$` in default JS-regex mode does not match before `\r`.
- **Fix attempted:** None — pre-existing, NOT caused by plan 06-06's changes. The plan's new `packages/game-logic/src/accumulator.ts` is independently verified clean (no `Math.random` substring in any line, comment or otherwise). Logged to `.planning/phases/06-.../deferred-items.md` with suggested one-line fix (`/\/\/[^\r\n]*$/m` or pre-strip `\r`) for a future tooling plan.
- **Files modified:** `.planning/phases/06-.../deferred-items.md` (deferred-items log only).
- **Commit:** included in 452b69b.

### Rule 4 (architectural) deviations

None.

### Auth gates

None encountered.

## Threat Surface Scan

No new security-relevant surface introduced by this plan beyond the threats already enumerated in the plan's `<threat_model>`. The dispatcher only WRITES to `room.send`; the server's `cInputSchema.strict()` parse is unchanged from plan 06-03 and rejects any forged `account_id`/`x`/`y`/extra fields at the wire boundary.

## Known Stubs

None. All four production modules ship complete with tests.

## TDD Gate Compliance

This plan was scoped as `tdd="true"` per task. The Wave-0 RED commits live in plan 06-01 history (`it.todo` stubs); plan 06-06 closes them with GREEN commits 452b69b (predictor + reconciler + extrapolator) and e0a0f20 (input-dispatcher). The intermediate refactor commit 2bdc1a3 (accumulator hoist) is not part of the RED/GREEN cycle but is mechanically pure and individually unit-tested. No REFACTOR commit needed — both GREEN modules are first-write production-shape code.

## Phase 7 / Plan 06-07 Hand-off

- `InputDispatcher.pauseMovement()` is the canonical chat-HUD integration hook. Plan 06-07 (Chat HUD) MUST call this when the chat input opens (per CONTEXT D-04 + RESEARCH §Pitfall 3) so the server's `heldInputs` map clears.
- `ReconcilerSpriteAdapter` is the canonical contract for plan 06-08 (GameScene). GameScene supplies an adapter wrapping `Phaser.GameObjects.Sprite` + `scene.tweens.add({ targets, x, y, duration })`. The engine never knows about Phaser.
- `EXTRAPOLATION_CAP_MS = 250` and `DIVERGENCE_THRESHOLD_PX = 22` are tunable named constants. Phase 5 staging soak operator UAT (RESEARCH §Open Q4) is the next gate to confirm or revise.

## Self-Check: PASSED

All 7 files claimed under `key-files.created` exist. All 3 commits (2bdc1a3, 452b69b, e0a0f20) exist in `git log --all`. All 4 plan-06-06 client test suites pass (prediction 6 + reconciler 4 + extrapolation 7 + input-dispatcher 10 = 27 new). Server `tick-accumulator.test.ts` 8/8 still green.
