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.
48 KiB
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 thatlast_processed_input_seqmust 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 aRigidBody3Dunder prediction: incoming state has to enter a compare-against-history pipeline, not be stamped ontoglobal_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.MultiplayerSpawneris unnecessary for a separate reason: the roster is fixed at match start and fully described by thematch_configmessage, 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 |
→ ~6–10 concurrent matches per modern core, ~150–250 MB RSS per process. 100 concurrent matches ≈ 12–16 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).
i8per axis, not 3-bit bins. Bins matchingShipActionCodec.HEADSwould cut an action to 3 bytes, but they permanently foreclose analog gamepad sticks, which this game will want.round(v*127)/127round-trips-1/0/+1exactly, so today's digital input (player_ship_controller.gdisis_action_pressed-only) is lossless.- The encoding is itself a validator.
i8/127cannot 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:
multiplayer.get_remote_sender_id()→ look up slot. Unknown sender → drop and count.- Rate limit.
packets_this_second > 110(60 Hz × 1.5 + 20) → drop. Three consecutive seconds over budget → disconnect withRATE_LIMIT. Same for a byte budget. - Framing.
count > 4orpayload_size != 12 + count*7→ drop, count malformed. 20 malformed → disconnect. - Sequence range.
seq > server_tick + 20→ drop. (Not 120:input_leadis clamped to 12, so anything above ~20 is broken or hostile.) This is why the ring is fixed-size and indexedseq % 32— a client can never make the server allocate. - For each action, newest first at descending seq:
seq <= last_applied_seq→ discard (already consumed); else writering[seq % 32]. - 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 thei8encoding 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_leadclamp[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_leadmeans 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
BoxShape3Dof(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:16hascollision_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.03–0.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 N−1) |
| 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
predicted[current_tick - 1] = {transform, linear_velocity, angular_velocity}— ring of 128.var a := _player.get_action().copy()— must copy.player_ship_controller.gdreuses a singleShipActionacross ticks (its own header warns about this); buffering it aliases every history entry to the same object._action = a, returned byget_action()this tick soShip._integrate_forcessamples input exactly once.input_history[seq] = a,seq = predicted_server_tick + input_lead.- 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
$Visualby 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
$Visualinterpolation, 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 theNode3Dthe first time it is called, which can respond to physics interpolation resets… be sure to call it at least once before resetting theNode3Dphysics interpolation." Every hard snap callsreset_physics_interpolation()on$Visual. Prime the pump when the camera'stargetis 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— notSTATIC, 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.gdsamples the snapshot buffer (last 8 per body); collider atserver_time_est(§4.1),$Visualatserver_time_est - INTERP_DELAY.- The two samples run on different clocks and different callbacks. The collider is a physics concern:
_physics_process, 60 Hz.$Visualis a render concern:_process, sampled at true render time withphysics_interpolation_mode = OFFso 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_msterm 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 stampedone_wayago. So rendering$Visualatserver_time_est - INTERP_DELAYonly interpolates ifINTERP_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_velocityto a frozen body. Godot/Jolt zeroes and holds velocity on frozen bodies, soball.gd:35'slinear_velocity.length()trail driver will not work that way.Ball.set_visual_speed(speed)mirrors theShip.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 0–16.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 90–110 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.5term 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 = 32the 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:
- 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.
- 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.
- Frame rate barely moves world response — 174 → 146 across the whole 60–360 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_scaledis defined against a 60 Hz reference andai_ship_controller.gd'sreaction_tickscounts ticks. A bump means a full retrain — and perTODO.mdthe generation-5 curriculum is still running. - Server density halves, ~6–10 matches per core to ~3–5 (§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_fpsis 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.9–3.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 ~90–110 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. L1–L4 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 L1–L4 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:
- Past ~100 ms, you are optimising 3–4 ms at a time against a 60 ms constant. Server siting and matchmaking dominate everything else from that point on.
- "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
- 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, thendisconnect_peer.auth_ticketis an emptyPackedByteArrayuntil Phase 7 lands — the field is reserved. - Welcome. Server assigns
player_id, balances teams, replieswelcome(player_id, server_info, roster, match_state, server_tick, score, end_tick), broadcastsplayer_joined. - Lobby.
ready_toggle(); start when all ready, or--auto-startafter--min-playersplus a countdown. - 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 validatesarena_pathagainstArenaRegistry.ARENASbeforeload(); a malicious or buggy server must not be able to make a client load an arbitraryres://path. - 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 addsHUD.tscnin code. Client sendsscene_ready(match_id). - Kickoff. Server waits for all
scene_ready(10 s timeout → proceed). Broadcastskickoff(reset_transforms[], countdown_start_tick, reset_gen). Both sides freeze bodies. HUD counts down fromserver_tick, not a localTimer. Atcountdown_start_tick + 180the server unfreezes and broadcastsstate_change(PLAYING). - Play. Inputs up, snapshots down.
- Goal. Server's
Goalsensor fires →_handle_goal_scoreddebounce →goal_scored(scoring_team, score, goal_tick, resume_tick). Bodies freeze. Clients play the cinematic within[goal_tick, resume_tick]. Atresume_tick:kickoff(...). - Clock. Tick-derived:
remaining_ticks = end_tick - current_server_tick.end_tickand arunningflag ship inmatch_configand inclock_state(running, end_tick, at_tick). - Full time / overtime / results.
RESULTSholds, thenstate_change(LOBBY)and both sides loadlobby.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:
--fill-bots: replace with anAIShipControlleron the server's configured model.--no-fill-bots(default for public servers, see §1.4): swap to the baseShipController— 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.