# Phase 06.7: Network model — client-trust + server illegal-position fall trigger — Research

**Researched:** 2026-05-17
**Domain:** Real-time multiplayer netcode trust model (client-authoritative position with absolute-position state replication)
**Confidence:** HIGH for stack/architecture; MEDIUM for downsampling and broadcast-cadence recommendations (validated against Minecraft analog, but specific Colyseus 0.17 fan-out telemetry will only come from staging UAT)

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

**Scope**
- **D-01:** Phase A only — flip self-player to client-trust + reconciler no-op. Phase B (fall reset + legality check) → backlog (candidate Phase 06.8).
- **D-02:** Motivating bugs explicitly in scope: (a) ~10 px diagonal-stop drift on staging; (b) dropped-packet hitching tolerance. Current model assumes perfect network — fix that.
- **D-03:** Inter-player collision arbitration is **out of scope** (not currently observed; theoretical risk only).
- **D-04:** D-57b and D-58b anti-revert regression tests are **explicitly retired** as an acceptance criterion of this phase. They guard reconciler behavior that is being removed for the self-player. Replaced by a regression test asserting **the self-player sprite position is never written by `ReconcileEngine.onServerSnapshot`** (positive guard against future revert).

**Wire shape + cadence**
- **D-05:** Position-update wire shape (client → server, every tick): `{x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms}`. **Absolute position** (not deltas) — single dropped packet recovered cleanly by the next.
- **D-06:** Cadence = **30 Hz steady**, regardless of motion. Idle player sends `{x, y, vx=0, vy=0, facing, anim_state=STAND_*, ...}` 30 times/sec. Matches Minecraft's `PlayerPosition` cadence model and keeps the server's authoritative store always-fresh for remote-player broadcasts.
- **D-07:** **Int16 quantization** on `x, y` — client snap-rounds to integer pixels before sending. Matches the BNO snap-round model already in `packages/game-logic/src/step.ts:117-122`. Removes float-comparison footguns. Halves packet size vs float.
- **D-08:** `anim_state` is a 1-byte enum carried in **every position_update** (NOT derivable from kinematics — moving platforms, conveyor platforms, hexport all produce velocity-without-running states). Enum covers 8 directions × {stand, run} + reserved slots. The existing `SpriteStateMachine` in `packages/game-logic/src/sprite-state-machine.ts` continues to drive client-side animation selection; its current output is what gets shipped.
- **D-09:** Special non-derivable sprite states (HexportIn/Out, TeleIn/Out, ncol overlays, jokershell, watching) ship via a separate **`set_sprite_override`** event-only intent. Mirrors `set_facing` precedent shipped in 06.4 round-3 (D-58c).
- **D-10:** `facing` stays in position_update (NOT folded into `anim_state`). Facing = "where the player is pointing"; `anim_state` = "what pose to render right now". Different semantics, both needed.

**Anti-cheat**
- **D-11:** **Zero server-side anti-cheat in 06.7.** Server trusts client-reported position fully. 50 CCU, pre-launch private staging, trusted operators — speed-cap, walkable-grid sanity, and fall reset all deferred to 06.8.

**Migration + doctrine**
- **D-12:** **Hard cut on staging.** No feature flag. Rollback = `/gsd-undo` on phase commits.
- **D-13:** **CLAUDE.md Hard Rule 1 — narrow movement carve-out.** One paragraph: "Movement (position, velocity, facing, anim_state) is CLIENT-AUTHORITATIVE — server stores client-reported state. Non-movement state (chat origin, inventory, room transitions, persistence) remains server-authoritative." Drop scores/combat from the example list — REBNO has none.
- **D-14:** **Rollback strategy = `/gsd-undo` phase commits.** No dead-code branches.

**Research directive**
- **D-15:** Researcher must produce a comparison table scoring Minecraft Java Edition vs. Quake/Tribes lineage vs. current REBNO model against constraints: 50 CCU, predictable-movement, dropped-packet tolerance, single Fly.io machine, Colyseus 0.17.
- **D-16:** Dropped-packet tolerance is a first-class research dimension.

### Claude's Discretion
- Exact `anim_state` enum members + numeric byte values — pick during planning based on existing `SpriteStateMachine` states. Add a binary-format note to `packages/protocol`.
- Whether to keep `seq`/`last_input_seq` echo on the position_update path or drop it.
- Server-side `step()` retention — whether to keep it running on a separate tick for self-player platform-effects (conveyor application) vs. trusting client's reported velocity.
- Whether to send remote-player broadcast diffs at full 30 Hz or downsampled (server fan-out optimization).

### Deferred Ideas (OUT OF SCOPE for 06.7)
- **Phase B — server illegal-position detection + fall reset** (→ candidate 06.8).
- **Inter-player collision arbitration** (defer until observed).
- **Speed-cap server-side validation** (→ 06.8).
- **Walkable-grid sanity check server-side** (→ 06.8).
- **CLAUDE.md trust-doctrine table restructure** (Rule 1 narrow carve-out only for now).
- **Server-side `step()` future role** (planner + researcher input — addressed below).
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Title (from `traceable-reqs.toml`) | Research Support |
|----|-----------------------------------|------------------|
| REQ-SRV-03 | `apps/server` runs Node 22 + Colyseus 0.17 with one Room class for the MVP slice | Server-side: new `position_update` + `set_sprite_override` handlers added to `onMessageHandlers.ts`; tick-loop in `RebnoRoom.ts` modified to skip `step()` for self-player movement (covered in "Architecture Patterns" below) |
| REQ-SRV-14 | Deterministic moving-platform sync via game-logic step; clients interpolate, never extrapolate | Platforms remain server-authoritative — they are world state, NOT player movement. The carve-out is narrow: only player x/y/vx/vy/facing/anim_state flip to client-trust. Platforms stay derived from `step()` (covered in "Pitfalls" + "Architecture Patterns") |
| REQ-CLI-04 | Client-side prediction + entity interpolation + server reconciliation | Reconciler becomes no-op for self-player; entity interpolation/extrapolation for REMOTE players UNCHANGED (server state IS authoritative for those). The req remains satisfied — its implementation morphs (covered in "Don't Hand-Roll" + "Architecture Patterns") |
| REQ-CLI-08 | MVP GATE: two players join, move, and chat over deployed server | Two-player movement smoothness regression — the existing 06.4 e2e integration tests against staging confirm REQ-CLI-08 still holds post-flip. Specifically: `apps/client/src/__test__/colyseus-client.test.ts` + server `event-driven-input.integ.test.ts` (covered in "Validation Architecture") |

**Required stages** for each: `doc` + `impl` mandatory for all; `unit` for REQ-SRV-03/SRV-14/CLI-04; `int` for REQ-SRV-03/SRV-14/CLI-08.
</phase_requirements>

## Summary

The phase flips player-movement trust from hybrid client-prediction + server-authoritative-reconciler to **client-authoritative absolute-position streaming**, matching Minecraft Java Edition's `Set Player Position` model. Client streams `{x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms}` at 30 Hz; server stores the most-recent payload and broadcasts to remote players. The reconciler becomes a no-op for the self-player; the remote-player extrapolation path is preserved because the server-stored value IS authoritative for those peers.

Three findings drive the recommendation:

1. **Minecraft's model is the industry-standard precedent.** Minecraft sends absolute-position `Set Player Position` packets at 20 Hz, server stores them as authoritative state, and uses force-teleport `Synchronize Player Position` only as a correction backstop (the latter being REBNO's deferred 06.8). The "trusted store + corrective override" pattern is exactly Phase A + Phase B.
2. **30 Hz absolute position over TCP/Colyseus is robust to single-packet loss by construction.** Each packet is fully self-describing; missing tick N's update just means tick N+1's update lands one frame later. No state divergence is possible because there's no delta to lose context for. Server-side extrapolation between packets is unnecessary at 30 Hz with Colyseus's TCP ordering guarantee.
3. **Custom server-side broadcast fan-out optimization (downsampling) is premature.** At 50 CCU × 30 Hz × ~20 B payload, broadcast bandwidth is ~37 kbps down per client — well within Fly.io shared-cpu-1x. Ship at full 30 Hz; revisit if profiling shows fan-out cost.

