---
phase: 06.1-gap-closure-d-39-d-46-uat-2026-05-11
plan: 02
subsystem: game-logic
tags: [game-logic, collision, constants, single-speed, navi-mask]
requires: []
provides:
  - RUN_SPEED_PX_PER_TICK constant (value 5) for Wave 2/3 callsites
  - FRAMES_PER_TICK_AT_RUN constant (value 0.5) for SpriteStateMachine
  - NAVI_MASK constant ({left:9, top:40, right:26, bottom:46}) for renderers
  - RoomLayout.walkable_grid OPTIONAL field for client-side prediction
  - isFeetBboxWalkable() pure helper exported from collision.ts
  - Per-axis sub-pixel collision loop in step() (wall-slide + edge-block)
affects:
  - packages/game-logic/src/constants.ts
  - packages/game-logic/src/types.ts
  - packages/game-logic/src/collision.ts
  - packages/game-logic/src/step.ts
  - packages/game-logic/test/movement-constants.test.ts
  - packages/game-logic/test/step-bno-fidelity.test.ts
tech-stack:
  added: []
  patterns:
    - "extract→cite→name (S-02): every new constant carries SOURCE: comment block citing extracted GML"
    - "per-axis 1-px sub-pixel collision loop (D6.1-28) ported from Step.gml:222-243"
    - "OPTIONAL field on shared interface preserves wire shape (checker B2 mitigation for T-06.1.02-01)"
key-files:
  created:
    - packages/game-logic/test/run-speed.test.ts
    - packages/game-logic/test/navi-mask-bbox.test.ts
    - packages/game-logic/test/wall-slide.test.ts
    - packages/game-logic/test/walkable-edge.test.ts
  modified:
    - packages/game-logic/src/constants.ts
    - packages/game-logic/src/types.ts
    - packages/game-logic/src/collision.ts
    - packages/game-logic/src/step.ts
    - packages/game-logic/test/movement-constants.test.ts
    - packages/game-logic/test/step-bno-fidelity.test.ts
decisions:
  - WALK_SPEED_PX_PER_TICK (=3) renamed to RUN_SPEED_PX_PER_TICK (=5) — single-speed rebno per D6.1-05
  - walkable_grid added as OPTIONAL on RoomLayout (preserves server-broadcast wire shape per checker B2)
  - step() falls back to resolveCollision + clampToRoom when walkable_grid is absent (server-side path unchanged from pre-06.1)
  - Tests placed under packages/game-logic/test/ (matches existing vitest config `include: ['test/**/*.test.ts']`); plan's `tests/` path was a typo
metrics:
  duration: "~10 minutes"
  completed_date: "2026-05-12"
  tasks_completed: 2
  files_created: 4
  files_modified: 6
  tests_added: 4
  tests_total_passing: 50
requirements: [REQ-CLI-04]
---

# Phase 06.1 Plan 02: WALK→RUN + NaviMask Feet-bbox + Walkable-Grid Per-axis Collision Summary

JWT-style single-speed RUN constant + NaviMask feet bbox + walkable-grid per-axis sub-pixel collision loop landed in `@rebno/game-logic`, gated by a guard so server-side behaviour is unchanged.

## Objective

