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

48 KiB
Raw Permalink Blame History

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, 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 i16s 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:

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 % 32a 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:
    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:
    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_KINEMATICnot 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.