**Primary recommendation:** Implement the wire shape exactly as D-05 specifies, ship at 30 Hz steady, keep `seq` for ordering/idempotent-retries (existing `cInputSchema` precedent), drop the server-side `step()` call for self-player position entirely (it stops being authoritative for x/y), retain `step()` for platforms (REQ-SRV-14 unchanged), broadcast at full 30 Hz patch rate, use a 1-byte packed `anim_state` enum with bits 0-2 = octant + bit 3 = running + bits 4-7 reserved.

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Self-player position (x, y) | Browser/Client | API/Backend (storage only) | D-05 — client owns its position; server is a write-through store |
| Self-player velocity (vx, vy) | Browser/Client | API/Backend (storage only) | Derived from intent on the client; server stores for broadcast only |
| Self-player anim_state | Browser/Client | API/Backend (storage only) | D-08 — non-derivable from kinematics; client SpriteStateMachine output is canonical |
| Self-player facing | Browser/Client | API/Backend (storage only) | D-10 — D-58c set_facing precedent extended to every-tick streaming |
| Remote-player rendering | Browser/Client | — | Client consumes server-broadcast state and runs existing extrapolation cap (250 ms per ADR 0007) |
| Remote-player state-of-record | API/Backend | — | Server is the canonical store of every player's last-reported {x, y, vx, vy, facing, anim_state}; clients receive via Colyseus state-diff |
| Platform position | API/Backend | — | REQ-SRV-14 unchanged: deterministic, derived from `step()` on the server, NOT carved out |
| Chat origin / sender identity | API/Backend | — | Hard Rule 1 unchanged — server-tagged from `auth.account_id` per `onMessageHandlers.ts:182-188` |
| Inventory / persistence | API/Backend | Database/Storage | Hard Rule 1 unchanged |
| Room transitions | API/Backend | — | Hard Rule 1 unchanged — `room_join` handler is the authority |
| Spawn position | API/Backend | — | `RebnoRoom.findHomePortal()` assigns; client adopts on join. Only the FIRST authoritative position; after spawn, client owns it |
| Anti-cheat enforcement | (none in 06.7) | — | Deferred to 06.8 |

## Standard Stack

### Core (already pinned — no changes)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `colyseus` | 0.17.10 | Authoritative-server framework, room/state-diff/WS transport | `[VERIFIED: apps/server/package.json:32]` — already in use; no version change |
| `@colyseus/sdk` | 0.17.42 | Client-side Colyseus | `[VERIFIED: apps/client/package.json:21]` — already in use |
| `@colyseus/schema` | 4.0.23 | Schema-delta encoder for the `PlayerState`/`RoomState` MapSchemas | `[VERIFIED: apps/server/package.json:21]` — already in use |
| `phaser` | 3.90.0 | Client-side renderer | `[VERIFIED: apps/client/package.json:25]` — already in use |
| `zod` | 3.23.x | Wire validator at the onMessage boundary | `[VERIFIED: apps/server/package.json:45]` — already in use; new `cPositionUpdateSchema` adds here |
| `msgpackr` | 1.11.10 | S2C event codec | `[VERIFIED: packages/protocol/src/events.ts:7]` — already in use; no new S2C event needed |

### Supporting — no new libraries

The phase introduces **zero new dependencies**. Everything is a refactor of how the existing libraries are wired.

**Version verification:** Pins above are the **current** versions in `package.json` and were verified against the files in this session. No dependency upgrades are part of the phase.

### Alternatives Considered (and rejected per CONTEXT)
| Instead of | Could Use | Why Rejected |
|------------|-----------|--------------|
| Colyseus state-diff (PlayerState mutation) for broadcast | A new msgpackr s2c `position_broadcast` event | The Colyseus state-diff path already broadcasts `PlayerState` mutations at `setPatchRate(TICK_MS)`. Re-using it costs zero new wire shapes. Adding a parallel msgpackr broadcast would create two sources of truth and split the test surface. |
| Float64 x/y on the wire | Float32 or fixed-point Q16.16 | D-07 locks int16. Float64 is 4× larger than int16; pixel-quantized positions don't need sub-integer precision because step.ts already snap-rounds before write. |
| Binary packed position_update | msgpackr-encoded object | The existing `cInputSchema` lives as a JSON-shaped object (Colyseus message channel does its own packing). Binary packing would gain ~10-20 B per frame at the cost of a parallel codec test surface. Defer unless 50 CCU CPU profile shows the JSON path as a hot spot. |

## Architecture Patterns

### System Architecture Diagram

```
Self-player flow (NEW in 06.7):
  Keyboard input  →  InputDispatcher (client)
                       ↓
                     PredictionEngine.predictTick(layout)   [LOCAL FEEL — kept]
                       ↓ (writes localState.x/y/vx/vy)
                     Phaser sprite render               [INSTANT RESPONSE]
                       ↓
                     PositionDispatcher.send(position_update)  [NEW: every 30 Hz tick]
                       │
                       │     wire: {x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms}
                       │     binary: int16+int16+int16+int16+u8+u8+u32+u32 = 18 B payload
                       ↓
              [ Colyseus WS / TCP — reliable, ordered ]
                       ↓
                     RebnoRoom.onMessage('position_update')
                       ↓
                     cPositionUpdateSchema.safeParse()  [zod strict — D-04]
                       ↓ (pass) write into PlayerState.{x, y, vx, vy, facing, anim_state}
                       │
                       │
              Tick loop:
              RebnoRoom.setSimulationInterval(TICK_MS=50)
                       ↓
                     step() runs for PLATFORMS ONLY (REQ-SRV-14)
                       │   no longer reads/writes self-player position
                       ↓
                     Colyseus setPatchRate(TICK_MS=50)
                       ↓
              [ broadcast PlayerState diffs to all clients ]
                       ↓
              Remote-player flow (UNCHANGED):
                     PlayerState.onChange (per-field)
                       ↓
                     PlayerRenderer.setRemotePosition (extrapolation cap 250 ms)
                       ↓
                     deriveFrameWithAuthoritativeFacing (06.4 D-58c — kept)
                       ↓
                     remote sprite render

  Self-reconciler path (REMOVED):
                     ReconcileEngine.onServerSnapshot
                       ↓ for self-player: NO-OP (early return when snap.account_id === local.account_id)
                       ↓ for remote-player: NOT INVOKED (remote path uses PlayerRenderer directly, NOT ReconcileEngine)
                       │   → Effectively dead code for self; can be removed entirely once 06.8 confirms no future need
```

Key data-flow change vs. the current model:
- **Before:** keypress → c2s.input (axes) → server.step() → state diff → reconciler (self & remote)
- **After:** keypress → c2s.position_update (absolute x/y/vx/vy/facing/anim_state from client predictor) → state diff (passthrough) → renderer (remote only)

### Recommended Project Structure

No structural changes. All file paths exist; this is a refactor:

```
packages/protocol/src/
├── intents.ts              # ADD: cPositionUpdateSchema, cSetSpriteOverrideSchema, AnimState enum
├── events.ts               # unchanged (force_reset event reserved for 06.8 fall trigger)
└── version.ts              # BUMP PROTOCOL_VERSION 3 → 4 (wire shape change)

apps/server/src/
├── onMessageHandlers.ts    # ADD: position_update handler + set_sprite_override handler
└── RebnoRoom.ts            # MODIFY: tick loop — step() no longer writes self-player x/y/vx/vy/facing

apps/client/src/
├── prediction/
│   ├── predictor.ts        # KEEP — local-feel prediction stays for input responsiveness
│   ├── reconciler.ts       # MODIFY: no-op for self-player; remote unchanged. Remove globalThis.__rebno.lastReconcile* writes for self
│   └── input-dispatcher.ts # ADD: per-tick PositionDispatcher branch alongside the keydown/keyup InputDispatcher
└── net/
    └── colyseus-client.ts  # MODIFY: bindHandlers wires position_update dispatch on local player tick

CLAUDE.md                   # MODIFY: Hard Rule 1 narrow carve-out per D-13
```

### Pattern 1: Absolute-position client-trust streaming (Minecraft-canonical)

**What:** Client computes its position locally and ships absolute coordinates to the server every tick. Server stores the latest payload as authoritative state and broadcasts to remote peers. Server does not compute, validate, or correct positions.

**When to use:** Pre-launch private games where the trust boundary can include the player; games where predictable feel matters more than anti-cheat; small player counts.

**Example (server side):**
```typescript
// Source: pattern derived from packages/protocol/src/intents.ts:45-59 + apps/server/src/onMessageHandlers.ts:104-136
// [CITED: Minecraft "Set Player Position" packet — minecraft.wiki/w/Java_Edition_protocol/Packets]
export const cPositionUpdateSchema = z.object({
  type: z.literal('position_update'),
  x: z.number().int().min(-32768).max(32767),
  y: z.number().int().min(-32768).max(32767),
  vx: z.number().int().min(-32768).max(32767),
  vy: z.number().int().min(-32768).max(32767),
  facing: z.enum(['D', 'DR', 'R', 'UR', 'U', 'UL', 'L', 'DL']),
  anim_state: z.number().int().min(0).max(255),  // packed enum (see below)
  seq: z.number().int().min(0),
  monotonic_at_ms: z.number().nonnegative(),
}).strict();

ctx.room.onMessage('position_update', (client, raw) => {
  const auth = ctx.getAuth(client.sessionId);
  if (!auth) return;
  if (!rateLimitOrDrop(ctx, client, 'position_update', auth.account_id)) return;
  const parsed = cPositionUpdateSchema.safeParse(raw);
  if (!parsed.success) return;
  const player = ctx.room.state.players.get(client.sessionId);
  if (!player) return;
  player.x = parsed.data.x;
  player.y = parsed.data.y;
  player.vx = parsed.data.vx;
  player.vy = parsed.data.vy;
  player.facing = parsed.data.facing;
  player.anim_state = parsed.data.anim_state;
  player.last_input_seq = parsed.data.seq;  // keep — see "Pattern 4"
});
```

