# ADR 0007: Phase 6 client protocol amendments + prediction contract

**Date:** 2026-05-10
**Phase:** 06 (Plan 06-03)

[doc->REQ-CLI-02] [doc->REQ-CLI-04] [doc->REQ-CLI-09]

## Status

**Accepted** — locks D-07/D-08/D-09/D-10/D-11 from `06-CONTEXT.md` for the
Phase 6 client rebuild. Re-evaluation gate: Phase 7 PAR-04 (snapshot-replay
parity) and PAR-05 (multi-room movement) — both inherit this contract. Any
change to PROTOCOL_VERSION before Phase 7 redeploys requires the same
server-before-client deploy ritual described in **Decision** below.

## Context

Phase 4 shipped the authoritative server with a per-tick `c2s.input` shape
and a flat `PlayerState` schema (no broadcast-side input vector). When Phase
6 started writing the client (`apps/client`) on top of `@rebno/protocol@1`,
several gaps surfaced that could not be patched on the client alone:

1. **No remote-player extrapolation signal.** The Phase 4 `PlayerState`
   broadcasts `(x, y, vx, vy, last_input_seq)` but never the **input axes
   the server is currently holding**. A connected client therefore has to
   guess what direction another player is moving between snapshots; the
   visible result is a "stop-and-jump" gait when the discrete snapshot
   cadence doesn't line up with the render rate (60 Hz). Phase 4 D-22
   deliberately left this for Phase 6 to design.
2. **`c2s.input` was per-tick.** The Phase 4 shape required the client to
   send `{axis_x, axis_y, dt_ms, jump, action_btns}` every render tick
   regardless of whether the input changed. At 60 Hz that's 60 msg/s per
   player; at 50 CCU the room collectively pumps 3 000 msg/s through
   `cInputSchema.safeParse` even when nobody is moving. The token-bucket
   budget for `input` was 25/35 (D-22) just to absorb the no-op floor.
3. **`onJoin` hardcoded `(100, 100)` as the spawn coordinate.** The Phase 4
   D-09 layout schema introduced `spawn_points[]` with `kind` strings, but
   `RebnoRoom.onJoin` never read them. The MVP layout
   (`apps/server/rooms/mvp-lobby/000.json`) ships
   `{x: 500, y: 500, kind: 'default'}` — discarded at runtime.
4. **No "fresh session vs reconnect" distinction.** The Phase 4 SRV-06
   reconnect grace preserved `sessionId` across the 10 s window, but the
   onJoin code path was identical for fresh joins and grace rejoins —
   meaning a reconnect within the window snapped the avatar back to
   `(100, 100)` instead of preserving the disconnect-time position. The
   `characters` row in SQLite (Phase 4 SRV-08) carried the long-lived
   position but onJoin never consulted it for fresh sessions either.

`06-RESEARCH.md` §"Critical ordering constraint" calls out the wire-shape
risk: `@colyseus/schema` 4.x is not forward-compatible with `PlayerState`
field additions. A v1 client that decodes a v2 PlayerState binary patch
crashes the schema decoder mid-frame, so the only safe rollout is the
**coordinated server-before-client redeploy**: bump `PROTOCOL_VERSION`,
deploy the server, force every connected v1 client off via the 4400 gate,
then ship the v2 client bundle. Phase 4 D-03 already established the
PROTOCOL_VERSION enforcement at `RebnoRoom.onAuth`; Plan 06-03 wires the
Phase 6 contract into that gate.

## Decision

**1. PROTOCOL_VERSION 1 → 2.** `packages/protocol/src/version.ts` exports
`PROTOCOL_VERSION = 2`. The number is uint16 numeric (NOT the string
SemVer of `tools/protocol-doc/output/protocol.ts`). `validateAuthFrame`
throws `ServerError(4400, 'PROTOCOL_VERSION_MISMATCH expected=2 got=N')`
on every other value. Coverage:
`apps/server/test/protocol-v2-handshake.integ.test.ts`.