Close D-44 (single-speed run), D-41/D-42 (canonical anim rate exposure), and D6.1-27/28/29 (NaviMask feet bbox, per-axis sub-pixel collision, walkable-region edge-block) inside the pure `@rebno/game-logic` package. This is the shared simulation core for both server tick and client prediction, so it must apply identically in both — with the new client-only walkable-grid path gated behind an OPTIONAL field so server-side authoritative collision (`collision_polys`) remains the ground truth (CLAUDE.md hard rule #1 + threat model T-06.1.02-01).

## Tasks Completed

| # | Task | Commit | Files |
|---|------|--------|-------|
| 1 | RED — pin constants + bbox + wall-slide + walkable-edge | `88d6fdb` | 4 new test files under `packages/game-logic/test/` |
| 2 | GREEN — rename WALK→RUN + NAVI_MASK + RoomLayout.walkable_grid + isFeetBboxWalkable + per-axis step loop | `0c40432` | constants/types/collision/step + 2 existing test files updated |

## Implementation Notes

### Constants (D-44, D-41/D-42)

`packages/game-logic/src/constants.ts` adds three load-bearing constants, each with a `SOURCE:` JSDoc block (S-02 extract→cite→name pattern):

- `RUN_SPEED_PX_PER_TICK = 5` — replaces `WALK_SPEED_PX_PER_TICK = 3` per `extracted/client-5-8/objects/0000-server/events/KeyPress-82.gml:5-8`. Single-speed rebno (D6.1-05); the legacy Ctrl+R walk/run toggle path is dropped.
- `FRAMES_PER_TICK_AT_RUN = RUN_SPEED_PX_PER_TICK / 10 = 0.5` — canonical anim rate from `extracted/client-5-8/objects/0000-server/events/Other-7.gml:4` (`image_speed = curspeed / 10`). SpriteStateMachine in Plan 06.1-04 consumes this as its `framesPerTick` parameter.
- `NAVI_MASK = {left:9, top:40, right:26, bottom:46}` — 18×7 feet bbox pinned to `extracted/client-5-8/sprites/0034-NaviMask/meta.json`. Cross-checked at test time so silent re-extraction is detected immediately.

### Types (D6.1-29, checker B2)

`packages/game-logic/src/types.ts` adds:

```ts
export interface WalkableGrid {
  width_tiles: number;
  height_tiles: number;
  tile_w: number;  // = 44 per CLAUDE.md
  tile_h: number;  // = 40 per CLAUDE.md
  cells: Uint8Array;  // row-major; 1=walkable, 0=blocked
}

export interface RoomLayout {
  collision_polys: ReadonlyArray<ReadonlyArray<{ x: number; y: number }>>;
  room_size: { w: number; h: number };
  walkable_grid?: WalkableGrid;  // OPTIONAL — preserves server wire shape
}
```

`walkable_grid` is OPTIONAL per checker B2's mitigation for T-06.1.02-01. The server-broadcast / Ed25519-signed `RoomLayout` payload shape is unchanged; the field is populated by the client (`RoomCollision.deriveWalkableGrid` in Plan 06.1-05) AFTER the room loads, BEFORE invoking `step()`. The server constructor (`apps/server/src/RebnoRoom.ts:562`) is unchanged — `walkable_grid` is implicitly `undefined`.

### Collision Helper (D6.1-27/29)

`packages/game-logic/src/collision.ts` adds `isFeetBboxWalkable(grid, left, top, right, bottom)` — a pure 4-corner probe. It samples the 4 corners of the NaviMask feet bbox via `floor(px / tile_w)` / `floor(py / tile_h)` and returns `false` on any out-of-bounds OR non-walkable cell. The 18×7 bbox can straddle at most 2 cells per axis, so 4-corner sampling is sufficient — no interior cells can be blocked while all 4 corners are walkable at this bbox size. Existing `resolveCollision` and `clampToRoom` exports stay in place (still used by the no-grid fallback path).

### Step Loop (D6.1-28)

`packages/game-logic/src/step.ts` replaces the previous single-shot `resolveCollision` + `clampToRoom` block (lines 127-146) with a two-branch collision phase:

- **`grid !== undefined` branch (client prediction):** per-axis 1-px advance loop. X-axis first: probe NaviMask feet bbox at each `+signX` step via `isFeetBboxWalkable`; break on blocked. Then Y-axis from the post-X position (so wall-slide composes naturally — x advances first, then y attempts and may break early on a corner). No `clampToRoom` — the walkable grid IS the bound per D6.1-16 unbounded camera.
- **`grid === undefined` branch (server fallback):** the original full-vector `resolveCollision` + `clampToRoom`. This preserves the threat-model invariant T-06.1.02-01: the authoritative server never trusts a client-fabricated grid — it doesn't even consume the field; collision_polys remain the ground truth.

Diagonal normalization at RUN=5 produces `round(5 / √2)` = 4 px per axis (vs the previous WALK=3 → 2 px). This is consistent with the existing 06-13 BNO-fidelity model. The opposite-axes-cancel, sub-pixel snap-round, and ACCEL/FRICTION=0 invariants are preserved.

## Tests (Wave 0)

4 new test files added under `packages/game-logic/test/` (existing convention — the project's `vitest.config.ts` uses `include: ['test/**/*.test.ts']`):

1. **`run-speed.test.ts`** — pins `RUN_SPEED_PX_PER_TICK=5` and `FRAMES_PER_TICK_AT_RUN=0.5`; asserts `WALK_SPEED_PX_PER_TICK` is no longer exported (catches accidental re-introduction).
2. **`navi-mask-bbox.test.ts`** — pins `NAVI_MASK={9,40,26,46}` AND `readFileSync`s the canonical `extracted/client-5-8/sprites/0034-NaviMask/meta.json` to cross-check `bboxLeft/Top/Right/Bottom`. Test files are NOT under the lint-purity scope, so `node:fs` is permitted (and intentional — it's the regression guard).
3. **`wall-slide.test.ts`** — 3×2 grid (row 0 walkable, row 1 blocked); diagonal `(1,1)` input must advance x by `round(RUN/√2)=4` while y stays at start (per-axis loop produces slide along the free axis).
4. **`walkable-edge.test.ts`** — 3×3 all-walkable grid; player feet bbox flush against east edge (`px = 132 - NAVI_MASK.right = 106`); cardinal `(1,0)` input must NOT advance x past the walkable-region boundary (first +1 probe lands out-of-bounds → blocked).

Existing tests `step-bno-fidelity.test.ts` and `movement-constants.test.ts` were updated for the WALK→RUN rename. Assertions like `60 * WALK_SPEED_PX_PER_TICK = 180` are now `60 * RUN_SPEED_PX_PER_TICK = 300` — still valid trajectories at the new single-speed value. Collision and diagonal-normalization assertions still hold (`dx < RUN_SPEED`, `mag < RUN * √2`, etc.).

## Verification

| Gate | Command | Result |
|---|---|---|
| Unit tests | `pnpm --filter @rebno/game-logic test` | **50/50 pass** across 10 files (4 new + 6 existing) |
| Purity lint | `node tools/scripts/lint-game-logic-purity.mjs` | OK (7 src files clean) |
| Build | `pnpm --filter @rebno/game-logic build` | clean |
| Server typecheck | `pnpm --filter @rebno/server typecheck` | clean (`walkable_grid?` is OPTIONAL — server constructor unchanged) |
| Client typecheck | `pnpm --filter @rebno/client typecheck` | clean |
| Trace check (REQ-CLI-04) | `pnpm trace:check` filter REQ-CLI-04 | `[OK]` (+doc +impl +unit +int) |

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 3 — Blocking] Test directory path mismatch**
- **Found during:** Task 1
- **Issue:** Plan acceptance criteria reference `packages/game-logic/tests/` (plural), but the project's `vitest.config.ts` (existing) has `include: ['test/**/*.test.ts']` (singular). Tests written to `tests/` would not be discovered by the test runner.
- **Fix:** Placed all 4 new test files under `packages/game-logic/test/` to match the existing convention. The plan's `tests/` path was a typo — every existing test in the project lives under `test/`.
- **Files affected:** `packages/game-logic/test/{run-speed,navi-mask-bbox,wall-slide,walkable-edge}.test.ts`
- **Commit:** `88d6fdb`

**2. [Rule 3 — Blocking] WALK_SPEED_PX_PER_TICK call-site rename**
- **Found during:** Task 2
- **Issue:** `WALK_SPEED_PX_PER_TICK` was imported by `step-bno-fidelity.test.ts` and `movement-constants.test.ts`. Renaming the export to `RUN_SPEED_PX_PER_TICK` without updating these tests would break the existing test suite (out of scope: "step.test.ts continues to pass or is updated minimally" per the plan's behavior block).
- **Fix:** Renamed imports + value pins in both tests. Value pin in `movement-constants.test.ts` updated 3→5 with rationale comment citing Plan 06.1-02 D6.1-05. Trajectory expectations in `step-bno-fidelity.test.ts` (e.g., `60 * WALK → 60 * RUN`) auto-adjust via the symbolic reference; the new values are still correct BNO-fidelity trajectories at single-speed RUN.
- **Commit:** `0c40432`

**3. [Rule 2 — Auto-add missing critical functionality] Server-side fallback collision path**
- **Found during:** Task 2 analysis of plan checker B2 + threat model T-06.1.02-01
- **Issue:** The plan's `<action>` block instructs step() to "guard EVERY probe with `if (grid && !isFeetBboxWalkable(...))`" and says "if `grid` is undefined (server-side construction), fall through to 'no edge-block' (all-walkable) so server-side behavior is unchanged from pre-06.1." But pre-06.1 server behaviour used `resolveCollision` + `clampToRoom`, not "no edge-block." Reading "fall through to no edge-block" literally would have **dropped server-side collision_polys enforcement entirely**, breaking REQ-SRV-02 and CLAUDE.md hard rule #1 (server-authoritative).
- **Fix:** Interpreted "server-side behavior is unchanged from pre-06.1" as authoritative. Implemented a two-branch step(): `grid !== undefined` → new per-axis loop; `grid === undefined` → existing `resolveCollision` + `clampToRoom`. This preserves the threat-model invariant T-06.1.02-01 (server keeps using collision_polys; client-fabricated grid affects only client prediction).
- **Files affected:** `packages/game-logic/src/step.ts` (collision phase block)
- **Commit:** `0c40432`

### Out-of-Scope Findings (Deferred)

- **`apps/client/src/scenes/GameScene.ts:653`** still has `* 3` hardcoded with a `// WALK_SPEED_PX_PER_TICK from constants` comment. This is a Wave 3 GameScene wire-up task (per the plan tree) — out of scope for Plan 06.1-02. The hardcoded `3` doesn't reference the renamed constant by name, so client typecheck still passes. Logged for the GameScene/InputDispatcher plan.
- **`pnpm trace:check` reports ~50 pre-existing `undeclared_id` and `parse_error` findings** in older `.planning/phases/04-*`, `05-*`, `06-*` plans (placeholder tags like `REQ-CLI-XX`, `REQ-DEP-NN`, `REQ-SRV-XX`). None of these are in files touched by this plan. REQ-CLI-04 itself reports `[OK]` (+doc +impl +unit +int). Not in scope; logged for a future cleanup pass.

## Acceptance Criteria

All Plan 06.1-02 acceptance criteria met:

- 4 new test files exist with the correct filenames (under `test/` rather than `tests/` — see Deviation #1).
- Each new file imports from `../src/constants.js`, `../src/types.js`, or `../src/step.js` as appropriate.
- Each new file contains `// [unit->REQ-CLI-04]` literal tag.
- Wave 0 tests went RED at Task 1 (7 failures captured in 4 files; commit message records this) and GREEN at Task 2.
- `pnpm --filter @rebno/game-logic test`: 50/50 pass.
- `pnpm --filter @rebno/game-logic build`: clean.
- `pnpm --filter @rebno/server typecheck`: clean.
- `node tools/scripts/lint-game-logic-purity.mjs`: OK.
- `grep -v '^#' packages/game-logic/src/types.ts | grep -c "walkable_grid?:"` = 1.
- non-optional form absent (count = 0).
- `grep -v '^#' packages/game-logic/src/step.ts | grep -c "if (grid"` = 1 (the `if (grid !== undefined)` guard).
- `grep -v '^#' packages/game-logic/src/constants.ts | grep -c 'RUN_SPEED_PX_PER_TICK'` = 5 (definition + 1 doc reference + 3 doc cross-refs).
- `grep -v '^#' packages/game-logic/src/constants.ts | grep -c 'WALK_SPEED_PX_PER_TICK'` = 0.
- `grep -v '^#' packages/game-logic/src/step.ts | grep -c 'NAVI_MASK'` = 10 (probe sites + import).
- `grep -v '^#' packages/game-logic/src/collision.ts | grep -c 'isFeetBboxWalkable'` = 2 (export + doc).

## Self-Check: PASSED

Files verified to exist:
- `packages/game-logic/test/run-speed.test.ts` — FOUND
- `packages/game-logic/test/navi-mask-bbox.test.ts` — FOUND
- `packages/game-logic/test/wall-slide.test.ts` — FOUND
- `packages/game-logic/test/walkable-edge.test.ts` — FOUND
- `packages/game-logic/src/constants.ts` — modified (RUN_SPEED + NAVI_MASK + FRAMES_PER_TICK_AT_RUN)
- `packages/game-logic/src/types.ts` — modified (WalkableGrid + RoomLayout.walkable_grid?)
- `packages/game-logic/src/collision.ts` — modified (isFeetBboxWalkable)
- `packages/game-logic/src/step.ts` — modified (per-axis sub-pixel loop)

Commits verified:
- `88d6fdb` — Task 1 RED tests — FOUND
- `0c40432` — Task 2 GREEN implementation — FOUND