### Pattern 2: Server-side step() retains platform authority (REQ-SRV-14 preserved)

**What:** The server still runs `step()` every tick for platforms (and any future world state). Players are skipped entirely in the player loop within `step()`.

**When to use:** Whenever the carve-out is partial — only one entity type (player position) flips to client-trust while everything else stays server-authoritative.

**Example:**
```typescript
// Source: derived from packages/game-logic/src/step.ts:65-235
// Modification: skip the player-input-application block; players carry the values written
// by the position_update handler. Platforms still advance per p.x += p.vx * dt_ms.

export function step(state, inputs, dt_ms) {
  // 1. Advance platforms (UNCHANGED — REQ-SRV-14)
  const newPlatforms = new Map();
  for (const [id, p] of state.platforms) {
    newPlatforms.set(id, { x: p.x + p.vx * dt_ms, y: p.y + p.vy * dt_ms, vx: p.vx, vy: p.vy, cycle_phase: p.cycle_phase + dt_ms });
  }
  // 2. Players: pass-through. The position_update handler already wrote authoritative values.
  //    REMOVED: input-axis decoding, diagonal normalization, per-axis sub-pixel collision.
  return { rev: state.rev + 1, rng_state: state.rng_state, players: state.players, platforms: newPlatforms, room_layout: state.room_layout };
}
```

**Important nuance:** the existing `step()` signature is preserved; client-side prediction in `predictor.ts` still calls `step()` with the client's local walkable_grid, so client-side wall-slide collision is preserved. The change is purely server-side: the server no longer derives player movement from input axes — but `step()` still exists and still gets called for platforms.

### Pattern 3: Self-reconciler no-op (positive guard)

**What:** When a server snapshot arrives for the local player, the reconciler returns early. The renderer keeps using the predictor's local state, never written by the snapshot path.

**When to use:** When the server is no longer the authoritative source for self-player position (the client IS).

**Example:**
```typescript
// Source: derived from apps/client/src/prediction/reconciler.ts:52
// Replace onServerSnapshot's self-player branch with an early return:

onServerSnapshot(snap: ServerSnapshot, isSelfPlayer: boolean): void {
  if (isSelfPlayer) {
    // [impl->REQ-CLI-04] D-04 acceptance criterion: self-player sprite is
    // NEVER written by the snapshot path. Self-player position comes from
    // PredictionEngine.predictTick + InputDispatcher only.
    return;
  }
  // Remote-player path unchanged — but note that remote rendering does NOT
  // currently go through ReconcileEngine at all (it goes through
  // PlayerRenderer.setRemotePosition). So this branch is effectively dead
  // code today; we retain the signature for symmetry and the (unused) call
  // site, but the entire ReconcileEngine class is a candidate for removal
  // after staging UAT confirms no remote-player consumer.
}
```

### Pattern 4: Keep `seq` echo on position_update (recommended)

**What:** Client increments a per-message sequence number on every position_update; server echoes the last-received seq in `PlayerState.last_input_seq`.

**When to use:** Whenever you want ordering/staleness detection and idempotent retries on reconnect.

**Why keep it:** Three concrete benefits, all cheap:
1. **Staleness detection:** Server can ignore an out-of-order position_update (`parsed.data.seq <= player.last_input_seq`). Defends against the rare TCP reorder-after-reconnect case.
2. **Reconnect coherency:** After SRV-06's 10 s grace reconnect, the client may have local seq=N; sending one position_update advances the server's last_input_seq cleanly.
3. **Existing infrastructure reuse:** `PlayerState.last_input_seq` is already a `@colyseus/schema` field; no protocol change to keep it.

**Cost:** 4 bytes per frame (u32). Trivial.

### Anti-Patterns to Avoid

- **Don't recompute position server-side after the position_update arrives.** The whole point of the flip is that the client owns it. Running `step()` then overwriting with the client value would just be wasted CPU AND introduce a race window where the rendered/broadcast value flickers between the two.
- **Don't extrapolate remote players on the server.** Colyseus state-diff fans out per `setPatchRate`; the existing client-side extrapolator (apps/client extrapolation cap 250 ms per ADR 0007) already handles brief gaps. Server extrapolation duplicates this logic on the wrong side of the boundary.
- **Don't drop the heartbeat semantics.** The existing 15 s `HEARTBEAT_INTERVAL_MS` in `input-dispatcher.ts` survives reconnect-grace. With position_update at 30 Hz, the heartbeat is implicit (any frame proves liveness) — but keep the explicit `c2s.heartbeat` handler for the case where the player is in a menu / chat-input-focused state and is NOT streaming position_updates (movement is paused per `pauseMovement()`).
- **Don't ship `anim_state` as a string.** D-08 calls for 1-byte enum; sending `"NaviRunDR_003"` per frame at 30 Hz is 14+ bytes of bandwidth waste vs. 1 byte for the pose enum + cycle phase derived client-side from velocity.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Position broadcasting | A new msgpackr `s2c.position_broadcast` event | The existing Colyseus `PlayerState` MapSchema delta channel | Already pinned at `setPatchRate(TICK_MS)`; already test-covered; adding a parallel msgpackr broadcast doubles the test surface and creates two sources of truth |
| Position ordering | A custom sequence-window state machine | Existing `last_input_seq` field on `PlayerState` + the seq from D-05 | The seq field exists, is wire-tested, and the comparator is one line: `if (seq <= player.last_input_seq) return;` |
| Self-player visual smoothing | A new "client-side smoothing buffer" between predict and render | Existing `PredictionEngine.predictTick` + Phaser sprite.setPosition each tick | Local feel is already instant; the smoothing buffer of yesteryear is an artifact of round-trip latency, which the client-trust model eliminates |
| Server-side anti-cheat envelope | A speed-cap, walkable-grid, illegal-position machine | NOTHING in 06.7 | D-11 LOCKS this. Backlog for 06.8 |
| Remote-player extrapolation | A new extrapolation algorithm | Existing `PlayerRenderer.setRemotePosition` + extrapolation cap per ADR 0007 + `axis_x_held`/`axis_y_held` on PlayerState | Already shipped; the new `vx`/`vy` fields from position_update populate the same `PlayerState.vx`/`vy` the renderer already consumes |
| Packet-loss handling | A custom retransmit / sequence-gap detector | Nothing — Colyseus runs over TCP/WS which is reliable + ordered | TCP guarantees delivery and order. The only "loss" possible is the connection drop, which is already handled by SRV-06's 10 s grace |

**Key insight:** Every component the phase needs already exists; the work is rewiring trust direction, not building new mechanisms.

## Comparison Table (D-15)

Scored against constraints: **50 CCU**, **predictable-movement** (core REBNO value), **dropped-packet tolerance**, **single Fly.io machine**, **Colyseus 0.17 transport**.

Scale: ✅ aligns / ⚠️ partial / ❌ misaligned.

| Constraint | Minecraft Java Edition | Quake / Tribes lineage | Current REBNO (pre-06.7) | REBNO post-06.7 (recommended) |
|------------|------------------------|------------------------|--------------------------|-------------------------------|
| **Trust model** | Client-trusted position; server stores; "Synchronize Player Position" corrective backstop `[CITED: minecraft.wiki/w/Java_Edition_protocol/Packets]` | Server-authoritative simulation; client predicts, server reconciles `[CITED: fabiensanglard.net/quake3/network.php]` | Server-authoritative simulation; client predicts, server-reconciler-snaps when >22 px divergence `[VERIFIED: reconciler.ts:21]` | Client-trusted (matches Minecraft model). No fall-trigger correction in 06.7 |
| **Wire shape** | Absolute position: `{X, Y, Z, on_ground}` + variants for rotation. Doubles for coordinates (3×8=24 B per packet) `[CITED: minecraft.wiki]` | Compact input commands + delta-compressed state from old baseline `[CITED: ra.is/unlagged/network.html]` | Axes-only `{seq, axes:{x,y}, buttons_down/up, monotonic_at_ms}` — event-driven, NOT every-tick `[VERIFIED: cInputSchema in intents.ts]` | Absolute position int16: 18 B payload (2+2+2+2+1+1+4+4) |
| **Cadence** | 20 Hz (server tick rate); client sends position every tick under normal play `[CITED: minecraft.wiki/w/Tick — "20 ticks per second"]` | ~20-30 Hz client snapshots; server snapshots variable | Event-driven on keydown/keyup transitions + 15 s heartbeat — NO per-tick send `[VERIFIED: input-dispatcher.ts:26]` | **30 Hz steady, regardless of motion** (D-06) — matches BNO native tick rate; the heartbeat IS implicit |
| **Dropped-packet tolerance** | ✅ Excellent — every packet is self-describing; missed packet recovered by next | ⚠️ Quake3 delta-compresses against arbitrary old snapshot; loss recovery is per-snapshot ack `[CITED: fabiensanglard.net/quake3/network.php]` | ❌ Poor — the held-axes map persists state across ticks, but a dropped keyup leaves player walking until heartbeat re-affirms (15 s). Operator UAT 2026-05-16 confirmed "hitching" `[VERIFIED: SEED §Problem]` | ✅ Excellent — absolute-position-per-tick means single packet loss is one missed frame at 30 Hz = 33 ms — invisible. TCP order + Colyseus reliability means packet loss is the connection drop, which SRV-06 grace already handles |
| **Predictable movement** | ✅ Client owns its position — no rubber-banding under normal play | ⚠️ Reconciler snap if prediction diverges enough; visible in fast-paced FPS | ❌ Failing today — reconciler snap on diagonal-stop produces ~10 px shift `[VERIFIED: SEED §Problem]` | ✅ No reconciler-snap path for self-player; visual position = predictor output, which never reverts |
| **50 CCU bandwidth** | N/A directly comparable (different scale per server) | Per-server typical 32 CCU; bandwidth scales linearly | Low — event-driven keeps c2s sparse | ~37 kbps down per client (50 peers × 30 Hz × ~25 B per peer); ~15 Mbps aggregate at 50×50. Fly.io shared-cpu-1x handles trivially |
| **Single Fly.io machine** | ✅ N/A — Minecraft uses one process per server | ✅ Quake3 uses one machine | ✅ Already deployed | ✅ No change — broadcast is still one fan-out per `setPatchRate` tick |
| **Colyseus 0.17 transport** | N/A (Minecraft is TCP + custom protocol) | N/A (Quake3 is UDP) | ✅ Already configured `[VERIFIED: RebnoRoom.ts:177-178]` | ✅ Same channel (`onMessage('position_update')`) + same state-diff broadcast — zero transport changes |
| **Anti-cheat posture** | Strong — server force-corrects via `Synchronize Player Position` clientbound | Strong — full simulation authority | Strong — full server authority over x/y | **Zero (D-11)** — accepted risk on pre-launch trusted operator pool |
| **Implementation cost** | Reference model only — not directly implementable | Heavyweight — full simulation symmetry required | Already shipped | Modest refactor — new handler + handler-side validator + reconciler-no-op + tick-loop trim |