**2. PlayerState gains `axis_x_held` + `axis_y_held` (D-08).** Two
`@type('number')` fields encoding the last-known axis vector the server
is integrating for that player. Tick loop reads `RebnoRoom.heldInputs`
each step and writes the values into the Schema row. Connected clients
extrapolate remote-player position between snapshots using these as the
direction signal; the apps/client extrapolation module caps forward
projection at **250 ms** (preventing the visible "rubber band" when a
remote player's connection drops).

**3. `c2s.input` switches to event-driven (D-09).** New zod shape:

```ts
{
  type: 'input',
  seq: number,                         // monotonic per client
  axes: { x: -1|0|1, y: -1|0|1 },      // current pressed axis vector
  buttons_down: uint16,                // bitmask of edges pressed
  buttons_up:   uint16,                // bitmask of edges released
  monotonic_at_ms: number              // client monotonic clock
}
```

Sent on **keydown / keyup transitions only** plus a 15 s heartbeat-coupled
"still pressed" reaffirmation that defends against dropped keyup packets
(legacy gripe: T-FCRD-3 in PITFALLS — a half-press over a flaky wifi
strands the avatar mid-stride). Server stores into a per-account
`heldInputs` map; the tick loop reads from `heldInputs` each step and
**never clears on consume** so a single keydown event integrates over
many ticks until a follow-up event replaces it. `.strict()` is retained
verbatim — Phase 4 D-04 forged-identity rejection carries forward.

**4. `RebnoRoom.onJoin` spawns at the home portal on a fresh session (D-07).**
Order of resolution:

1. Layout's `spawn_points[kind === 'default']` (canonical).
2. `spawn_points[0]` (warn-on-fall-through).
3. `FALLBACK_HOME_PORTAL = (100, 100)` (warn-on-registry-miss; preserves
   playability if room hot-reload hasn't loaded yet).

A grace-window reconnect (existing PlayerState row at the joining
sessionId) preserves the disconnect-time `(x, y, vx, vy, last_input_seq)`.
This requires zero changes to the Phase 4 SRV-06 reconnect grace path
(`Room.allowReconnection(client, 10)`); we just stop overwriting the
preserved row. Coverage: `home-portal-spawn.integ.test.ts`.

**5. Threshold-gated reconciliation (D-10).** Client-side prediction
re-applies any pending unacked inputs on top of every server snapshot.
The reconciler compares the predicted local position against the server
authoritative position; if the divergence exceeds **22 px** (an
empirically tuned tolerance derived from the 50 ms tick interval and the
typical movement speed in `@rebno/game-logic.step()`), the client
performs a **100 ms linear interpolation** to the authoritative position
rather than a hard snap. Below the threshold the local prediction is
trusted as-is. The 22 / 100 numbers are the contract Phase 7 inherits;
they are tuned against `@rebno/game-logic.step()` constants, not the
client renderer's frame budget.

**6. Snapshot interpolation (D-11).** Remote players are rendered with a
**~100 ms backbuffer** (the apps/client interpolation module holds the
two most-recent snapshots and renders linearly between them). When the
backbuffer underflows (network blip), the client falls back to the
**`step()`-driven extrapolation** path with the 250 ms cap from D-08.
This combines snapshot-interpolation (smooth) with prediction-style
extrapolation (resilient) without ever showing a snap-back when a
delayed snapshot arrives in the middle of an extrapolation window.

**7. Rate-limit budget reduction.** `apps/server/src/rate-limit.ts`
`RATES.input` drops from `{rate: 25, burst: 35}` to `{rate: 10, burst: 15}`.
The legitimate event-driven traffic envelope is ~3-5 msg/s; 10/15 leaves
plenty of headroom for stop-start spamming while shrinking the
sustained-flood ceiling that an attacker can push through `safeParse`.
The `tools/scripts/lint-rate-limit-budgets.mjs` drift guard is updated
in lock-step (and so is `apps/server/test/rate-limit.test.ts`).

**Deploy ritual (non-negotiable).** Per Phase 4 D-03 and 06-RESEARCH
§"Critical ordering constraint":

1. Bump `PROTOCOL_VERSION` in `packages/protocol/src/version.ts`.
2. Build + redeploy `@rebno/server` to staging.
3. Verify `protocol-v2-handshake.integ.test.ts` against the staged
   server (v1 clients receive 4400; v2 succeed).
4. Build + redeploy `apps/client` v2 bundle.
5. Phase 6 plan 06-09 verify-phase-6 gate runs server tests + client e2e
   against the deployed bundle as the final sequencing check.

## Consequences

- **Phase 7 PAR-04 / PAR-05 inherit this contract.** Multi-room expansion
  (PAR-03) and full-parity snapshot replay (PAR-04) both consume
  `axis_x_held` / `axis_y_held` for the same extrapolation purpose, and
  both rely on the home-portal-on-new-session semantics for room
  transitions. Any change to the prediction thresholds (22 px / 100 ms)
  or the extrapolation cap (250 ms) requires a new ADR superseding this
  one.
- **`@colyseus/schema` adding fields is wire-incompatible with v1 clients.**
  The PROTOCOL_VERSION 4400 gate is structural mitigation; a v1 client
  literally cannot decode a v2 PlayerState patch (RESEARCH §A5). The
  4400 close happens at `onAuth` BEFORE the room sends any state diff,
  so the schema decoder never sees a v2 payload.
- **Reduced rate-limit envelope.** Operators MUST be aware that the
  `input` budget is now 10/15. If a legitimate input pattern emerges
  that exceeds this (e.g., a fighting-game-style rapid combo system in
  Phase 7), the budget is per-`(account, msg_type)` so isolated raises
  don't compromise the global anti-flood guarantee.
- **Heldinputs is a new abuse surface.** A flooding client that toggles
  axes every 100 ms does NOT escape via the rate-limit (10/s rate) but
  still mutates `heldInputs` on every legal frame. The Phase 6 threat
  register (06-03 plan) catalogs this as T-06-03-04 with mitigation
  "rate_limit_or_drop runs BEFORE zod.parse". Stale held axes from a
  disconnected client are cleared by `onLeave` (T-06-03-03).
- **The mvp-lobby home portal is now a config surface.** Editing
  `apps/server/rooms/mvp-lobby/000.json` `spawn_points` re-signs via the
  Phase 4 D-11 manifest pipeline and propagates via `RoomRegistry`
  hot-reload. No server restart needed for spawn-coord changes.

## References

- `06-CONTEXT.md` D-07/D-08/D-09/D-10/D-11
- `06-RESEARCH.md` §"Critical ordering constraint", §A5, §Open Q5
- `06-PATTERNS.md` lines 766-862 (RebnoRoom + intents.ts patches)
- `04-CONTEXT.md` D-03 (PROTOCOL_VERSION enforcement at onAuth)
- `04-CONTEXT.md` D-04 (.strict() forged-identity rejection — carried forward)
- ADR 0001 (client engine choice — Phaser 3 retains the rendering surface)
- ADR 0004 (room hot-reload — `spawn_points` are part of the signed layout)
