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

[doc->REQ-CLI-04]

# 06-13 Movement Derivation — BNO Client 5-8 Numeric Constants

This document records the complete derivation trail mapping extracted GML source lines to
movement constants in `packages/game-logic/src/constants.ts`. Every constant carries a
SOURCE comment in the TS file citing the file + line recorded here.

Per CLAUDE.md hard rule #4/#5/#6: extract → document → rewrite. The Phase 1 extraction
produced the GML files; this plan documents the derivation; `constants.ts` + `step.ts`
consume the documented values.

**Canonical mover object**: `extracted/client-5-8/objects/0000-server/` (NOT `0042-player` or
any `navi*` script — see CONTEXT addendum line 387).

---

## §Source GML

| File | Role in derivation |
|------|--------------------|
| `extracted/client-5-8/objects/0000-server/events/Create.gml` | Object initialization — `global.curspeed` (walk speed), `tcollide`, `image_speed` |
| `extracted/client-5-8/objects/0000-server/events/Step.gml` | Per-tick movement application — fspeed direction model, pixel-by-pixel collision loop, diagonal via `lengthdir_x/y`, `round()` before loop, movement tile acceleration |
| `extracted/client-5-8/objects/0000-server/events/Step-1.gml` | Begin-step: stores `server.lastx`, `server.lasty` — coordinate bookkeeping, no physics |
| `extracted/client-5-8/objects/0000-server/events/Step-2.gml` | End-step: stores `server.slx`, `server.sly` — coordinate bookkeeping, no physics |
| `extracted/client-5-8/objects/0000-server/events/Keyboard-37.gml` | Left-arrow held: sets `left=1`, `direction=180`, `fspeed=global.curspeed`; handles diagonal combos |
| `extracted/client-5-8/objects/0000-server/events/Keyboard-38.gml` | Up-arrow held: sets `up=1`, `direction=90`, `fspeed=global.curspeed`; handles diagonal combos |
| `extracted/client-5-8/objects/0000-server/events/Keyboard-39.gml` | Right-arrow held: sets `right=1`, `direction=0`, `fspeed=global.curspeed`; handles diagonal combos |
| `extracted/client-5-8/objects/0000-server/events/Keyboard-40.gml` | Down-arrow held: sets `down=1`, `direction=270`, `fspeed=global.curspeed`; handles diagonal combos |
| `extracted/client-5-8/objects/0000-server/events/KeyRelease-37.gml` | Left released: `left=0`; fspeed zeroed only if no other directional key held |
| `extracted/client-5-8/objects/0000-server/events/KeyRelease-38.gml` | Up released: `up=0`; same instant-stop logic |
| `extracted/client-5-8/objects/0000-server/events/KeyRelease-39.gml` | Right released: `right=0`; same instant-stop logic |
| `extracted/client-5-8/objects/0000-server/events/KeyRelease-40.gml` | Down released: `down=0`; same instant-stop logic |
| `extracted/client-5-8/scripts/0285-player_update.gml` | Recreates other players in the room — irrelevant to motion constants |
| `extracted/client-5-8/scripts/0347-iv_keyactions.gml` | Inventory key-item action strings — irrelevant to motion constants |
| `extracted/client-5-8/scripts/0355-pcode_mover.gml` | MrProg NPC mover script — uses `srspeed/slspeed/suspeed/sdspeed` accumulator pattern; NOT the player object mover |
| `extracted/client-5-8/rooms/0058-BNCentral/meta.json` | `"speed": 30` — room_speed / tick rate |

---

## §Derivation table (numeric constants)