**Score summary:**
- **Minecraft model** is the closest aligned reference for REBNO's pre-launch posture: client owns position, server stores it. ✅ for everything that matters.
- **Quake lineage** is what REBNO is moving AWAY from — it's overkill for a 50 CCU walking-chat game and the reconciler snap is the SOURCE of operator-reported pain.
- **Current REBNO** scores ❌ on predictable-movement and packet-loss tolerance — both motivating bugs in SEED.

## Dropped-Packet Tolerance Analysis (D-16)

**Question:** how often is a missed `position_update` survivable for the remote-player path? Do we need server-side extrapolation between packets?

**Finding (HIGH confidence):** No server-side extrapolation needed. Three converging reasons:

### 1. Colyseus runs over TCP — there is no "missed packet" in the UDP sense

`[VERIFIED: WebSearch — Colyseus FAQ + docs.colyseus.io/server/transport/ws]` Colyseus's default transport is WebSocket (TCP). TCP guarantees:
- **In-order delivery** — frames arrive in send order
- **Reliable delivery** — frames are retransmitted on loss at the transport layer
- **No reordering** — application sees a strictly ordered stream

The only "packet loss" possible from the application's perspective is the connection itself dropping, which already triggers Colyseus's `onLeave` → SRV-06 10 s reconnect grace. During that grace window the player's position is frozen at last-known; this is the correct behavior and matches operator UAT expectations (a dropped player visually pauses, doesn't extrapolate into walls).

### 2. 30 Hz absolute position is self-healing by construction

`[VERIFIED: REQ-SRV-05 in REQUIREMENTS.md + accumulator.ts:8]` Each position_update is fully self-describing — no deltas, no baseline references, no acks required. If frame N is somehow delayed (TCP retransmit blip), frame N+1 lands 33 ms later (at 30 Hz) and contains the same data type — the latest absolute position. The visual effect on the remote viewer: one frame's pause, then jump to current. At 33 ms intervals this is invisible to human perception.

Compare with Quake 3's delta-compressed snapshots `[CITED: ra.is/unlagged/network.html]`, which can stall ALL subsequent decodes until the baseline arrives — REBNO's model is more robust here.

### 3. The remote-extrapolator already exists for the 30 ms gap case

`[VERIFIED: packages/protocol/src/state.ts:31-32 + ADR 0007 cited there]` `PlayerState.axis_x_held` and `axis_y_held` are broadcast every tick today. The client-side extrapolator uses these to smooth between snapshots (250 ms cap per ADR 0007). With position_update at 30 Hz, gap between consumed snapshots is normally 33 ms; the extrapolator only kicks in when a snapshot is genuinely delayed. The 250 ms cap means even a worst-case TCP-retransmit-spike (~150 ms) is bounded.

**The new position_update fields populate the same PlayerState slots.** `vx` and `vy` from D-05 land in `PlayerState.vx`/`vy` and the existing extrapolator can incorporate them directly. No new client logic required.

### Recommendation
- **Broadcast cadence:** `setPatchRate(TICK_MS=50)` (current value). At 30 Hz client send + 20 Hz server broadcast, every client snapshot reflects ≤ 50 ms-old data. Acceptable.
- **No downsampling.** Concrete bandwidth budget: 50 CCU × 30 Hz × ~25 B msgpackr-encoded PlayerState diff per peer = ~37 kbps down per client; aggregate ~15 Mbps over WS. Fly.io shared-cpu-1x is rated 1000s of Mbps NIC throughput — not a constraint.
- **Defer optimization** until a Phase 7 60 CCU+ stress test demands it. Premature.

## Recommended `position_update` Wire Schema

### TypeScript shape (zod, JSON over Colyseus channel)

```typescript
// packages/protocol/src/intents.ts — ADD after cSetFacingSchema
export const cPositionUpdateSchema = z.object({
  type: z.literal('position_update'),
  // Int16 quantized — D-07. Range [-32768, 32767] safely covers BNCentral
  // 8000×6400 px and any room ≤ ~30000 px. Snap-rounded by client (Math.round).
  x: z.number().int().min(-32768).max(32767),
  y: z.number().int().min(-32768).max(32767),
  // BNO instant-set/instant-stop model means vx/vy are typically -5..+5 px/tick.
  // Int16 range trivially holds this with headroom for any future faster items.
  vx: z.number().int().min(-32768).max(32767),
  vy: z.number().int().min(-32768).max(32767),
  // Facing — D-10. Same enum as cSetFacingSchema (legacy compat).
  facing: z.enum(['D', 'DR', 'R', 'UR', 'U', 'UL', 'L', 'DL']),
  // anim_state — D-08. Packed 1-byte enum (see byte layout below).
  anim_state: z.number().int().min(0).max(255),
  // seq — keep per Pattern 4. Per-account, monotonic.
  seq: z.number().int().min(0),
  // monotonic_at_ms — 06.4 D-58c precedent; staleness detection without trusting wall-clock.
  monotonic_at_ms: z.number().nonnegative(),
}).strict();
```

### Byte layout (binary packing — if Phase 7 demands further compression)

| Field | Type | Size | Range | Notes |
|-------|------|------|-------|-------|
| `x` | int16 LE | 2 B | -32 768..32 767 | Pixel-quantized; client `Math.round()` before send |
| `y` | int16 LE | 2 B | -32 768..32 767 | Same |
| `vx` | int16 LE | 2 B | -32 768..32 767 | Typically -5..+5 (RUN_SPEED) |
| `vy` | int16 LE | 2 B | -32 768..32 767 | Same |
| `facing` | u8 | 1 B | 0..7 | Octant index (see enum below) |
| `anim_state` | u8 | 1 B | 0..255 | Packed (see below) |
| `seq` | u32 LE | 4 B | 0..4 294 967 295 | Per-account monotonic |
| `monotonic_at_ms` | u32 LE | 4 B | 0..~50 days | Wraps roughly every 50 days — acceptable for a 06.4 D-58c precedent value |
| **Total payload** | | **18 B** | | |

**Wire-frame overhead:** Colyseus message-channel framing adds ~10-20 B (channel name "position_update" + msgpackr keyed-object header). End-to-end frame size ~30-40 B. At 30 Hz × 50 CCU upstream that's ~50-60 kbps per server NIC for c2s; trivial.

### facing enum (octant index)

```typescript
export const FACING_OCTANT: Record<Direction, number> = {
  R:  0,  // 0°
  UR: 1,  // 45°
  U:  2,  // 90°
  UL: 3,  // 135°
  L:  4,  // 180°
  DL: 5,  // 225°
  D:  6,  // 270°  ← BNO spawn default
  DR: 7,  // 315°
};
```

### anim_state byte layout (packed 1-byte enum)

```
bit 7  6  5  4  3  2  1  0
       |reserved | |R| |octant|
                  │   └── 3 bits: facing octant 0..7 (mirrors `facing` field — see note)
                  └── bit 3: 1 = running, 0 = standing
       └── bits 4-7: RESERVED for future poses (jump=0x10, hit=0x20, etc.)
```

Mapped values for the 16 base poses:

