Files
CosmicClash/MULTIPLAYER_SPEC.md
Josh Creek b43ad207c1 docs(multiplayer): split spec into MULTIPLAYER_SPEC.md, trim task doc to outstanding work
multiplayer-next.md was a 1662-line mix of standing architecture spec
and task-completion tracking, most of which was dense per-task DONE
evidence for finished Phases 0-6. Split it:

- MULTIPLAYER_SPEC.md (new): the locked architecture decisions, wire
  format, server-side input handling, prediction/reconciliation,
  latency/frame-rate budget, and match lifecycle state machine -
  standing design reference, not task-tracked.
- multiplayer-next.md (trimmed 1662 -> ~370 lines): only outstanding
  work remains - §0 status, §7 Phase 7/8 task tables condensed to
  "what's left" per task, §8-11 reference material (refactoring notes,
  gotchas, testing, flagged items). Phases 0-6 collapsed to a pointer
  at git history instead of ~500 lines of DONE evidence.

Also:
- Repointed every `multiplayer-next.md §N` code comment (N 1-6) across
  Game/scripts, Game/tools and Game/tests to MULTIPLAYER_SPEC.md, since
  those sections moved. Task-number references (`task N.N`, §7-11)
  correctly still point at multiplayer-next.md.
- Updated CLAUDE.md's doc index and docs/TECH_STACK.md's spec-section
  citations to match.
- TODO.md: added a "what's left to actually finish multiplayer
  (human-actionable)" checklist pulled from multiplayer-next.md §0 and
  docs/MATCHMAKING.md - things that need a person (hardware, a design
  decision, a Steam App ID, hands on a controller), not more agent code.
2026-09-04 22:43:13 +01:00