| Constant | Derived value | Source (file:line) | Notes |
|----------|---------------|--------------------|-------|
| `TICK_RATE_HZ` | `30` | `extracted/client-5-8/rooms/0058-BNCentral/meta.json:9` — `"speed": 30` | GM5 room_speed = steps per second. BNO BNCentral uses 30. Note: the REBNO server currently runs at 20 Hz (TICK_MS=50); the BNO-faithful value is 30 Hz. See §Decisions for the discrepancy note. |
| `WALK_SPEED_PX_PER_TICK` | `3` | `extracted/client-5-8/objects/0000-server/events/Create.gml:23` — `if(global.curspeed == 0) global.curspeed = 3;` | `global.curspeed` is the canonical walk speed. `fspeed` is set to `global.curspeed` on every held-key event. The "normal" speed is 3 px/tick. (JokerShell item sets it to 7, but that is an item effect, not the baseline.) |
| `ACCEL_PX_PER_TICK_SQ` | `0` | Absent — negative evidence: Keyboard-37.gml:17 sets `fspeed = global.curspeed` directly (no accumulation). No ramp-up pattern observed. | Speed is set instantly to `global.curspeed` on key-press. No acceleration. |
| `FRICTION_PX_PER_TICK_SQ` | `0` | Absent — negative evidence: KeyRelease-37.gml:2 sets `left=0`; on all-keys-released: `fspeed=0` (line 11). Instant stop, no decay. | `fspeed` goes from `global.curspeed` to 0 in one frame on key release. No multiplicative friction or deceleration observed. |
| `KEY_BUFFER_WINDOW_MS` | `0` | Absent — negative evidence: Keyboard-37..40 events are GM5 Keyboard-held events (fire continuously while key is down). No timestamp comparison, no `keyboard_check_pressed`, no buffer array. See §Keyboard repeat. | Continuous held-key; no buffering window. |
| `SUB_PIXEL_ACCUMULATOR_MODE` | `'snap-round'` | `extracted/client-5-8/objects/0000-server/events/Step.gml:224` — `move = round(lengthdir_x(fspeed,direction));` and line 234 — `move = round(lengthdir_y(fspeed,direction));` | The `round()` is applied to the movement vector before the pixel-by-pixel loop. Final `x += sign(move)` per loop iteration = integer pixels. See §Sub-pixel accumulator. |
| `DIAGONAL_NORMALIZATION_MODE` | `'normalize'` | `extracted/client-5-8/objects/0000-server/events/Step.gml:224` — `move = round(lengthdir_x(fspeed,direction))` where `direction` is set to `DIR_UL/UR/DL/DR` in Keyboard events (diagonal angles e.g. 135°). `lengthdir_x(3,135) = 3*cos(135°) ≈ -2.12`, `round(-2.12) = -2`. See §Diagonal normalization. | The direction-vector decomposition via trigonometry inherently normalizes diagonal speed. `|v_diag| = round(3*cos(45°)), round(3*sin(45°))` ≈ `(2,2)` ≈ 2.83 px/tick vs 3 px/tick cardinal — not a perfect 1/√2 but functionally similar. |
| `COLLISION_STEP_PX` | `1` | `extracted/client-5-8/objects/0000-server/events/Step.gml:226-233` — pixel-by-pixel loop `for(i=0; i<abs(move); i+=1) { ... x += 1; }`. See §Collision-step granularity. | 1 px per iteration collision check. |
| `STARTING_DIRECTION_DEGREES` | `270` | Convention. BNO convention per Keyboard-40.gml:8: `direction=270` is DOWN. No explicit spawn direction in Create.gml (the `//dir = 2` comment is commented out). Best-guess: face DOWN on spawn as the most common navi art default. |

---

## §Diagonal normalization (D-32)

**Mode: `'normalize'`** (via trigonometric decomposition).

In BNO, the player has a single scalar `fspeed = global.curspeed` (3 px/tick) and a single
`direction` angle. Diagonal input sets `direction` to a diagonal constant (e.g. `DIR_UL = 135°`).
Movement is applied via:

```gml
// Step.gml:224
move = round(lengthdir_x(fspeed, direction));
// ... loop: x += sign(move) for abs(move) iterations
move = round(lengthdir_y(fspeed, direction));
// ... loop: y += sign(move) for abs(move) iterations
```

`lengthdir_x(3, 135°) = 3 * cos(135°) ≈ -2.121 → round = -2`
`lengthdir_y(3, 135°) = 3 * sin(135°) ≈ 2.121 → round = 2`

So the actual diagonal pixel displacement is `(-2, 2)` ≈ 2.83 px/tick vector magnitude,
versus 3 px/tick cardinal. This IS a form of normalization (not `accept-1.414×`) — it arises
naturally from the trigonometric decomposition. BNO does **not** give a 1.414× diagonal advantage.

This design appears in the Keyboard events: opposite keys simultaneously set `fspeed = 0`
(e.g. Keyboard-37.gml:6-10: `if(right) { direction=180; fspeed=0; }`), so pure axis-priority
applies for **opposite** keys (left+right = stop), but **diagonal** keys (left+up, etc.) set a
diagonal direction angle and keep `fspeed = global.curspeed`.

**Final constant value: `DIAGONAL_NORMALIZATION_MODE = 'normalize'`**

---

## §Collision-step granularity (D-32)

**Mode: 1 pixel per step** (`COLLISION_STEP_PX = 1`).

Step.gml lines 224-243 apply movement in two separate axis passes, each as a pixel-by-pixel loop:

```gml
// Step.gml:224-233
move = round(lengthdir_x(fspeed, direction));
i = 0;
for(i = 0; i < abs(move); i += 1)
{
  if(sign(move) == -1 && ((tbordered && (!abscheckheight2(tile1,mplatparent,x+9-1,y+39,8) ...)) || ...))
    x -= 1;
  else if(sign(move) == 1 && ((tbordered && (!abscheckheight2(tile1,mplatparent,x+26+1,y+39,8) ...)) || ...))
    x += 1;
  else break;
}
```