| Pose | Byte | Description |
|------|------|-------------|
| `STAND_D`  | `0x06` | facing=D (6), running=0 |
| `STAND_DR` | `0x07` | facing=DR (7), running=0 |
| `STAND_R`  | `0x00` | facing=R (0), running=0 |
| `STAND_UR` | `0x01` | facing=UR (1), running=0 |
| `STAND_U`  | `0x02` | facing=U (2), running=0 |
| `STAND_UL` | `0x03` | facing=UL (3), running=0 |
| `STAND_L`  | `0x04` | facing=L (4), running=0 |
| `STAND_DL` | `0x05` | facing=DL (5), running=0 |
| `RUN_D`    | `0x0E` | facing=D (6), running=1 (0x08) |
| `RUN_DR`   | `0x0F` | facing=DR (7), running=1 |
| `RUN_R`    | `0x08` | facing=R (0), running=1 |
| `RUN_UR`   | `0x09` | facing=UR (1), running=1 |
| `RUN_U`    | `0x0A` | facing=U (2), running=1 |
| `RUN_UL`   | `0x0B` | facing=UL (3), running=1 |
| `RUN_L`    | `0x0C` | facing=L (4), running=1 |
| `RUN_DL`   | `0x0D` | facing=DL (5), running=1 |

**Note on the facing-vs-anim_state redundancy:** `facing` (top-level field) and `anim_state` bits 0-2 carry the same octant on the wire — this looks redundant. It is intentional per D-10: `facing` = "where the player is pointing" (intent / D-58c set_facing semantics); `anim_state` = "what pose to RENDER right now". The two diverge during HexportIn (`set_sprite_override`) where `facing` may stay at D but rendered pose is the override. Cost of the duplication: 1 byte. Keep both.

**Reserved bits 4-7** future-proof for jump (`0x10`), hit/stun (`0x20`), watching (`0x30` = set via `set_sprite_override` but mirrored here for state-diff completeness). Up to 16 future poses with zero protocol change.

### `set_sprite_override` schema (rare event)

```typescript
// packages/protocol/src/intents.ts — ADD after cPositionUpdateSchema
export const cSetSpriteOverrideSchema = z.object({
  type: z.literal('set_sprite_override'),
  sprite_id: z.enum([
    'hexport_in', 'hexport_out',
    'tele_in', 'tele_out',
    'ncol_1', 'ncol_2', 'ncol_3', 'ncol_4',
    'jokershell',
    'watching',
    'none',  // clear override
  ]),
  // No coords/velocity — the override is layered atop the position_update stream.
}).strict();
```

Override semantics (server side): write `PlayerState.sprite_override: string` (new field — additive). Client renderer prefers `sprite_override` over `anim_state` when non-`'none'`. Override is cleared either by the client sending `set_sprite_override(none)` or — once 06.8 lands — by the fall-reset path.

**This phase ships `set_sprite_override` as a stub** — handler + schema + state field, but no consumer wiring in the renderer (since the listed special poses are all post-MVP). The wiring is forward-compat: when PAR-* phases add Hexport / TeleIn animations, the protocol surface already exists.

## Treatment of `seq`, `last_input_seq`, server-side `step()`, broadcast cadence

### `seq` / `last_input_seq` — KEEP

**Recommendation:** Keep both fields exactly as Pattern 4 describes. They are already in the protocol, already on `PlayerState`, and cost 4 B/frame on the wire — trivial for the staleness-detection and reconnect-coherency benefits.

**Concrete server-side use:**
```typescript
if (parsed.data.seq <= player.last_input_seq) {
  // Out-of-order position_update — discard. Rare under TCP but cheap to defend.
  return;
}
player.last_input_seq = parsed.data.seq;
```

### Server-side `step()` for self-player platform effects — DROP from self-player path

**Recommendation:** Stop running `step()` for self-player position derivation entirely. Run `step()` ONLY for platforms (REQ-SRV-14) and any future world simulation.

**Rationale:** Once the client is the authoritative source for `{x, y, vx, vy, facing, anim_state}`, the server has no business running collision/platform-carry logic against the position the client just declared. If the client is on a conveyor platform, the client's `predictor.ts` already runs `step()` locally with the platform state in its WorldState (it reads platforms from the broadcast `PlatformState`). The position the client sends to the server already includes the platform-carry displacement; the server applying it again would double-count.

**The clean architecture:**
- Client `predictor.ts` runs `step()` locally with full WorldState (platforms + self + collision grid).
- Client sends `{x, y, vx, vy, ...}` reflecting the post-step values.
- Server stores them. Done.
- Server's own `step()` advances platforms (so the broadcast PlatformState stays fresh) but does NOT touch player position.

This is the simplest, fewest-moving-parts design and matches Minecraft `[CITED: minecraft.wiki/w/Tick — server tick advances world state, client sends Set Player Position]`.

**Forward-compat concern (06.8 Phase B):** when the fall trigger lands, the server WILL need a server-side walkable check — but that's a *read-only* check against the client-reported position, NOT a re-derivation. Adding it is orthogonal to running step() for the player.

### Broadcast cadence — FULL 30 Hz (= current setPatchRate)

**Recommendation:** No change. `setPatchRate(TICK_MS=50)` stays — that's 20 Hz broadcast against a 30 Hz client-send. Server stores 30 Hz of updates but only fans out the latest at 20 Hz. Bandwidth-per-client at 50 CCU stays ~37 kbps. Defer downsampling until profiling shows fan-out is a bottleneck.

Actually — re-examine: the current `setPatchRate(TICK_MS=50)` aligns broadcast with the server tick. With the new model the server tick still runs at 20 Hz (accumulator.ts) for platforms. Keeping `setPatchRate(TICK_MS=50)` is the right call; it aligns broadcast with the server's own simulation cadence and the existing extrapolator's 250 ms cap.

**Why not match patch rate to the client send rate (30 Hz)?** Two reasons: (1) the server isn't computing player position anymore, so faster broadcast adds no per-tick fidelity; (2) state-diff batching at 50 ms naturally coalesces multiple 33 ms client sends into one network frame downstream, saving bandwidth. Win-win.

## Common Pitfalls

### Pitfall 1: Reconciler-snap regression

**What goes wrong:** A future tweak to `reconciler.ts` accidentally re-enables the self-player write path (e.g., someone refactors and removes the early return).

**Why it happens:** The current self-reconciler is the source of the diagonal-stop drift bug; the temptation to "just fix the threshold" rather than fully no-op is real.

**How to avoid:** Add an explicit unit test asserting `ReconcileEngine.onServerSnapshot(snap, isSelfPlayer=true)` produces **zero** calls to `sprite.setPosition` and **zero** calls to `sprite.tweenTo`. D-04 calls this out as an acceptance criterion. Pair with a comment in `reconciler.ts` header explaining the invariant.

**Warning signs:** Operator UAT reports "10 px diagonal shift", "rubber-banding on stop", or "visible jolt on connection blip". Diagnostic ring-buffer `globalThis.__rebno.lastReconcile*` (still useful for debugging remote-path reconciler if retained) will show `lastReconcileReason: 'divergence-threshold'` with self account_id — instant red flag.

### Pitfall 2: Stale local prediction after force-resume

**What goes wrong:** Client reconnects after SRV-06's 10 s grace; server has the LAST-broadcast position (the one BEFORE the disconnect). Client's local predictor has been running the whole time and is now ahead. First position_update post-reconnect tells the server the new (correct) position.

**Why it's actually fine:** This is the model working as designed. Client is the authoritative source; server adopts client's value on the next position_update. The only visible effect is a single frame where remote viewers see the player at the pre-disconnect position before the post-reconnect update lands — invisible at 30 Hz.

**How to avoid worry:** Test it explicitly. Server `reconnect.integ.test.ts` should assert: disconnect → reconnect → first position_update overwrites server's stored x/y. (Likely a tiny addition to an existing test.)

### Pitfall 3: Position_update spam from a runaway client

**What goes wrong:** Bug in client send-loop causes 1000+ position_updates per second; server token-bucket drops most but processing each parse still costs CPU.

**Why it happens:** Client tick loops can degenerate; an infinite loop in render code could starve the tick scheduler and then catch up by firing many in a row.

**How to avoid:** Reuse the existing token bucket. Add `'position_update'` as a new msg_type to `TokenBucket` per CONTEXT D-22 + Plan 04-08. Suggested budget: refill at 35 tokens/sec, burst capacity 60 (gives 30 Hz + 5/sec headroom for reconnect retransmits, mutes at 60). The cheap-path-before-zod pattern from `onMessageHandlers.ts:69-96` is the right design.

**Warning signs:** `rate_limit_dropped` log entries spike for `msg_type: 'position_update'`. Operator dashboard should plot this.

### Pitfall 4: Forgetting to bump PROTOCOL_VERSION

**What goes wrong:** Old v3 clients send axes-shape `c2s.input` payloads; new v4 server only handles `position_update`. Mixed-version traffic silently degrades (movement becomes a no-op for the v3 client because nothing writes its position anymore).