583 lines
48 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Online multiplayer — architecture and wire-format spec
The standing design reference for the online multiplayer effort: locked
architecture decisions, the wire format, server-side input handling,
prediction/reconciliation, the latency/frame-rate budget, and the match
lifecycle state machine. This describes *how the system works* — it is not
task-tracked and does not distinguish implemented from not-yet-implemented;
for that, and for the outstanding task list, see
[`multiplayer-next.md`](multiplayer-next.md), which cites sections here by
number (`§2.4`, `§4.1`, …) and assumes them as background before picking up
Phase 2 or later work.
---
## 1. Architecture decisions
### 1.1 Locked decisions
| # | Decision | Why |
|---|---|---|
| 1 | **Server-authoritative simulation, with client-side prediction of the local ship and ball. No world rollback / resimulation.** | Jolt is not bit-deterministic across platforms or across differing contact orderings, and Godot exposes no world snapshot/restore API. Rollback netcode would be a research project. |
| 2 | **Dedicated servers only.** Headless Godot export; the server is never a player. | Fair for every player, no host advantage. Self-hostable community servers first, so nothing is blocked on paid infrastructure. |
| 3 | **ENet first**, GodotSteam later, behind a boundary. | ENet works in-editor, headless, on LAN, and in CI with no Steam client. Direct-IP connect stays permanently supported and **must never become the degraded path**. |
| 4 | **Community discovery uses no custom backend; superseded for queued play by Phase 8.** | Steam's server APIs remain enough for the community browser. Casual/ranked queues, durable ratings, allocation and authoritative results require the project-owned Go control plane specified in `docs/MATCHMAKING.md`; it does not replace the browser or direct-IP path. |
### 1.2 Rejected alternatives
- **Peer-authoritative ships** (each client owns its own transform). Easiest to build, feels perfect locally, and is trivially cheatable — it directly contradicts `README.md`'s stated anti-cheat position. Ship-vs-ship collisions also become ambiguous with no arbiter.
- **Deterministic lockstep / rollback.** See decision 1.
- **`MultiplayerSynchronizer` / `MultiplayerSpawner`.** The decisive objection is not bandwidth. It is that `last_processed_input_seq` **must** arrive in the same packet as the state it describes, or reconciliation is off by a snapshot — and a synchroniser gives you nowhere to put it. It also writes replicated properties directly onto the node, which is exactly wrong for a `RigidBody3D` under prediction: incoming state has to enter a compare-against-history pipeline, not be stamped onto `global_transform`. You would end up building the correction pipeline anyway, with the synchroniser as pure overhead. Secondary objections: one packet per body (7 bodies × ~50 B of UDP/IP/ENet framing versus one coalesced snapshot), no per-field quantisation, and no client-side interpolation.
`MultiplayerSpawner` is unnecessary for a separate reason: the roster is fixed at match start and fully described by the `match_config` message, and **no ship is ever despawned** (§6.4).
- **Seeded RNG for kickoff jitter.** Shared-seed determinism requires both sides to consume the RNG stream in exactly the same order forever. The first `randf()` anyone later adds anywhere in the reset path — a spawn VFX variation, a cosmetic, a commentary line — silently desyncs kickoff positions with no error message. The server broadcasts the resulting transforms instead: 336 bytes, once per kickoff, cannot rot.
### 1.3 Derived decisions
**All hot-path RPCs live on autoloads.** `/root/NetworkManager` and `/root/MatchNet` exist at identical paths on every peer regardless of which scene is loaded, which side is headless, or whether a client is mid-scene-transition. This deletes the entire "NodePaths must match across peers" class of bugs, kills a family of late-join races where an RPC arrives before its target node exists, and warms Godot's RPC path cache once at connect so it never re-sends a full path on scene change.
**Entities are addressed by integer slot, never by path.** The snapshot is `[slot 0..N-1]` in a fixed order established by `match_config`. `MatchNet` holds an `Array[Node] _slots` populated at spawn.
**One server process hosts exactly one match.** This is forced, not chosen: `ship.gd:162` resolves the arena boundary via `get_tree().get_first_node_in_group("arena_boundary")` and `ai_ship_controller.gd` discovers its roster via `get_tree().get_nodes_in_group("ship")`. Both are tree-global, so two matches in one scene tree would cross-wire instantly. It is recorded here because it determines the RAM figure in §1.4.
### 1.4 Server sizing — bandwidth and CPU are not the constraint
Worth establishing up front, because §2 and §3 repeatedly trade bandwidth for latency and somebody will eventually want to trade back.
`ArenaBoundary.bake_colliders()` generates roughly 168 box colliders (corner fillets, base wrap, ceiling, end walls) plus the scene's own slabs, 2 goal backstops, 2 `Area3D` sensors, and 7 dynamic bodies (the ball with `continuous_cd`). Estimated per-tick cost:
| Component | ms/tick |
|---|---:|
| Jolt step | 0.15 0.4 |
| Godot headless main loop | 0.1 0.3 |
| Bot inference, amortised | ~0.3 |
| **Total, of a 16.7 ms budget** | **0.6 1.1** |
**~610 concurrent matches per modern core**, ~150250 MB RSS per process. 100 concurrent matches ≈ 1216 cores and ~20 GB — a single mid-tier VPS. Upstream bandwidth for a full 6-player match is ~630 kbit/s (§2.4).
**Neither CPU nor bandwidth is scarce. Latency is.** Optimise accordingly.
---
## 2. Wire format
Two peers must agree byte-for-byte, so this is specified rather than sketched.
### 2.1 Channels
| Channel | Transfer mode | Contents |
|---|---|---|
| 0 | reliable | handshake, `match_config`, kickoff, goal, clock, state changes, chat, admin |
| 1 | unreliable-ordered | client → server input |
| 2 | unreliable-ordered | server → client snapshots |
Unreliable-**ordered** (ENet sequenced-unreliable, drops stale) rather than plain unreliable for both hot paths: we carry explicit sequence numbers, and a reordered late packet is worthless work. Separating them stops a large reliable `match_config` from head-of-line-blocking state on a lossy link.
**Set `ENetMultiplayerPeer.server_relay = false`.** It defaults to `true`, which lets any client `rpc()` any other client *through your server*. With it off, clients can only talk to peer 1. Single highest-value one-line security change in this document.
### 2.2 Packet header
**Every hot-path packet opens with a 1-byte type + version.** A capture then decodes standalone, and a mismatched build fails loudly instead of decoding garbage straight into `state.transform`.
Hot paths carry a single `PackedByteArray` RPC argument (≈14 B of Godot RPC framing once the path cache is warm). Control messages on channel 0 use normal typed arguments — they are rare and readability beats bytes.
### 2.3 Input packet — client → server, channel 1, 60 Hz
```
u8 type_version
u32 seq server-tick-space sequence of the NEWEST action
u8 count 1..4 (MAX_REDUNDANCY)
u32 ack_snapshot_tick newest snapshot tick this client has processed
u16 client_send_ms wrapping ms clock, echoed back for RTT
--- repeated `count` times, newest first ---
i8 thrust_x, thrust_y, thrust_z value = clamp(round(v*127), -127, 127)
i8 rot_x, rot_y, rot_z
u8 flags bit0 = turbo
```
**12 + 7×4 = 40 B payload**, ~90 B on the wire with UDP/IP/ENet framing → **~43 kbit/s up per client**.
- **Redundancy 4** is what makes an unreliable input channel safe: starvation requires four consecutive losses (~66 ms).
- **`i8` per axis, not 3-bit bins.** Bins matching `ShipActionCodec.HEADS` would cut an action to 3 bytes, but they permanently foreclose analog gamepad sticks, which this game will want. `round(v*127)/127` round-trips `-1/0/+1` exactly, so today's digital input (`player_ship_controller.gd` is `is_action_pressed`-only) is lossless.
- **The encoding is itself a validator.** `i8/127` cannot express NaN, Inf, or a value outside `[-1.008, 1.008]`. Half of "sanitise untrusted client input" is solved by not using Variant encoding.
### 2.4 Snapshot — server → client, channel 2, 60 Hz default
Per-client header built per peer; body buffer built once per tick and reused across peers.
```
--- per-client header (7 B) ---
u32 last_input_seq newest input from THIS client the server has applied
i8 input_buffer_depth jitter-buffer occupancy; negative = starved
u16 echo_client_send_ms from that input packet, for RTT
--- shared body header (8 B) ---
u8 type_version
u32 server_tick Engine.get_physics_frames() on the server
u8 match_state see §6.1
u8 reset_gen increments on every authoritative teleport
u8 body_count
--- repeated body_count times, slot order fixed by match_config (22 B each) ---
i16 pos_x, pos_y, pos_z range ±64 m -> 1.95 mm
i16 quat_x, quat_y, quat_z w = ±sqrt(1-x²-y²-z²), sign in flags
i16 vel_x, vel_y, vel_z range ±64 m/s -> 1.95 mm/s
i8 avel_x, avel_y, avel_z ships ±4 rad/s; ball ±32 rad/s
u8 flags bit0 frozen, bit1 turbo, bits2-4 thrust_z bin,
bit5 stalled, bit6 quat_w sign
```
7 bodies → **8 + 7 + 7×22 = 169 B payload**, ~219 B on the wire.
| | per client down | server up, 6 clients | + 10 spectators |
|---|---:|---:|---:|
| 60 Hz | 105 kbit/s | 631 kbit/s | 1.68 Mbit/s |
MTU headroom is ~6× (ENet fragments above ~1400 B); a hypothetical 10v10 at 21 bodies is 477 B and still fits. **This format does not need delta compression.**
**Plain `i16` quaternion components, not smallest-three.** Smallest-three saves 4 B/body and is the textbook answer. It is also exactly where a hand-rolled codec goes subtly wrong — off-by-one in the 2-bit index, sign of the dropped component, renormalisation drift. Three `i16`s plus a sign bit give ~3e-5 rad with no bit-shifting, for 2 B/body (≈3 kbit/s). Take the bytes.
**Quantisation ranges derive from constants, not from prose.** `ArenaBoundary.INNER_HALF_X = 18.0`, `INNER_HALF_Z = 27.0`, `INNER_HEIGHT = 18.0` (`arena_boundary.gd:8-10`) plus `GameMode.ESCAPE_MARGIN = 15.0`; `Ship.max_speed = 35.0` (`ship.gd:16`); `Ball.MAX_SPEED = 32.0` (`ball.gd:17`).
**The flags byte must carry `turbo` and a 3-bit `thrust_z` bin.** `_integrate_forces` is not called on frozen bodies, so remote ships on a client never pull `get_action()`, and `Ship._update_movement_vfx()` (`ship.gd:293`) reads `_current_action.thrust.z` and `turbo`. Without those bits, every remote ship flies with dead engines.
### 2.5 Reliable control messages, channel 0
`hello` · `welcome` · `player_joined` · `player_left` · `ready_state` · `match_config` · `scene_ready` · `kickoff` · `state_change` · `goal_scored` · `clock_state` · `match_ended` · `chat` · `server_shutdown`.
---
## 3. Server-side input handling
Per-player server state:
```gdscript
class PlayerSlot:
var peer_id: int
var slot: int # snapshot index
var ring: Array[ShipAction] # FIXED 32 entries, indexed seq % 32
var ring_seq: PackedInt32Array # 32 entries, seq stored at each index (-1 = empty)
var last_applied_seq: int
var last_action: ShipAction
var starved_ticks: int
var packets_this_second: int
var remote_controller: RLShipController
```
### 3.1 Ingestion
`@rpc("any_peer", "unreliable_ordered", channel = 1)`, in order:
1. `multiplayer.get_remote_sender_id()` → look up slot. Unknown sender → drop and count.
2. **Rate limit.** `packets_this_second > 110` (60 Hz × 1.5 + 20) → drop. Three consecutive seconds over budget → disconnect with `RATE_LIMIT`. Same for a byte budget.
3. **Framing.** `count > 4` or `payload_size != 12 + count*7` → drop, count malformed. 20 malformed → disconnect.
4. **Sequence range.** `seq > server_tick + 20` → drop. (Not 120: `input_lead` is clamped to 12, so anything above ~20 is broken or hostile.) This is why the ring is fixed-size and indexed `seq % 32`**a client can never make the server allocate.**
5. For each action, newest first at descending seq: `seq <= last_applied_seq` → discard (already consumed); else write `ring[seq % 32]`.
6. **Decode with per-axis clamp only:**
```gdscript
action.thrust = Vector3(b[0]/127.0, b[1]/127.0, b[2]/127.0).clampf(-1.0, 1.0)
```
> **Never normalise the thrust vector.** A player holding W+A+E legitimately produces `thrust = (1,1,1)`, length 1.73, and each axis uses a different power constant — `thrust_power 150`, `maneuvering_thrust 75`, `vertical_thrust 120` (`ship.gd:12-14`). Normalising would silently change the flight model for honest players. Per-axis clamp combined with the `i8` encoding is complete validation: the reachable value space is exactly what a legitimate client can produce.
### 3.2 Consumption — once per server physics tick, before the step
```
expected = last_applied_seq + 1
if ring holds expected:
action = ring[expected % 32]; starved_ticks = 0
else:
action = last_action # REPEAT — do not zero
starved_ticks += 1
if starved_ticks > 30: # 500 ms
action = ZERO_ACTION; flags.stalled = true
last_applied_seq = expected
last_action = action
remote_controller.action = action
```
**Repeat-last, not zero.** Player inputs are heavily autocorrelated at 60 Hz — the odds that a held thrust was released on exactly the dropped tick are low, and the client predicted with the real input either way, so repeating minimises expected divergence. It is also consistent with `AIShipController`, which already holds its action between decisions. Zeroing after 500 ms stops a disconnecting player's ship flying into a wall at full throttle forever.
### 3.3 Jitter buffer — one control loop, not three
An earlier draft had the server adapting `target_depth`, the server fast-forward-dropping queued actions, **and** the client slewing `input_lead`. Three integrators acting on one plant (buffer occupancy) with different time constants is a textbook oscillation; on a jittery link it hunts, and it presents to the player as intermittent sticky controls that are nearly impossible to attribute.
**The server reports `input_buffer_depth` in every snapshot and does nothing else adaptive. The client owns `input_lead` exclusively.**
- `target_depth = 1` (16.7 ms), not 2. With redundancy-4 you have already bought the insurance depth 2 provides; depth 2 is 16.7 ms of pure input latency for nothing.
- Client `input_lead` clamp `[1, 12]`, **fast attack / slow release**: on any starve, increase by up to 3 **immediately**; decrease by 1 per 60 ticks only after 2 s of clean surplus. A symmetric ±1-per-500 ms slew takes two seconds to absorb a wifi spike, during which the player steers and the ship does not turn — the most rage-inducing failure mode in any netcode.
- Changing `input_lead` means skipping or duplicating one tick's sequence number. Never change it more than once per 30 ticks.
**Enforce `input_lead` server-side from observed arrival times.** A client that fakes starvation to drive `input_lead` to 1 gets its inputs applied with less server-side buffering than honest players — a small but real responsiveness edge. The `i8` encoding does nothing about this; only observing actual arrival timing does.
---
## 4. Prediction and reconciliation
### 4.1 Two clocks for remote entities — the load-bearing correction
The obvious design runs remote ships and the ball as frozen kinematic proxies at `server_time_est - INTERP_DELAY` while predicting the local ship to *now*. **That is wrong**, and it is wrong in a way that only shows up over real latency:
- Two ships closing at 50 m/s put the opponent's collider **3.5 m** from truth. The hull is a `BoxShape3D` of `(1.6, 0.6, 4)` (`ship.tscn:12`) — that is most of a ship length of positional lie.
- A fast ball is **2.2 m** off against a 0.5 m radius — four ball diameters.
- `ship.tscn:16` has `collision_mask = 7`: ships collide with ships, the ball, and the arena. Ship-vs-ship contact is *constant* in vehicle soccer, not incidental.
So prediction would not diverge occasionally due to timing noise. It would diverge **deterministically and in the same direction on essentially every contact**, and the hard-snap threshold would become the steady state rather than a backstop.
**Fix: separate the collider clock from the render clock.**
| | runs at | why |
|---|---|---|
| remote body **collider** | `server_time_est`, extrapolated forward from the newest snapshot by ~one-way + half a snapshot interval | Extrapolation error over ~45 ms at real accelerations (`thrust_power 150 / mass 5` = 30 m/s², 75 m/s² on turbo — `ship.gd:12,15`, `ship.tscn:17`) is ~0.030.08 m. Two orders of magnitude better than 3.5 m. |
| remote **`$Visual`** | `server_time_est - INTERP_DELAY` | Smooth, jitter-free rendering. |
This is the same trick applied to the local ship, pointed the other way. It costs one extra transform write per remote body per tick.
### 4.2 Where each piece lives
| Concern | Location |
|---|---|
| sample + send input | `LocalNetShipController._physics_process` — runs before the physics step, guarantees exactly one sample/tick |
| record predicted state | same, at top of tick N (state = result of N1) |
| apply velocity / teleport correction | `Ship._integrate_forces`, ~15 guarded lines — the only Jolt-safe place to write `state.transform` / `state.linear_velocity` |
| visual smoothing | `Ship/$Visual.global_transform`, set in `_physics_process` |
| snap-vs-blend decision | `net_ship_predictor.gd` (child node) |
| remote bodies | `net_interpolator.gd` |
### 4.3 Per-tick, own ship
1. `predicted[current_tick - 1] = {transform, linear_velocity, angular_velocity}` — ring of 128.
2. `var a := _player.get_action().copy()` — **must copy.** `player_ship_controller.gd` reuses a single `ShipAction` across ticks (its own header warns about this); buffering it aliases every history entry to the same object.
3. `_action = a`, returned by `get_action()` this tick so `Ship._integrate_forces` samples input exactly once.
4. `input_history[seq] = a`, `seq = predicted_server_tick + input_lead`.
5. Build and send the packet with the last 4 entries.
`Ship._integrate_forces` then runs completely unchanged.
### 4.4 On snapshot arrival
```
A = last_input_seq
if reset_gen changed OR predicted[A] missing OR flags.frozen != local frozen:
HARD SNAP
else if pos_err > 2.0 m OR rot_err > 60°:
HARD SNAP
else:
SOFT CORRECT
```
Comparing server state at tick `A` against **`predicted[A]`** — the client's own state at that same tick — makes the delta latency-free by construction. That is the entire reason for keeping the prediction ring, and it is why this works acceptably without resimulation: **never blend current state toward stale state.**
**SOFT CORRECT**
- **Velocity: applied in full, immediately.** `net_vel_correction += (srv.linvel - predicted[A].linvel)`, consumed once in `_integrate_forces`. Velocity error is invisible to the player but is the *cause* of future position error; blending it just prolongs divergence.
- **Position/rotation: physics moves in full, rendering does not.** Queue the body teleport, and simultaneously offset `$Visual` by the negation. Net visual movement at the instant of correction: zero. The body is where the server says; the rendered ship catches up.
- **Decay** each physics tick, reusing the existing convention at `ship.gd:450`:
```gdscript
var k := _tick_scaled(0.88, delta) # 63% gone in ~130 ms, 95% in ~280 ms
```
- **`MAX_VISUAL_OFFSET = 0.4 m`**, not 2.0. The hull is 4 m long; a 2 m offset means being rendered half a ship-length from your own collider for ~280 ms, so you clip walls you visibly cleared — a felt bug in a game built around wall-riding. Beyond 0.4 m, show the correction. A visible correction is honest; an invisible 2 m lie is not.
**HARD CORRECT**
- Apply the same sequence-matched authoritative pose and velocity delta to the current local body, reset body and `$Visual` interpolation, and clear the visual offset. It is physically the same correction as soft correction; only its presentation differs.
**Delta transport, not one-body replay.** For every matched snapshot, overwrite `predicted[A]` with authority, transport its pose and linear/angular-velocity delta through each retained state `A+1..current`, and apply that same delta once to the live local Jolt body. This keeps retained history coherent, so a later snapshot does not correct an already-corrected pre-delta trajectory a second time.
Do **not** analytically replay stored actions. That approximation cannot reproduce Jolt integration or contact manifolds (friction, restitution, walls, ships, and ball), therefore it becomes least trustworthy exactly where reconciliation is most noticeable. This is still neither whole-world rollback nor a change to server physics: it is client-only state transport around a server-authoritative simulation.
For reset generation changes, place exact authority, begin a new history epoch, and do not consume pre-reset actions. For missing or overflowed history, place authority once and suppress stale acknowledgements until a new matched sequence is recorded; never manufacture future history by filling it with one stale authority state.
> Same-sequence **pre-correction** residual remains diagnostic telemetry. With a server input jitter buffer, it is not by itself a presentation-quality gate: the server may have integrated an action at a different physical instant from the client. Acceptance must report it separately by free-flight/contact/reset/resync cohort, while gating post-correction/presentation error and hard-snap behaviour.
>
> That the two sides integrate the **same action** for a given sequence is a separate claim, and a checkable one. Keep the two apart: "right action, different instant" is expected here; "wrong action" is a bug, and was one (`multiplayer-next.md` §9 gotcha 47).
### 4.5 Camera and visuals
**The camera must follow `$Visual`, not the body.** `ship_camera.gd:115`, `:149`, `:150` read `target.global_transform` directly. Left as-is, every soft correct makes the *camera* jump the full error while the *mesh* smoothly lags — strictly worse than snapping, because the world lurches around a player whose ship slides inside the frame.
**And it must read `$Visual.get_global_transform_interpolated()` from `_process`, not `global_transform` from `_physics_process`** (rationale in §5.4). `Node3D.get_global_transform_interpolated()` exists precisely for a camera tracking a physics-interpolated body; `global_transform` returns the last physics tick's pose, so a `_process` camera reading it would chase a 60 Hz staircase at 240 fps.
> **Ordering hazard**, straight from the engine docs: `get_global_transform_interpolated()` "creates an interpolation pump on the `Node3D` the first time it is called, which can respond to physics interpolation resets… be sure to call it at least once before resetting the `Node3D` physics interpolation." Every hard snap calls `reset_physics_interpolation()` on `$Visual`. **Prime the pump when the camera's `target` is assigned**, not lazily on the first frame, or the first snap of the match streaks the camera.
`project.godot` has `physics_interpolation=true`, and `$Visual`'s own local transform is interpolated too — so `reset_physics_interpolation()` must be called on `$Visual` as well as the body, or every snap smears the mesh for a frame.
### 4.6 Remote bodies on the client
- `freeze = true`, `freeze_mode = FREEZE_MODE_KINEMATIC` — **not `STATIC`**, or Jolt cannot derive contact velocity from the per-tick transform delta and your predicted ship hits a static wall instead of a moving ship.
- `net_interpolator.gd` samples the snapshot buffer (last 8 per body); collider at `server_time_est` (§4.1), `$Visual` at `server_time_est - INTERP_DELAY`.
- **The two samples run on different clocks *and* different callbacks.** The collider is a physics concern: `_physics_process`, 60 Hz. `$Visual` is a render concern: `_process`, sampled at true render time with `physics_interpolation_mode = OFF` so Godot does not interpolate an already-per-frame transform. On a 240 Hz client this is 240 distinct remote-ship positions per second instead of 60, and one fewer tick of lag, for no extra cost — the buffer lerp is happening either way (§5.4).
- `INTERP_DELAY = one_way_ms + snapshot_interval * 1.5 + 2.5 * jitter_ewma`, clamped `[25, 200] ms`. At 60 ms RTT / 60 Hz / 5 ms jitter that is 30 + 25 + 12.5 ≈ **68 ms**.
> **The `one_way_ms` term is not optional, and omitting it is a silent architectural failure.** `server_time_est` (§4.7) estimates what the server clock reads *right now*. The newest snapshot in hand was stamped `one_way` ago. So rendering `$Visual` at `server_time_est - INTERP_DELAY` only interpolates if `INTERP_DELAY ≥ one_way`. Set it to the buffer alone (~38 ms at 60 Hz) and the render cursor lands *on or past* the newest sample: every remote entity is permanently dead-reckoned. **The 25 ms clamp floor is reachable on LAN only.**
- Past the newest snapshot, extrapolate on last known velocity for at most 150 ms, then hold. **Never extrapolate indefinitely** — a stuck ship reads better than one flying through a wall.
- **Never write `linear_velocity` to a frozen body.** Godot/Jolt zeroes and holds velocity on frozen bodies, so `ball.gd:35`'s `linear_velocity.length()` trail driver will not work that way. `Ball.set_visual_speed(speed)` mirrors the `Ship.set_visual_action(thrust_z, turbo)` pattern. Don't route presentation data through a property the physics server owns.
- Call `reset_physics_interpolation()` on remote bodies at every kickoff.
### 4.7 Clock
`server_time_est = local_ms + clock_offset`, `clock_offset` from ping/pong on channel 0 every 1 s using the **minimum-RTT sample in a rolling 5 s window** (the min-RTT sample has the least queueing error).
**Freeze `tick_offset` at match start.** Seed it exactly from the handshake (`server_tick + round(one_way / tick_ms)`) and absorb all subsequent drift into `input_lead` alone. The prediction ring is indexed in server-tick space, so slewing `tick_offset` during play silently reinterprets every historical entry and produces sporadic, unreproducible false snaps. Re-seed only across a kickoff boundary.
---
## 5. Latency and frame-rate budget
Three of the largest terms are invisible to a netcode document that only counts network hops. Record the budget so future changes are argued against a number.
Client at 60 Hz physics, 60 ms RTT, 5 ms jitter, 60 Hz snapshots. **Display at 60 Hz with vsync on** — the Godot default, and the worst case. §5.4 redoes the display-dependent rows for 120/144/165/240/360 Hz.
### 5.1 Own ship (predicted) — input to pixel
| Stage | ms | | scales with fps? |
|---|---:|---|---|
| OS input → `Input.is_action_pressed` | 10 | 0.5 × frame interval + device polling | partly |
| wait for next physics tick | 8 | avg of 016.7 | no — 60 Hz physics |
| physics step applies force | 0 | | |
| Godot physics interpolation | 8 | mean, worst case 16.7 | no — 60 Hz physics |
| render + vsync present | 25 | 1.5 refresh intervals, vsync defaults on | yes |
| **Total** | **≈52** | | |
This is the **existing single-player floor**, unchanged by netcode. A low-latency present would take it to ~35 ms (§5.4). Note: **16 of the 52 ms do not move no matter how many frames the client draws.** That is the price of a 60 Hz simulation.
### 5.2 World response — the number that decides whether this ships
| Stage | ms | |
|---|---:|---|
| input freshness | 10 | 0.5 × frame interval + ~2 ms device polling |
| wait for next physics tick | 8 | |
| manual multiplayer flush | ~0 | |
| client → server transit | 30 | RTT/2 |
| jitter buffer, `target_depth = 1` | 17 | |
| server tick + flush | 8 | |
| **server → client transit** | **30** | RTT/2 — the return leg |
| interpolation buffer beyond arrival | 38 | `interval × 1.5 + 2.5 × jitter`; the `one_way` half of `INTERP_DELAY` is the row above |
| client physics interpolation | 8 | |
| render + present | 25 | vsync on, 60 Hz display |
| **World response, opponents** | **≈174** | |
| **Ball, with local prediction** | **≈52** | same as own ship |
| Both, at 144 Hz + low-latency present | **148 / 26** | §5.4 |
For reference, Rocket League runs 120 Hz physics and predicts both car and ball locally; its equivalent at 60 ms RTT is roughly 90110 ms.
**≈174 ms as designed here is not competitive, and this document should not pretend otherwise.** §5.6 gets to ≈127 ms with two changes that touch no graphics setting and require no bot retrain, and to ≈103 ms with 120 Hz simulation — inside the reference band. Read §5.6 before treating this table as a verdict.
What *is* settled is the shape of the design: a locally-predicted ball and own ship at ≈52 ms is the difference between this being playable and not, and a 30 Hz / default-poll / interpolated-ball design would land near ≈250.
### 5.3 Why 60 Hz snapshots, not 30
- Interpolation buffer: the `interval × 1.5` term is **50 ms at 30 Hz vs 25 at 60**, on top of the one-way term both share (§4.6), plus a half-interval of cadence quantisation.
- Interpolation fidelity: at `MAX_SPEED = 32` the ball moves **1.07 m between samples at 30 Hz** — more than its own diameter, so any wall bounce landing between two samples gets lerped as a straight line *through the wall*. At 60 Hz it is 0.53 m.
- Cost: 300 kbit/s. Per §1.4, bandwidth is not the constraint.
Keep `--snapshot-hz 30` as an explicit degraded mode.
### 5.4 High-refresh-rate clients — 120 / 144 / 165 / 240 / 360 Hz
Players on high-refresh displays are the ones most sensitive to everything in this document. Three places in the code must run per rendered frame, not per physics tick, for this to be true — these are now implemented (Phase 0); the reasoning is kept here because it explains why the split exists.
#### What frame rate actually buys
| Display | present | own ship / ball (§5.1) | world response (§5.2) | with low-latency present |
|---|---:|---:|---:|---:|
| 60 Hz | 25.0 | **52** | **174** | 35 / 157 |
| 120 Hz | 12.5 | **35** | **158** | 27 / 149 |
| 144 Hz | 10.4 | **33** | **155** | 26 / 148 |
| 165 Hz | 9.1 | **31** | **153** | 25 / 147 |
| 240 Hz | 6.3 | **27** | **149** | 23 / 145 |
| 360 Hz | 4.2 | **24** | **146** | 21 / 144 |
> **This table's reachability depends on the render budget — see §5.5 for what was actually measured on reference hardware.**
Three conclusions to design around:
1. **60 → 144 Hz is worth ~19 ms on own-ship feel. 144 → 360 Hz is worth ~9.** The curve flattens hard, because 16 ms of the remaining budget is the 60 Hz physics tick plus its interpolation and does not move.
2. **A low-latency present is worth more at 60 Hz (17 ms) than the entire jump from 144 to 360 Hz.** It costs one settings dropdown.
3. **Frame rate barely moves world response** — 174 → 146 across the whole 60360 range, because that budget is dominated by RTT and the interpolation buffer. Frame rate is an *own-ship feel* lever, not a netcode one. Say this to players plainly; someone who buys a 360 Hz monitor to see opponents sooner has been mis-sold.
#### Frame-time variance, not mean frame rate, is the real target
At 240 fps the frame budget is **4.17 ms**, and physics runs at 60 Hz — so **one frame in four carries the entire physics tick** and must still fit in 4.17 ms. On that frame the client pays, in one go: the Jolt step over 7 dynamic bodies against a 172-shape compound; 7 × `Ship._integrate_forces`; 6 × `_update_movement_vfx`; and on decision ticks, bot inference — `policy_network.gd` is a pure-GDScript MLP at **31→64→64→7 ≈ 6.5k multiply-accumulates per bot**, so five bots landing together is ~33k GDScript float ops in one frame.
**The physics tick sets a floor on 1%-low frame time that no graphics setting can lower.** A game that averages 240 fps but drops one frame in four to 8 ms is not a 240 fps game. Profile p99, not mean.
#### What frame rate does *not* buy, so nobody optimises the wrong thing
**Input sampling does not improve.** `player_ship_controller.gd:15-38` reads seven `Input.is_action_pressed` calls — all digital, all held-state — and `Ship._integrate_forces` pulls them once per physics tick. The state read at the tick *is* the freshest state; sampling it 240 times a second returns the same value 4 times in a row. **Do not build a sub-tick input accumulator.** If analog stick support is added later this changes, and the right answer is then a time-weighted average over the tick, not a higher sample rate.
**Physics interpolation stays on.** It costs ~8 ms (§5.1) and is the single largest fps-independent term after the tick wait, so it will look like a target. It is not: without it a 60 Hz simulation presents 60 distinct world states per second regardless of frame rate, which is precisely the stepping a 240 Hz display was bought to avoid. Leave it on; do not expose a toggle.
#### Why physics stays at 60 Hz, and what a bump would cost
The honest answer to "our players want 240 fps responsiveness" is that **simulation rate, not frame rate, is the binding constraint** — 16 ms of own-ship latency and ~33 ms of world response sit behind it, and §5.2 shows frame rate alone cannot get world response under ~146 ms. Doubling to 120 Hz (Rocket League's rate, with snapshots raised alongside) would take world response from ≈174 to **≈141 ms** and own-ship from 52 to **≈44**, at 60 Hz display — or **≈115 ms** combined with a 144 Hz display and a low-latency present.
That is a bigger win than every tuning parameter in §3 and §4 combined. It is nonetheless **out of scope for v1**, for reasons that are about the project rather than the netcode:
- **Every policy in `Game/bots/` is invalidated.** `ship.gd:450`'s `_tick_scaled` is defined against a 60 Hz reference and `ai_ship_controller.gd`'s `reaction_ticks` counts ticks. A bump means a full retrain — and per `TODO.md` the generation-5 curriculum is still running.
- **Server density halves**, ~610 matches per core to ~35 (§1.4).
- **Bandwidth roughly doubles**: input 43 → 86 kbit/s up, snapshots 105 → 210 kbit/s per client, 631 kbit/s → 1.26 Mbit/s per 6-player match. Still not the constraint, but 100 concurrent matches becomes ~126 Mbit/s of server uplink, which is a hosting-plan question rather than a rounding error.
**The consequence for this plan is a hard rule: 60 is a constant named `NetCodec.TICK_HZ`/`SimConstants.TICK_HZ`, never a literal.** Ring sizes, `INTERP_DELAY`, `input_lead` clamps, seq-window bounds, snapshot cadence and the timeout constants all derive from it — this is already true in code. Done this way, a later bump is a config change plus a retrain — not a protocol rewrite.
#### Client display settings
`VideoSettings` implements a `Preset` system (Low/Medium/High/Custom), `vsync_mode` (Adaptive default), refresh-derived `fps_cap_divisor`, and `resolution_scale` — see §5.5 for what these bought when actually measured. The design reasoning that shaped them:
- **Adaptive vsync** (`FIFO_RELAXED`) is FIFO while the renderer keeps up and tears only on a *missed* vblank — the right default for a game that will sometimes drop below refresh, avoiding FIFO's half-rate cliff.
- **FPS cap options are derived from the display**, not a fixed list — non-divisor caps beat against scanout (cap at 100 on a 144 Hz display and `gcd(100,144) = 4`: visible micro-stutter).
- `Engine.max_fps` is a throttle, not a pacer — it has no knowledge of scanout and never phase-locks to a vblank.
- Godot cannot report the *negotiated* present mode — `DisplayServer.window_get_vsync_mode()` echoes back the mode you stored, not the driver's actual grant. A live fps readout is the honest alternative.
### 5.5 Can this build produce frames at all? — measured
§5.4's table describes a device-class question, not a code question, and the
render configuration was a showcase build, not a competitive one by default.
**Measured on real reference hardware (RTX 3090, Linux, via
`Game/tools/gpu_profile_harness.gd`)**, 6-ship 3v3 Match, 1080p:
| | p50 | p99 | fps (p50) |
|---|---:|---:|---:|
| All effects on (project defaults) | 1.85 ms | 2.98 ms | 540 |
| All effects off | 0.53 ms | 1.53 ms | 1883 |
At 540 fps p50 with every effect enabled, **this scene is nowhere near
GPU-bound on reference-class desktop hardware** — the "must hit 144 fps"
framing this section originally worried about does not hold at that
hardware tier. SDFGI and SSIL account for over half of the effects' total
cost (0.36 ms and 0.25 ms respectively), matching the original expectation
that voxel cone tracing and a full-res screen-space GI pass would be the
expensive ones.
An earlier pass on Apple Silicon (M4, Metal) measured a much lower,
undifferentiated ~55 fps ceiling with all effects clustering at 2.93.8 ms
each — this was a poor stand-in for the target platform: Apple's
tile-based-deferred GPU architecture forces a full system-memory resolve on
any pass reading neighbouring pixels (SSAO, SSIL, glow, `screen_texture`),
which is a largely constant per-pass tax rather than proportional to each
effect's real cost. Treat that number as informative about relative
ordering only, not as a stand-in for desktop-GPU behaviour.
**Still open: no low/mid-tier GPU has been profiled.** The 3090 result rules
out "the game is GPU-bound on reasonable hardware" as a near-term concern,
but says nothing about a GTX 1660 or an integrated Iris/Vega part, which is
where a real preset ladder actually earns its keep. Re-run
`gpu_profile_harness.tscn` on weaker hardware before spending more effort on
frame-time optimisation. Baking the arena GI to retire SDFGI is real
but smaller than originally assumed on a 3090-class GPU — it stays worth
doing for low-end/integrated GPUs, unmeasured; a separate-physics-thread
prototype was closed without implementation, since no
frame-time variance problem exists to fix on reference hardware.
### 5.6 Closing the gap to the reference — without lowering settings
§5.2 lands at ≈174 ms against a ~90110 ms reference band. The instinct is that reaching it means trading visual quality for frames. **It does not.** Decompose the 174:
At 60 ms RTT, 60 ms is transit and irreducible in code. That leaves **114 ms of local overhead**, of which frame rate governs only two terms — input freshness (10) and present (25) — and *quality settings* govern neither directly. Present latency is a function of vsync mode and swapchain depth, not of how many effects are enabled; a 60 fps client with a shallow present queue beats a 240 fps client with a deep one. **The entire 60 → 240 fps range is worth ~12 ms once a low-latency present is in place** (§5.4). The other ~100 ms is netcode time model and simulation rate.
Four levers, none of which touches a graphics setting:
| | Lever | Saves | Risk |
|---|---|---:|---|
| **L1** | **Extrapolate remote *visuals* to present time** instead of interpolating the past | **30** | Mis-prediction pops |
| **L2** | 120 Hz simulation | 21 | Bot retrain, ½ server density, 2× bandwidth |
| **L3** | Adaptive jitter-buffer depth, 0 on clean links | 8 | Starvation on jittery links |
| **L4** | Shallow present queue + Adaptive vsync | 17 | Throughput loss if GPU-bound |
**L1 is the big one, and it is nearly free.** §4.1 already computes remote entities' **present-time** state — that was the fatal correction that put the collider at `server_time_est`. `$Visual` is then deliberately rendered ~68 ms in the past for smoothness. **Render it at present time too and the whole 37.5 ms interpolation buffer disappears**, leaving only a residual for error smoothing.
The reason this is safe here is that ships have bounded acceleration and the hull is large. Extrapolating with known velocity, error is `½·a·t²` over the full 68 ms horizon:
| | max accel | error @ 38 ms | error @ 68 ms |
|---|---:|---:|---:|
| position, cruise | 30 m/s² | 0.022 m | **0.069 m** |
| position, turbo | 75 m/s² | 0.054 m | **0.173 m** |
| yaw | 20 rad/s² | 0.8° | **2.6°** |
| pitch / roll | 2.9 rad/s² | 0.1° | **0.4°** |
**0.17 m and 2.6° worst case, against a 4 m hull.** Feed the residual through the same soft-correct pipeline already specified for the local ship (§4.4) and remote ships are visually at present time with a sub-decimetre wobble.
Two bonuses: it **collapses §4.1's dual clock back into one** — collider and visual both at `server_time_est`, so §5.4's `_process`/`_physics_process` split and the two-regimes-for-one-node-name hazard both go away — and it applies to the ball, which is near-ballistic between contacts and therefore extrapolates better than ships do.
The cost is real but narrow: a remote ship that *reverses input* at the moment you sample it mispredicts by the numbers above and then visibly corrects. Interpolation never mispredicts; it is just always late. This is the genuine trade, and it is the one the reference class makes. **L1L4 are not yet implemented; this remains netcode work, not measurement — see `multiplayer-next.md` §11 for status.**
| Term | today | L1 + L4 (v1) | + L2 + L3 | at 144 fps |
|---|---:|---:|---:|---:|
| input freshness | 10 | 10 | 10 | 5.5 |
| wait for next tick | 8.3 | 8.3 | 4.2 | 4.2 |
| client → server | 30 | 30 | 30 | 30 |
| jitter buffer | 16.7 | 16.7 | 4.2 | 4.2 |
| server tick + flush | 8 | 8 | 4 | 4 |
| server → client | 30 | 30 | 30 | 30 |
| interp buffer → extrapolation residual | 37.5 | 8 | 8 | 8 |
| client physics interpolation | 8.3 | 8.3 | 4.2 | 4.2 |
| present | 25 | 8.3 | 8.3 | 3.5 |
| **World response** | **≈174** | **≈127** | **≈103** | **≈94** |
**≈103 ms at 60 fps with every effect enabled**, and ≈94 at 144 fps — inside the reference band, without disabling SDFGI, SSIL, SSAO or shadows. Sequencing follows ms-per-unit-of-risk: **L4 then L1 first (≈127 ms, no bot retrain, no protocol change)**; L2 and L3 after, when a retrain is affordable.
> **The largest lever is not on this list.** All of the above assumes 60 ms RTT. Regional server siting that puts most players on a 30 ms RTT takes ≈127 to ≈97 and ≈103 to ≈73 with no code at all. Server siting (Phase 6/Phase 8 in `multiplayer-next.md`) owns it, and it should be argued against these numbers.
### 5.7 The next tier — and where it stops paying
#### Frame rate: SDFGI is the wrong tool for this arena
`arena.gd`/`goal.gd` have **no `_process`, no `_physics_process`, no `AnimationPlayer` and no `Tween`** — the floor, walls, ceiling, goals and every light are static for the entire match. SDFGI exists to light *dynamic* worlds, and it pays for that by re-voxelising cascades as the camera moves — and this camera never stops moving. It is the most expensive optional effect in the frame (§5.5), doing continuous work to solve a problem this project does not have. **Replace `sdfgi_enabled` with baked GI** (`LightmapGI` or `VoxelGI`) — still open, see §5.5's "still open" note; the relative win is real but the absolute win on reference-class hardware is small.
#### Latency: what is actually left, after L1L4 and 120 Hz simulation
At 144 fps the budget would be ≈94 ms — **and 60 of that is RTT.** The remaining ~34 ms of local overhead sits at or near a floor set by physics rate or hardware. Two small code ideas remain, both trading visual stability for a few ms: forward-extrapolating the local `$Visual` instead of interpolating the last two ticks (~4 ms, risk of overshoot on collision), and tightening the extrapolation-error smoothing (~4 ms, more visible correction pops). **That is the whole remaining code budget.** Regional server siting is worth 4× that for free (§5.6).
Two limits worth keeping in mind before spending a month on the last 5 ms:
1. **Past ~100 ms, you are optimising 34 ms at a time against a 60 ms constant.** Server siting and matchmaking dominate everything else from that point on.
2. **"Lowest lag" and "best feel" diverge at the end.** Every remaining lever buys milliseconds by predicting further ahead and correcting harder. Past a point that makes the game feel *worse*. **The human playtest is the authority; the budget table is not.**
---
## 6. Match lifecycle
### 6.1 State machine
```
LOBBY -> LOADING -> WARMUP -> PLAYING -> GOAL_PAUSE -> WARMUP -> ...
-> FULL_TIME -> OVERTIME_WARMUP -> OVERTIME -> ...
-> RESULTS -> LOBBY
```
Broadcast as the `match_state` byte in every snapshot, and on transition via `state_change(state, at_tick)`.
### 6.2 Sequence
1. **Connect.** Client sends `hello(protocol_version, physics_ticks_per_second, display_name, auth_ticket)`. Server rejects a mismatch on **either** version or tick rate, with a reason string, then `disconnect_peer`. `auth_ticket` is an empty `PackedByteArray` until Phase 7 lands — the field is reserved.
2. **Welcome.** Server assigns `player_id`, balances teams, replies `welcome(player_id, server_info, roster, match_state, server_tick, score, end_tick)`, broadcasts `player_joined`.
3. **Lobby.** `ready_toggle()`; start when all ready, or `--auto-start` after `--min-players` plus a countdown.
4. **Config.** `match_config(match_id, arena_path, team_size, match_length_ticks, roster[], seed)`. `roster[i] = {slot, team, spawn_index, player_id, name, is_bot}` — **slot order here is the snapshot's body order for the whole match.** The client validates `arena_path` against `ArenaRegistry.ARENAS` before `load()`; a malicious or buggy server must not be able to make a client load an arbitrary `res://` path.
5. **Load.** Both sides load `networked_match.tscn`. Each peer loads the arena and spawns the roster in slot order. Client additionally spawns a camera rig on its own ship and adds `HUD.tscn` **in code**. Client sends `scene_ready(match_id)`.
6. **Kickoff.** Server waits for all `scene_ready` (10 s timeout → proceed). Broadcasts `kickoff(reset_transforms[], countdown_start_tick, reset_gen)`. Both sides freeze bodies. HUD counts down from `server_tick`, not a local `Timer`. At `countdown_start_tick + 180` the server unfreezes and broadcasts `state_change(PLAYING)`.
7. **Play.** Inputs up, snapshots down.
8. **Goal.** Server's `Goal` sensor fires → `_handle_goal_scored` debounce → `goal_scored(scoring_team, score, goal_tick, resume_tick)`. Bodies freeze. Clients play the cinematic within `[goal_tick, resume_tick]`. At `resume_tick`: `kickoff(...)`.
9. **Clock.** Tick-derived: `remaining_ticks = end_tick - current_server_tick`. `end_tick` and a `running` flag ship in `match_config` and in `clock_state(running, end_tick, at_tick)`.
10. **Full time / overtime / results.** `RESULTS` holds, then `state_change(LOBBY)` and both sides load `lobby.tscn`. **Clients return to the lobby, not the main menu** — a community server that empties every 2.5 minutes is dead on arrival.
**Every lifecycle message carries absolute ticks**, never durations. **Specify the late-arrival case explicitly**: a `kickoff` that lands after its own `resume_tick` must apply the reset immediately and skip the countdown, not schedule it into the past.
### 6.3 Late joiners and spectators
`welcome` carries full state, so a late joiner reconstructs immediately.
- Free slot and state is `LOBBY`/`WARMUP` → join as a player now.
- Free slot mid-match → **spectate now, take the slot at the next kickoff.** Swapping a controller at a kickoff boundary is free; mid-play it is not.
- No free slot → spectator. A spectator receives identical snapshots (the snapshot is already a broadcast — zero extra server work), spawns no ship, and points a camera rig at a chosen ship or the ball. Cap with `--max-spectators`.
### 6.4 Disconnects — no ship is ever despawned
On `peer_disconnected` the server **keeps the ship and swaps its controller**:
1. `--fill-bots`: replace with an `AIShipController` on the server's configured model.
2. `--no-fill-bots` (default for public servers, see §1.4): swap to the base `ShipController` — inert but simulated.
Set `flags.stalled` so clients can grey out the nameplate. Reserve the slot for 30 s keyed by identity so a reconnect gets its ship back. If the last human leaves, abort to `LOBBY`.
**Justification is wire-format simplicity, not the bot cache.** Fixed slot order means the snapshot needs no add/remove machinery, no `MultiplayerSpawner`, and no re-indexing. That reason stands on its own.