Each iteration advances `x` by exactly 1 pixel and checks collision via `abscheckheight2`. This is
the classic GameMaker 5 pixel-step collision pattern — equivalent to `move_contact_solid` behavior
but implemented manually with axis separation (x before y). On contact, the loop `break`s, leaving
the player at the last clear position.

The ice-tile section (lines 108-142) also uses the same 1-pixel loop pattern:
```gml
for(i = 0; i < abs(ni); i += 1)
{
  if(collision_rectangle(...)) {ispeed = 0; break;}
  else x += sign(ni);
}
```

**Final constant value: `COLLISION_STEP_PX = 1`**

---

## §Keyboard repeat / input-buffering (D-32)

**Mode: continuous held-key, no buffering** (`KEY_BUFFER_WINDOW_MS = 0`).

GM5 "Keyboard" events (numbered 37–40 corresponding to Windows virtual key codes for arrow keys)
fire **every game step** while the key is held. This is the GM5 equivalent of `keyboard_check(vk_*)` —
continuous, not one-shot (`keyboard_check_pressed`).

Evidence from Keyboard-37.gml (left arrow):
```gml
// Keyboard-37.gml:1-55 — fires every step while left is held
if(mobilecheck()) {
  left = 1;
  if(right) { direction=180; fspeed=0; ... }
  else { direction=180; fspeed=global.curspeed; ... }
}
```

No `last_press_time`, no timestamp array, no frame counter for buffering. The handler simply
sets `left=1`, `direction`, and `fspeed` unconditionally each frame.

KeyRelease events clear the direction flag (`left=0`) and stop motion when all keys released:
```gml
// KeyRelease-37.gml:2-14
left = 0;
if(mobilecheck()) {
  if(!up && !down && !right) { direction=180; fspeed=0; ... }
}
```

Note: the `mobilecheck()` guard on fspeed=0 in the KeyRelease handler means fspeed is ONLY
zeroed on key-release if `mobilecheck()` is true. On desktop without "mobile mode", fspeed
persists from the last Keyboard event. However, since Keyboard events fire every step while
any key is held, the next step without a held key naturally stops the player because no
Keyboard event fires to set fspeed. In our TypeScript simulation, the model is simpler:
input axes are event-driven; when no input arrives, velocity is 0. This matches the observed behavior.

**Final constant value: `KEY_BUFFER_WINDOW_MS = 0`**

---

## §Sub-pixel accumulator (D-32)

**Mode: `'snap-round'`** applied to the movement vector before the pixel loop.

The key pattern in Step.gml:
```gml
// Step.gml:224
move = round(lengthdir_x(fspeed, direction));
i = 0;
for(i = 0; i < abs(move); i += 1)
{
  if(sign(move) == -1 && ... ) x -= 1;
  else if(sign(move) == 1 && ...) x += 1;
  else break;
}
// Step.gml:234
move = round(lengthdir_y(fspeed, direction));
// ... same loop for y
```

The fractional pixel distance (`lengthdir_x(fspeed, direction)`) is **rounded** to an integer
before the pixel-advance loop. This means fractional positions are snapped to the nearest integer
each tick. There is no fractional accumulator — `x` and `y` are always integer-valued after Step runs.

For cardinal movement at speed 3: `round(lengthdir_x(3, 0)) = round(3) = 3` → x advances by 3 px.
For diagonal at 135°: `round(lengthdir_x(3, 135)) = round(-2.121) = -2` → x advances by -2 px.

This is `'snap-round'` — the rounding happens to the per-tick displacement, not to the
accumulated position. In TS terms: `dx = Math.round(WALK_SPEED * cos(dirRad))`.

**Final constant value: `SUB_PIXEL_ACCUMULATOR_MODE = 'snap-round'`**

---

## §GML constructs that don't translate directly