**Why it happens:** The version-bump ritual is documented in `packages/protocol/src/version.ts` history block; easy to skip in the rush of refactoring.

**How to avoid:** Bump `PROTOCOL_VERSION = 4` and update the history block. The existing `validateAuthFrame` in `intents.ts:285-299` will reject v3 clients with `PROTOCOL_VERSION_MISMATCH` at handshake — exactly the right behavior under D-12 hard-cut deploy. Phase-plan task should call this out explicitly with the deploy-ritual checklist.

**Warning signs:** Mixed-version is impossible by construction once the server rejects v3; the only way this bug manifests is if the version bump is forgotten. Test: `apps/server/test/protocol-v2-handshake.integ.test.ts` precedent — add a `protocol-v4-handshake` variant.

### Pitfall 5: PlayerState schema extension breaks Colyseus delta-encoder

**What goes wrong:** Adding `anim_state` and `sprite_override` fields to `PlayerState` in `packages/protocol/src/state.ts` requires Colyseus schema-add discipline. `[VERIFIED: state.ts:11-13 comment block]` explicitly warns: "Adding fields to a `@colyseus/schema` class is wire-incompatible with v1 clients per RESEARCH §A5 — the deploy ritual is non-negotiable."

**Why it happens:** Easy to add a field, forget to bump version, mismatched-schema clients get garbled state.

**How to avoid:** Same deploy ritual as the PROTOCOL_VERSION bump. The version-history comment in `version.ts` notes "ADR 0007 locks the contract" — read ADR 0007 and ensure entry 4 covers position_update schema. New state.ts fields:
```typescript
@type('number') anim_state: number = 0x06;  // default = STAND_D
@type('string') sprite_override: string = 'none';
```

### Pitfall 6: Phaser tween fighting predictor

**What goes wrong:** Old reconciler called `sprite.tweenTo(x, y, 100)`. If the no-op branch isn't entered correctly, a running tween could still be in-flight at the moment predictor.predictTick writes new sprite.setPosition — visual fight, jerky movement.

**Why it happens:** Phaser tweens hold a reference to the sprite and apply incrementally; setPosition during a tween creates a race.

**How to avoid:** When removing the self-reconciler tween path, ALSO ensure GameScene cancels any in-flight tween targeted at the local sprite before the predictor writes. Pattern: `scene.tweens.killTweensOf(localSprite)` in the predictor-write code path. Test surface: `apps/client/src/__test__/game-scene.test.ts`.

### Pitfall 7: msgpackr Buffer vs Uint8Array normalization (carry-forward)

**What goes wrong:** Server emits a position_update derived value via `encodeS2C` (Buffer instance on Node); client expects Uint8Array. The existing `decodeS2C` in `events.ts:80-94` has `normaliseBinary` for `room_layout` — if a new S2C event is added in 06.7 that carries binary, it needs the same normalization.

**Why it's flagged:** Phase A doesn't add new S2C binary fields (the broadcast goes via Colyseus state-diff, not msgpackr) — but if planning adds an `s2c.position_broadcast` event (rejected option from "Alternatives Considered"), this Pitfall reactivates.

**How to avoid:** Stick to Colyseus state-diff. If a parallel binary event channel becomes necessary in 06.8, mirror the `normaliseBinary` pattern.

### Pitfall 8: Colyseus 0.17 `players.onChange` doesn't fire per-field

**What goes wrong:** `[VERIFIED: colyseus-client.ts:241-250 comment]` documents this: `players.onChange` only fires on Map key add/remove, NOT on per-field updates. Existing client uses `$(player).onChange(...)` inside `onAdd` to catch per-field deltas. New `anim_state` / `sprite_override` fields fall under the same per-instance handler — no new wiring needed, just verify the existing handler reads them.

**Warning signs:** Remote avatar plays wrong animation pose despite server logs showing correct `PlayerState.anim_state`. Diagnostic: log `player.anim_state` inside `$(player).onChange` callback in dev builds.

## Code Examples

### Adding `cPositionUpdateSchema` to the discriminated-union

```typescript
// packages/protocol/src/intents.ts — ADD to c2sSchema
// Source: pattern from intents.ts:120-127 (existing c2sSchema definition)
export const c2sSchema = z.discriminatedUnion('type', [
  cAuthSchema,
  cInputSchema,           // KEEP — chat-pause uses pauseMovement → sendInput(0,0); also test harness path
  cChatSendSchema,
  cRoomJoinSchema,
  cHeartbeatSchema,
  cSetFacingSchema,
  cPositionUpdateSchema,   // NEW (06.7)
  cSetSpriteOverrideSchema, // NEW (06.7)
]);
```

**Decision call:** keep `cInputSchema`? Yes — for two reasons:
1. `input-dispatcher.ts:298` `pauseMovement()` sends `{axes: {x:0, y:0}}` to clear server held-input state during chat-input focus. Easier to keep this contract than to remove it.
2. Test harnesses poke `inputBuffer` directly via `cInputSchema` (per `onMessageHandlers.ts:21-23`). Removing breaks tests.

**Server-side mechanics:** with the carve-out, the `c2s.input` handler in `onMessageHandlers.ts:104-136` still writes into `heldInputs`, but `tickLoop` no longer reads `heldInputs` for player position. Heldinputs becomes vestigial-but-harmless. Plan-task: leave it for 06.7; remove in a 06.9 cleanup once 06.8 ships.

### Position dispatcher (client, every-tick send)

```typescript
// apps/client/src/prediction/position-dispatcher.ts  (NEW FILE)
// [impl->REQ-CLI-04] [impl->REQ-CLI-08]
// Source: pattern derived from input-dispatcher.ts:256-286 sendInput
import type { Room } from '@colyseus/sdk';
import type { PredictionEngine } from './predictor.js';
import type { SpriteStateMachine } from '../render/SpriteStateMachine.js';
import { packAnimState, FACING_OCTANT } from '@rebno/protocol';

export class PositionDispatcher {
  private nextSeq = 0;
  constructor(
    private readonly room: Pick<Room, 'send'>,
    private readonly prediction: PredictionEngine,
    private readonly getFacing: () => Direction,
    private readonly getIsRunning: () => boolean,
  ) {}

  /** Called once per sim tick (30 Hz) by GameScene.update. */
  sendTick(monotonic_at_ms: number): void {
    const sim = this.prediction.getLocalState();
    const facing = this.getFacing();
    const isRunning = this.getIsRunning();
    this.room.send('position_update', {
      type: 'position_update',
      x: Math.round(sim.x),
      y: Math.round(sim.y),
      vx: Math.round(sim.vx),
      vy: Math.round(sim.vy),
      facing,
      anim_state: packAnimState(facing, isRunning),
      seq: ++this.nextSeq,
      monotonic_at_ms,
    });
  }
}
```

### Server tickLoop after the change