| GML construct | BNO semantics | TypeScript equivalent |
|---------------|---------------|-----------------------|
| `global.curspeed` | Global mutable walk speed (normally 3, JokerShell item sets to 7) | `WALK_SPEED_PX_PER_TICK` constant; item effects are out of scope for MVP |
| `lengthdir_x(spd, dir)` | `spd * cos(dir_degrees * π/180)` — GM5 uses degrees, 0=right, 90=up (note: GM5 y-axis is inverted vs screen — y increases downward in screen space but GM5's lengthdir_y gives screen-y-negative for 90°) | `spd * Math.cos(dirRad)` / `spd * Math.sin(dirRad)` with correct axis mapping |
| GM5 direction convention | 0=right, 90=up, 180=left, 270=down (counter-clockwise from positive x-axis; GM5 screen y is flipped) | In TS/Phaser: y increases downward. For `axis_y = -1` (up key), move is negative y. For `axis_y = +1` (down key), move is positive y. The diagonal angles in TS use axis vectors not degree-angles. |
| `DIR_UL`, `DIR_UR`, `DIR_DL`, `DIR_DR` | Diagonal direction constants. E.g. `DIR_UL = 135°` (left+up). Exact values not in extracted GML — they are constants defined in `Create.gml` of a constants object or global. We infer: `DIR_UR = 45°`, `DIR_UL = 135°`, `DIR_DL = 225°`, `DIR_DR = 315°`. | 8-direction normalized vectors per DIAGONAL_NORMALIZATION_MODE |
| `abscheckheight2(tile1, mplatparent, x, y, w)` | Checks a column of pixels for collision with tile/platform objects — custom script | In TS: collision against `room_layout.collision_polys` AABB. The 1-px loop pattern maps to `COLLISION_STEP_PX = 1`. |
| `mobilecheck()` | Returns 1 if mobile/touch input mode active; on desktop this may or may not be true. Governs direction-sprite update on KeyRelease. Not relevant to physics constants. | Not ported — our input model is axis-based |
| Movement tile system (`srspeed`, `slspeed`, etc.) | Conveyor-belt tiles that push the player. Implemented via separate speed variables accumulated per-tile-touch in Step.gml. | Out of scope for MVP movement constants. The conveyor system is separate from player-input motion. |

---

## §Decisions made + uncertainties

1. **WALK_SPEED = 3**: Derived from Create.gml line 23. The value is unambiguous. JokerShell sets it to 7 — this is an item effect, documented but not reflected in the base constant.

2. **TICK_RATE_HZ = 30 (BNO) vs 20 Hz (REBNO server)**: BNCentral meta.json confirms `"speed": 30`. The REBNO server currently runs at 20 Hz (TICK_MS=50) per Phase 4 D-22. The `TICK_RATE_HZ` export in constants.ts documents BNO's original 30 Hz. The TS step() receives dt_ms and works correctly at either rate — the positional advance per call is `round(WALK_SPEED * cos(dir)) * dt_ms / BNO_TICK_MS`. However, at 20 Hz the server calls step() with `dt_ms=50` and the constants assume `px/tick at 30 Hz`. This is a **known discrepancy** — the constants module documents BNO's 30 Hz; step() adapts by computing px/tick from the passed `dt_ms`. The full reconciliation (upgrading server to 30 Hz or adjusting step() velocity scaling) is deferred to 06-17 UAT (cli-08 milestone).

3. **DIAGONAL_NORMALIZATION_MODE = 'normalize'**: Derived from the trigonometric decomposition in Step.gml. The `DIR_*` diagonal angle constants are not visible in the extracted files but the pattern `lengthdir_x(fspeed, diagonal_angle)` + `round()` inherently normalizes. Confidence: high.

4. **SUB_PIXEL_ACCUMULATOR_MODE = 'snap-round'**: The `round()` in Step.gml lines 224+234 is unambiguous. GM5's built-in `round()` rounds half-up. TypeScript's `Math.round()` also rounds half-up (ties go to +∞). Behavior is identical.

5. **ACCEL = 0, FRICTION = 0**: No ramp-up or decay observed. Speed is set directly on key-press and zeroed on release. Confidence: high.

6. **KEY_BUFFER_WINDOW_MS = 0**: No buffering pattern detected. Keyboard events are continuous-held. Confidence: high.

7. **STARTING_DIRECTION_DEGREES = 270**: The commented-out `//dir = 2` in Create.gml suggests a direction variable was planned but not implemented. 270° (DOWN) is the conventional BNO default — the NaviStandD sprite faces down and is the default idle sprite. Best-guess assumption.

8. **Opposite-key cancellation**: When left+right are both held, `fspeed=0` (Keyboard-37.gml:6-10: `if(right) { fspeed=0; }`). This "stops on opposite keys" behavior differs from the existing step.ts which uses separate `axis_x`/`axis_y` and lets them cancel via `-1+1=0`. The net result is the same (no movement), so no constant is needed.

---

## §Open questions for 06-16

The following are out of scope for this plan (numeric constants) but are captured for the
06-16 sprite-state machine plan:

- Q1: What are the exact values of `DIR_UL`, `DIR_UR`, `DIR_DL`, `DIR_DR` constants? (Needed to confirm diagonal angles are ±45° from cardinal.)
- Q2: How does the 8-direction sprite selection logic work exactly? (Step.gml lines 87-94 show the `slidedir` range checks for movement-tile sliding, but the running-sprite selection for regular movement is in the Keyboard events.)
- Q3: What is the idle-to-run sprite transition timing? (image_speed=1 from Create.gml.)
- Q4: What is the `Hexport` / TeleIn / TeleOut / JoinIn / JoinOut animation sequence? (These override normal movement in Step.gml.)