```typescript
// apps/server/src/RebnoRoom.ts — MODIFIED tickLoop (compare to existing :1274-1315)
private tickLoop(realDt: number): void {
  const { newAcc, ticks } = advanceAccumulator(this.accumulator, realDt);
  this.accumulator = newAcc;
  if (realDt > 100 || ticks > 4) {
    log.warn({ event: 'tick_loop_slow', realDt, ticks, roomId: this.roomId, playerCount: this.state.players.size },
      'tick loop slow — event-loop stall suspected');
  }
  for (let i = 0; i < ticks; i++) {
    // Server step() ONLY for platforms (REQ-SRV-14).
    // Player position is no longer derived here — it's written by the
    // position_update handler. Pass an empty inputs map: with the player
    // pass-through change in step.ts (Pattern 2 above), the players map
    // round-trips unchanged.
    const next = step(this.toWorldState(), new Map(), TICK_MS);
    this.applyToColyseusState(next);
  }
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Quake3 server-authoritative simulation + reconciler | Minecraft-style client-trusted position + corrective override (the "fall trigger" backstop in 06.8) | Modern small-scale multiplayer (post-~2015) | Predictable feel, simpler server, smaller anti-cheat surface for trusted player pools |
| Event-driven axes input | Per-tick absolute position streaming | The phase | Single dropped packet recovers naturally; no held-state divergence |
| Hybrid prediction + threshold-snap reconciler | Pure client-feel for self, server-broadcast for remote | The phase | Removes the diagonal-stop ~10 px drift root cause |

**Deprecated/outdated (post-06.7):**
- `apps/client/src/prediction/reconciler.ts` `globalThis.__rebno.lastReconcile*` writes for the self-player — remove cleanly. Operator diagnostic still useful if a remote-snap path is added in 06.8 (it currently isn't called for remotes either, but the symmetry is worth preserving until 06.8 confirms).
- `DIVERGENCE_THRESHOLD_PX = 22` constant — stays defined but unused. Mark `@deprecated 06.7` in the comment.

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `[ASSUMED]` Bandwidth at 50 CCU × 30 Hz × 25 B is ~15 Mbps aggregate / ~37 kbps per client | "Standard Stack" + "Dropped-Packet Tolerance" | Low — back-of-envelope is conservative; even if 3× off, still well under Fly.io shared-cpu-1x NIC budget |
| A2 | `[ASSUMED]` Colyseus state-diff batches multiple 33-ms client sends into one 50-ms broadcast frame downstream | "Broadcast cadence" recommendation | Low — Colyseus docs `[CITED: docs.colyseus.io/state — "only state mutations are broadcasted at every patch"]` confirm property-level batching; the temporal coalescing is implied but not explicitly tested in this session |
| A3 | `[ASSUMED]` Client `predictor.ts` already incorporates platform-carry into its local step() | "Server-side step() retention" — DROP recommendation | Medium — if `predictor.ts` does NOT consume PlatformState in its local WorldState, then dropping server-side player-position step() leaves the client unable to compute platform-carry locally. **Mitigation:** verify before implementation. Predictor.ts:105-115 builds `prevState.platforms: new Map()` (empty) — so as of today the client does NOT have full platform-carry. This is acceptable for 06.7 (MVP slice has no platforms per CDOC/PAR-03 deferral) but the assumption needs explicit handoff to Phase 7 when platforms ship. Plan-phase should flag this. |
| A4 | `[ASSUMED]` Token bucket budget of 35 tokens/sec, burst 60 is sufficient for position_update at 30 Hz | "Pitfall 3" | Low — rate-limit values are tunable post-staging; default starting point chosen with 5 Hz headroom |
| A5 | `[ASSUMED]` PROTOCOL_VERSION bump from 3 → 4 is the right path (vs. additive optional fields) | "Pitfall 4" | Low — wire shape is genuinely changing (new `position_update` handler is the new authoritative source for player position); D-12 hard-cut + version-history convention `[VERIFIED: version.ts:14-22]` makes the bump the established practice |
| A6 | `[ASSUMED]` Bits 4-7 of `anim_state` reserved for future poses (jump/hit/watching) covers needs through Phase 7 PAR-* | "anim_state byte layout" | Low — 4 reserved bits = 16 future poses; legacy BNO has ~6 special poses tracked in SEED |

## Open Questions (RESOLVED)

1. **A3 — does client-side `predictor.ts` need to start consuming PlatformState?**
   - What we know: predictor.ts:105-115 builds a WorldState with `platforms: new Map()` (empty)
   - What's unclear: when Phase 7 adds platform-bearing rooms, will the position the client SENDS include platform-carry displacement, or will it omit it (causing visual sync to lag platforms)?
   - Recommendation: Plan-phase task should NOT add platform-carry to predictor.ts in 06.7 (MVP has no platforms). Add an explicit cross-reference comment in the predictor + flag this in PAR-03 planning.

2. **Should `ReconcileEngine` be deleted entirely vs. neutered to a no-op?**
   - What we know: ReconcileEngine.onServerSnapshot is invoked from somewhere in GameScene (likely; need to confirm during planning). The current code already documents (06.4 round-3 comments) that remote players go through PlayerRenderer, not ReconcileEngine.
   - What's unclear: is there ANY caller of ReconcileEngine.onServerSnapshot in active client code post-06.7? If not, the cleanest action is full deletion of the file + test.
   - Recommendation: Planner should grep `apps/client/src` for `ReconcileEngine` and `onServerSnapshot` callers. If zero non-test callers, full deletion is safer than no-op-with-trapdoor. Document the diagnostic-ringbuffer-loss tradeoff.

3. **Should `c2s.input` and `heldInputs` be removed in 06.7 or deferred to 06.9 cleanup?**
   - **RESOLVED (codex review override, 2026-05-17 — Path A locked):** The original recommendation ("KEEP in 06.7, defer all cleanup to 06.9") is PARTIALLY OVERTURNED. Codex grepped the repo and found that `RebnoRoom.applyToColyseusState()` (apps/server/src/RebnoRoom.ts:1340-1388) actively reads `heldInputs` to write PlayerState.facing + axis_x_held + axis_y_held EVERY tick, AND that `GameScene.ts:1184-1194` derives remote-player vx/vy from `p.axis_x_held * RUN_SPEED_PX_PER_TICK`. Under the 06.7 client-trust model, these reads OVERWRITE the position_update handler's writes on the very next tick — fully invalidating the trust flip.
   - **Path A chosen (smaller blast radius):** the apply-path READS are removed in 06.7. Specifically:
     - Plan 02 Task 2 deletes the heldInputs-read block at RebnoRoom.ts:1356-1386 (axis_x_held/axis_y_held writes + deriveFacing-from-held derivation block).
     - Plan 03 Task 3 region #6 switches GameScene.ts:1192-1193 from `(p.axis_x_held ?? 0) * RUN_SPEED_PX_PER_TICK` to `p.vx ?? 0` (and same for y), consuming the velocity that the position_update handler writes.
     - The `heldInputs` MAP itself + the `c2s.input` handler that populates it + the `axis_x_held`/`axis_y_held` PlayerState fields STAY vestigial in 06.7 (the chat-focus pauseMovement contract still calls cInputSchema). Their CLEANUP (full removal from PlayerState schema + handler deletion + cInputSchema deletion) is deferred to candidate Phase 06.9.
   - **Path B considered + rejected:** "Keep server `axis_*_held` writes; change client remote rendering to consume `vx`/`vy`/`anim_state`." Smaller server diff but leaves two state sources of truth in place (held axes AND vx/vy both writeable on PlayerState) — fragile under future refactors. Path A consolidates to a single source of truth: position_update is the only writer of PlayerState.facing/vx/vy/anim_state.
   - **Acceptance gates for Path A:**
     - Plan 02 codex #2 gate: `awk '/private applyToColyseusState/,/^  \}$/' apps/server/src/RebnoRoom.ts | grep -c "heldInputs"` returns 0.
     - Plan 03 codex #2 gate: `grep -nE "p\.axis_x_held.*RUN_SPEED_PX_PER_TICK" apps/client/src/scenes/GameScene.ts` returns 0 matches.
     - Plan 02 codex #3 positive-guard integ: after position_update, sending c2s.input axes does NOT alter PlayerState.x or PlayerState.facing for that account.

4. **Should the protocol track a server-broadcast `last_received_at_server_monotonic_ms` for round-trip diagnostics?**
   - What we know: client sends `monotonic_at_ms`; server doesn't echo a corresponding timestamp.
   - What's unclear: would operators benefit from a c2s/server-stamp round-trip diagnostic for staging UAT?
   - Recommendation: defer; no signal from operator that this is needed. Position itself self-validates.

## Environment Availability

Skipped — phase is purely code refactor against existing stack. All dependencies already pinned in `apps/server/package.json` and `apps/client/package.json`; no new tooling required.

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | `vitest` 4.1.5 (server) / 3.2.4 (client) `[VERIFIED: package.json files]` |
| Config file | Implicit `vitest` config in each app + workspace |
| Quick run command (server) | `pnpm -C apps/server test` |
| Quick run command (client) | `pnpm -C apps/client test` |
| Full suite command | `pnpm -r test` (workspace-recursive) |
| Integration tests (server) | `pnpm -C apps/server test:integration` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| REQ-SRV-03 | server `position_update` handler validates with cPositionUpdateSchema (zod strict) and writes to PlayerState | unit | `pnpm -C apps/server test position-update` | Wave 0: NEW `apps/server/test/position-update.test.ts` |
| REQ-SRV-03 | server `position_update` handler is rate-limited and rejects stale seq | unit | `pnpm -C apps/server test position-update-rate-limit` | Wave 0: NEW (covered in same file) |
| REQ-SRV-03 | server `set_sprite_override` handler accepts valid sprite_id, writes to PlayerState.sprite_override | unit | `pnpm -C apps/server test sprite-override` | Wave 0: NEW `apps/server/test/sprite-override.test.ts` |
| REQ-SRV-03 | end-to-end: client sends position_update → server stores → broadcasts → second client sees update | integration | `pnpm -C apps/server test:integration position-update` | Wave 0: NEW `apps/server/test/position-update.integ.test.ts` |
| REQ-SRV-03 | PROTOCOL_VERSION 3 client rejected at handshake (close 4400) | integration | `pnpm -C apps/server test:integration protocol-v4-handshake` | Wave 0: NEW `apps/server/test/protocol-v4-handshake.integ.test.ts` (mirrors `protocol-v2-handshake.integ.test.ts`) |
| REQ-SRV-14 | `step()` still advances platform positions (regression — platforms not broken by carve-out) | unit | `pnpm -C packages/game-logic test step.platforms` | ✅ existing `packages/game-logic/test/step.test.ts` — add platform-advance assertion if not present |
| REQ-CLI-04 | reconciler.onServerSnapshot is a no-op for self-player — sprite.setPosition + sprite.tweenTo never called (D-04 acceptance criterion) | unit | `pnpm -C apps/client test reconciler-self-noop` | Wave 0: NEW assertion added to `apps/client/src/__test__/reconciler.test.ts` |
| REQ-CLI-04 | PositionDispatcher sends position_update at 30 Hz on tick, correctly populated from PredictionEngine + SpriteStateMachine output | unit | `pnpm -C apps/client test position-dispatcher` | Wave 0: NEW `apps/client/src/__test__/position-dispatcher.test.ts` |
| REQ-CLI-04 | predictor continues to drive local sprite position on every tick (regression — local feel preserved) | unit | `pnpm -C apps/client test prediction` | ✅ existing `apps/client/src/__test__/prediction.test.ts` |
| REQ-CLI-04 | anim_state packing — 16 base poses round-trip pack→unpack | unit | `pnpm -C packages/protocol test anim-state` | Wave 0: NEW `packages/protocol/test/anim-state.test.ts` |
| REQ-CLI-08 | two-player smoke: A moves, B sees A move smoothly (no rubber-band, no diagonal-stop drift, no hitching on simulated packet delay) | e2e | `pnpm -C apps/client test:e2e two-player-movement` | partial — covered by existing CLI-08 e2e; ADD assertion for diagonal-stop drift regression |
| REQ-CLI-08 | manual operator UAT on staging — diagonal-stop drift bug closed; dropped-packet hitching bug closed | manual-only | (operator session — captures in `06.7-HUMAN-UAT.md`) | Wave 0: NEW operator UAT script |

**Manual-only justification (REQ-CLI-08 row 2):** the original two motivating bugs (D-02) are subjective movement-feel issues caught by the operator's eye, not by automated thresholds. The automated CLI-08 e2e covers structural correctness (positions update, broadcast happens, two players see each other); operator UAT closes the feel question. Standard for 06.* operator-UAT-gated phases (see `04-HUMAN-UAT.md` precedent).

### Sampling Rate
- **Per task commit:** `pnpm -C <affected-package> test` (server / client / protocol / game-logic — only the changed one)
- **Per wave merge:** `pnpm -r test` (workspace-recursive) + `pnpm -r typecheck` + `pnpm trace:check`
- **Phase gate:** Full suite green + `pnpm -C apps/server test:integration` green + `pnpm -C apps/client test:e2e` green + operator UAT signed off before `/gsd-verify-work`

### Wave 0 Gaps
- [ ] `apps/server/test/position-update.test.ts` — unit tests for cPositionUpdateSchema.safeParse rejection paths + handler PlayerState writes — covers REQ-SRV-03
- [ ] `apps/server/test/position-update.integ.test.ts` — two-client roundtrip integration test — covers REQ-SRV-03
- [ ] `apps/server/test/sprite-override.test.ts` — handler + schema — covers REQ-SRV-03
- [ ] `apps/server/test/protocol-v4-handshake.integ.test.ts` — version-bump rejection — covers REQ-SRV-03
- [ ] `apps/client/src/__test__/position-dispatcher.test.ts` — dispatcher unit — covers REQ-CLI-04
- [ ] `packages/protocol/test/anim-state.test.ts` — packAnimState/unpackAnimState round-trip — covers REQ-CLI-04
- [ ] `apps/client/src/__test__/reconciler.test.ts` — ADD self-noop assertion (file exists; new test case) — covers REQ-CLI-04 D-04 acceptance
- [ ] Update existing `apps/client/src/__test__/prediction.test.ts` — confirm predictor still drives sprite (regression guard) — covers REQ-CLI-04
- [ ] `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` — operator UAT script covering diagonal-stop drift + simulated-packet-delay hitching — covers REQ-CLI-08

*Framework install: none — `vitest` already pinned and runs in every package.*

## Security Domain

> Phase-relevant ASVS categories. REBNO's `security_enforcement` is implicit-on (no explicit `false` in config.json `workflow` block).

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes | Better-Auth + argon2id (unchanged — auth runs at room onAuth before any position_update can land) |
| V3 Session Management | yes | Server-tagged identity from `client.auth.account_id`; wire-side identity REJECTED at zod parse (`.strict()`) — same pattern as existing `cInputSchema` |
| V4 Access Control | yes | Server checks `auth` exists before accepting any position_update; rate-limit gate (token bucket) per (account_id, msg_type) |
| V5 Input Validation | **yes — primary** | zod `cPositionUpdateSchema.strict()` validates every field, including int16 range on x/y/vx/vy, enum on facing, byte range on anim_state, monotonic on seq. Same pattern as existing schemas. |
| V6 Cryptography | no | No new crypto in 06.7; manifest signatures for room layouts unchanged |

### Known Threat Patterns for {Colyseus 0.17 + Phaser 3.90 + zod stack}

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Forged sender identity on the wire (client claims to be another account) | Spoofing | `.strict()` on cPositionUpdateSchema rejects any extra fields including `account_id`/`pid`; server tags identity from `client.auth.account_id` (existing pattern, `onMessageHandlers.ts:107-110`) |
| Malicious zod payload (huge / deeply-nested) | DoS | int16 bounds + 1-byte enum bounds + bounded string for facing enum + non-negative for seq cap memory cost per parse to bytes |
| Position-update spam | DoS | Token bucket per (account_id, 'position_update') — same pattern as existing rate limits, suggested 35 tokens/sec / burst 60 |
| Position spoofing for advantage (teleport) | Tampering | **Accepted in 06.7 per D-11** — fall-trigger reset is 06.8 mitigation; trusted operator pool in interim |
| Stale / out-of-order updates from TCP reorder | Tampering (mild) | seq monotonic check: `if (parsed.data.seq <= player.last_input_seq) return;` |
| `anim_state` corruption causing client renderer crash | Tampering | int range cap (0..255) + client-side enum-table lookup (already a defensive pattern in SpriteStateMachine: unknown frame keys would fall through to STAND_D) |

**Threat acceptance note (D-11):** position spoofing is acknowledged as accepted risk. The threat-modeling row above is for documentation continuity; CLAUDE.md Hard Rule 1 carve-out per D-13 codifies the acceptance.

## Sources

### Primary (HIGH confidence)
- **Codebase (this session):** apps/server/src/RebnoRoom.ts, apps/server/src/onMessageHandlers.ts, apps/client/src/prediction/reconciler.ts, apps/client/src/prediction/predictor.ts, apps/client/src/prediction/input-dispatcher.ts, apps/client/src/net/colyseus-client.ts, packages/protocol/src/{intents,state,events,version}.ts, packages/game-logic/src/{step,sprite-state-machine,constants,accumulator}.ts, apps/client/src/render/SpriteStateMachine.ts, package.json files
- **CONTEXT.md** at `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md` — all D-01..D-16 decisions
- **SEED.md** at `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-SEED.md` — original problem statement

### Secondary (MEDIUM confidence)
- [Minecraft Wiki: Java Edition protocol/Packets](https://minecraft.wiki/w/Java_Edition_protocol/Packets) — Set Player Position, Set Player Position and Rotation, Synchronize Player Position packets (verified field layouts; byte estimates; clientbound vs serverbound)
- [Minecraft Wiki: Java Edition protocol/FAQ](https://minecraft.wiki/w/Java_Edition_protocol/FAQ) — protocol overview
- [Minecraft Wiki: Tick](https://minecraft.wiki/w/Tick) — "20 ticks per second" canonical rate; client position updates "on each tick"
- [Quake 3 Source Code Review: Network Model (Fabien Sanglard)](https://fabiensanglard.net/quake3/network.php) — client-prediction + server-reconcile + delta-compression mechanics (Quake lineage comparator)
- [Quake 3 Networking Primer (ra.is/unlagged)](https://www.ra.is/unlagged/network.html) — delta-compression and packet-loss recovery details
- [Wikipedia: Client-side prediction](https://en.wikipedia.org/wiki/Client-side_prediction) — sequence-number reconciliation pattern (Pattern 4)
- [Colyseus docs: State Synchronization](https://docs.colyseus.io/state) — property-level delta encoding; `setPatchRate` default 50ms
- [Colyseus docs: WebSocket (Default) transport](https://docs.colyseus.io/server/transport/ws) — TCP-based reliable + ordered delivery confirmation
- [Colyseus docs: Best Practices](https://docs.colyseus.io/state/best-practices) — delta-compression and binary-encoding overview

### Tertiary (LOW confidence — flagged for validation)
- No tertiary-only sources used. Every claim is supported either by codebase grep (HIGH) or by at least one MEDIUM source corroborated against another.

## Metadata

**Confidence breakdown:**
- **Standard stack:** HIGH — every version pinned in this session against `package.json` files; no new dependencies
- **Architecture:** HIGH — patterns derived from existing 06.4 D-58c set_facing precedent and verified Minecraft analog
- **Wire schema:** HIGH — int16 bounds + zod patterns mirror existing `cInputSchema` and `cSetFacingSchema` exactly
- **Pitfalls:** HIGH — direct from operator UAT logs (SEED), version-bump history (`version.ts` comments), Phaser tween idioms (in-file comments)
- **Broadcast-cadence recommendation:** MEDIUM — bandwidth math is back-of-envelope; will validate against Fly.io staging telemetry post-deploy
- **Server-side `step()` retention:** MEDIUM — recommendation is sound but A3 (platform-carry assumption) needs explicit handoff to Phase 7 planner

**Research date:** 2026-05-17
**Valid until:** 2026-06-17 (30 days — stable stack, no breaking version pins to track)
