New NetSim autoload: seeded, CLI-driven (--net-sim-latency/-jitter/-loss/-dup) latency/jitter/loss/duplicate decorator, a true no-op passthrough unless a flag is set. Wraps MatchSim.send_input/send_snapshot per the design doc's scope, plus NetworkManager's ping/pong so the already-tested RTT/clock measurement becomes the acceptance signal for "raises observed RTT" without waiting on Phase 3's per-peer snapshot echo. Two real bugs found while building and verifying this against Phase 2's own milestone gate (a real match under --net-sim-latency 80 --net-sim-jitter 20, not just LAN): a timestamp captured inside a delayed RPC closure silently ate that side's own added delay out of the round-trip measurement instead of adding to it; and a delayed send whose target disconnected (or whose own process had already shut down) during the hold threw RPC errors, since the existing get_peers() filtering only checked validity at schedule time. Fixed by capturing timestamps before handing off to NetSim, and by having NetSim re-validate the target at fire time. Phase 2's milestone gate now passes for real: a full 1v1 under simulated 80ms latency / 20ms jitter still shows clean server-authoritative movement and zero RPC errors. Full Phase 1 + Phase 2 regression suite re-verified clean with NetSim present but inactive.
162 KiB
Online multiplayer — architecture and task breakdown
Working document for the online multiplayer effort. TODO.md points here.
Everything below is written so an agent (or a person) can pick up a single numbered task, do it, verify it against a stated acceptance criterion, and stop. Sections 1–6 are the decisions those tasks assume; read them before picking up work in Phase 2 or later.
Status: Phase 0 done, Phase 1 done, Phase 2 done — milestone gate passing. A real two-process 1v1 runs: a headless server hosts, a client joins through the lobby, spawns into a server-picked arena, drives its ship via real held input, and renders server-authoritative movement (verified: 26–31 m over a 2 s held-thrust drive, purely from interpolated snapshots) with camera and HUD attached — and this still holds under --net-sim-latency 80 --net-sim-jitter 20 (task 2.8's net_sim.gd), which is Phase 2's own stated gate, not just LAN. No own-ship/ball prediction yet (Phase 4) — everything the client renders, including its own ship, comes from the interpolation buffer. See §7 for per-task status and evidence.
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 | No custom backend. | Steam's ISteamGameServer master-server listing covers discovery, ISteamMatchmakingServers covers the in-game browser, and Steam auth tickets cover identity and ban state. README.md's C# backend stays unstarted. |
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 (see task 0.8) | ~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.
Verify at implementation time. Godot's
ENetMultiplayerPeerreserves low ENet channels for its own system messages and offsetstransfer_channelon top. The intent above is "three logically distinct channels"; the concrete indices may need an offset. Confirm empirically, don't assume.
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 — in a project that has no test framework yet. 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).
CLAUDE.md's Architecture section states the play volume as "inner x ±12, z ±18, height 12, goal lines z ±17". That is stale — see the real constants above. Task 0.13 fixes the doc.
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 # see §7 task 5.7 — null on takeover
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. See task 0.1._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 SNAP
- Set body transform and velocities from the server values, caught up to the current tick (below),
reset_physics_interpolation()on the body and on$Visual, zero the visual offset. - Backfill the prediction ring for ticks
A..current. Do not clear it — "missingpredicted[A]" is itself a snap condition, so clearing guarantees the next snapshot also snaps, turning isolated snaps into bursts.
Catch-up replays the ship's own force formulas, not ballistic dead-reckoning. Input-free extrapolation is not unbiased: turbo acceleration is 150 × 2.5 / 5 = 75 m/s², so a 6-tick catch-up lands ~0.375 m short in the direction the player is accelerating, on every snap, and the ship feels permanently rubbery under sustained thrust. Replaying 6–12 stored ShipActions through apply_thruster_forces / apply_rotation_forces / apply_drag_and_limits / apply_righting_torque (ship.gd:361-486 — pure float math, no Jolt dependency) is ~30 lines and ~1000 float ops.
This is not world rollback and does not touch locked decision 1. It replays one body against a frozen world and needs no determinism guarantee.
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 (task 0.16, 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. (This is the same artefact game_mode.gd:263 already exists to prevent.)
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 — §4.1 says exactly this when it extrapolates the collider forward "by ~one-way + half a snapshot interval". 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: the bullet below about extrapolating past the newest snapshot becomes the steady state rather than the exception, and 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. AddBall.set_visual_speed(speed)mirroring 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 — see below |
| wait for next physics tick | 8 | avg of 0–16.7 | no — 60 Hz physics |
| physics step applies force | 0 | ||
| Godot physics interpolation | 8 | physics_interpolation=true; 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 — and ~43 of those 52 ms are things no netcode document discusses. A low-latency present would take it to ~35 ms (§5.4).
Two notes on the model, both corrected from an earlier draft that read ≈45:
- Input freshness is 0.5 of a frame interval, not 0.25. Godot pumps OS input once per main-loop iteration and
Ship._integrate_forces(ship.gd:347) consumes it once per physics tick; for arrivals distributed uniformly between pumps the mean staleness at the pump is half the interval. On top sits device polling, which does not scale with fps at all: ~1 ms at a 1000 Hz mouse or gamepad, ~8 ms at a 125 Hz USB device. The table assumes ~2 ms. - Physics interpolation's 8 ms is a mean. Rendering happens between the two most recent completed ticks, so displayed pose lags the newest state by
(1 − fraction)of a tick — 0 to 16.7 ms, averaging 8.3. The worst case matters for §5.4's discussion of frame-time variance.
Note the right-hand column: 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 | ~8 with default idle-frame poll — see §7 task 1.3 |
| 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 |
Correction — this table previously read ≈138 ms and omitted the server→client transit row entirely.
INTERP_DELAYwas quoted as 38 ms, which is the interpolation buffer measured from snapshot arrival, while §4.6 defines the render cursor relative toserver_time_est— server-now. The 30 ms return leg fell between the two definitions and was never counted. §4.6's formula is corrected to includeone_way; this table keeps the two terms on separate rows because that is clearer to budget against.
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. It is also not the end state: §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, and the current code has three places where the client draws 240 frames but only 60 of them contain new information. Those are bugs, not tuning.
What frame rate actually buys
Modelling present as ~1.5 refresh intervals with vsync on (§5.1), and input freshness as 0.5 of a frame interval plus ~2 ms of device polling:
| 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 assumes the client can actually produce those frames. It cannot — see §5.5. As configured today the project runs SDFGI, SSIL, SSAO, a 5-level glow pyramid, five shadow-casting lights, MSAA 4× and FXAA, and an unconditional full-screen backbuffer pass, none of which any player can switch off. Read §5.5 before treating any row below 60 Hz's as reachable.
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.
Three things that must run per rendered frame, not per physics tick
a. The camera rig. ship_camera.gd:86 runs the entire rig in _physics_process. Global physics_interpolation=true smooths the resulting camera transform, so this is not visible as judder — but it costs an extra tick of camera latency on top of the ship's, and two things it does are not transforms and therefore not interpolated: camera.fov (:182) and the PostFX shader parameters (:186-187). At 240 fps those step at 60 Hz, which reads as a faint pulse in the turbo FOV kick.
The rig moves to _process, reading target.get_global_transform_interpolated() (and $Visual's, post-task 0.2) instead of target.global_transform, with physics_interpolation_mode = PHYSICS_INTERPOLATION_MODE_OFF on the rig itself so Godot does not re-interpolate an already-per-frame transform.
The move is cheap but it is not tuning-neutral. Cost first: one call is ~15 engine-bound operations (2 × get_noise_1d, 2 × set_shader_parameter, Basis.looking_at, slerp, orthonormalized, signed_angle_to, rotated, several global_basis accesses) plus ~60–100 bytecode ops — call it 5–15 µs. At 360 Hz that is 1.8–5.4 ms/s, under 0.5% of a core. Negligible, but negligible because the absolute work is tiny; 1-exp(-k·delta) is a correctness property, not a cost argument, and it does not license moving arbitrarily expensive code into _process.
The impact shake must be re-tuned, and in the opposite direction to what you would guess.
ship_camera.gd:204advances the noise coordinate bydelta * 60.0, and:64setsfrequency = 2.5, so each sample stepsdelta × 150noise units. At 60 fps that is 2.5 units per sample — simplex noise decorrelates over roughly 1 unit, so the shake is currently white noise, and physics interpolation is lerping between independent samples. At 360 fps in_processit becomes 0.42 units per sample, which is strongly correlated: the shake turns into a slow, smooth wobble that gets softer the better your monitor is. Re-derivefrequency(or the* 60.0) for constant noise-units-per-second, then re-check amplitude by eye at 60 and 240 fps.
Everything else in the rig genuinely is rate-independent and needs no attention: 1.0 - exp(-k * delta) at :126, 137, 156, 172, 177 and move_toward(…, shake_decay * delta) at :212.
Two pre-existing bugs sit in the code this task touches, so fix them here rather than discovering them in Phase 5:
- The rig has no snap path.
camera.global_positionis smoothed atcamera_smoothing = 10.0(:14, 137, 156) with no reset anywhere in the file. At a kickoff teleport (game_mode.gd:256-263, becoming an_integrate_forceswrite under task 0.15) the camera lerps across the arena over ~300 ms. Addsnap_to_target()— setglobal_position/global_basisdirectly, zero_last_shake_offset— and call it from the kickoff path. - Shake decay stalls during a goal cut.
:94-96returns before_apply_shake, so_shake_strength'smove_towarddecay never runs for the length of the cinematic. Task 0.12 proposes building goal feel on exactly this system.
b. Remote-entity visuals. §4.6's interpolator samples a snapshot buffer between two known states. Driving that from _physics_process quantises every remote ship and the ball to 60 distinct positions per second and then leans on Godot to interpolate between them — an extra tick of lag for no benefit, since we are already interpolating. Sample the buffer at true render time in _process instead: 240 distinct positions per second and one fewer tick of lag.
The split is clean because the two consumers want different times anyway (§4.1): the collider is a physics concern and stays in _physics_process at server_time_est; $Visual is a render concern and moves to _process at server_time_est - INTERP_DELAY, with physics_interpolation_mode = OFF. Setting it OFF is coherent precisely because the node's global_transform is overwritten every rendered frame — there is nothing left for the engine to interpolate. Note this is the opposite of §4.5's rule for the local ship's $Visual, which is written per physics tick and therefore must stay interpolated and must be reset on snap. Same node name, two different regimes; task 0.16 lands in Phase 0 against local-ship semantics, task 2.4 adds the remote case.
It is not free, though it is cheap: per body per frame you bracket-search a ring of 8, run two Vector3.lerps and a Quaternion.slerp, build a Transform3D, and assign global_transform (which dirties and propagates to children). Estimate 3–6 µs per body → ~21–42 µs/frame for 7 bodies, ~1.5% of a core at 360 Hz. That is 4–6× the work of sampling at 60 Hz. Measure it in task 0.15b rather than asserting it.
c. Receive polling. Task 1.3 already flushes sends from _physics_process. Receiving is the other half: with (b) in place, a snapshot that lands 2 ms after a physics tick can be rendered 2 ms later at 240 fps instead of waiting 14 ms for the next tick. Poll for receive unconditionally at the top of both _process and _physics_process — no rate limiter. A zero-timeout enet_host_service on an empty socket is one non-blocking recvfrom returning EWOULDBLOCK, on the order of 1 µs; 360 of those per second costs ~0.36 ms/s. An earlier draft proposed a 2 ms limiter, which is worse than useless: at 240 fps the frame interval is already 4.17 ms so it never fires, and it only engages above ~500 fps where polling was already cheaper than the limiter.
Manual polling relocates the connection signals. With
set_multiplayer_poll(false),peer_connected/peer_disconnectednow fire from inside yourpoll()call — mid-_process, during a render frame — rather than on the idle-frame boundary. Any handler that mutates the scene tree must defer.
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 (ship.gd:346-357), each running apply_thruster_forces, a full ArenaBoundary.get_surface_pull with five _falloff calls (arena_boundary.gd:183-198), apply_rotation_forces, apply_righting_torque and apply_drag_and_limits with two pow() calls via _tick_scaled (:450); 6 × _update_movement_vfx (:296-315, writing two material params and two OmniLight3D energies per ship); 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.
Task 0.8's decision stagger is framed above as a cosmetic hitch. It is not — 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 (task 0.15b).
The same term matters at the bottom of the range, where most players actually are: see gotcha 22 and task 0.22 for the client-side Engine.max_physics_steps_per_frame cap that stops a hitching client from spiralling.
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. The only thing lost is a press-and-release entirely inside one 16.7 ms tick, which is below human tap duration. 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:
| Term | 60 Hz sim | 120 Hz sim | |
|---|---|---|---|
| wait for next tick | 8.3 | 4.2 | |
| physics interpolation | 8.3 | 4.2 | |
| jitter buffer, depth 1 | 16.7 | 8.3 | |
| server tick + flush | 8 | 4 | |
| interpolation buffer | 37.5 | 25.0 | only the interval × 1.5 term halves; the jitter term does not |
| client ↔ server transit | 60 | 60 | does not move |
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, never a literal. Ring sizes, INTERP_DELAY, input_lead clamps, seq-window bounds, snapshot cadence and the timeout constants all derive from it. Task 1.4's handshake already gates on physics_ticks_per_second, so a mismatched client is rejected rather than silently desynced. Done this way, a later bump is a config change plus a retrain — not a protocol rewrite. Done the other way, the literal 60 ends up in twelve files and the bump never happens.
Client display settings
project.godot sets neither display/window/vsync_mode (defaults to enabled/FIFO) nor application/run/max_fps (uncapped). video_settings.gd:14-16 persists only AA, glow and brightness, and settings_menu.gd exposes only those three. Task 0.17 adds:
VSync: Enabled (FIFO) · Adaptive (default) · Mailbox · Disabled.
- Adaptive (
FIFO_RELAXED) is FIFO while the renderer keeps up and tears only on a missed vblank. That is the right default for a game that will sometimes drop below refresh, because it avoids FIFO's half-rate cliff — miss 144 Hz by one millisecond under strict FIFO and you are pinned to 72. - Mailbox only lowers latency when the renderer sustains above the refresh rate; below it there is never a second frame to replace the queued one, so it degenerates to FIFO latency at Mailbox power draw. Per §5.5 this build will not sustain above 144 Hz on typical hardware today, which makes Mailbox an opt-in for players with headroom, not a default. Defaulting to it would be a thermal regression for most players in exchange for nothing.
FPS cap: derived from the display, not a fixed list. Query DisplayServer.screen_get_refresh_rate(DisplayServer.window_get_current_screen()) and offer "Match display" (default), the integer divisors of that rate, then Unlimited — 144 Hz → 144/72/48, 165 Hz → 165/82/55, 240 Hz → 240/120/80/60.
Non-divisor caps beat against scanout. A fixed 60/75/90/…/360 list is wrong on every panel that is not 60 or 120 Hz. Cap at 100 on a 144 Hz display and
gcd(100,144) = 4: the pattern repeats every 25 frames across 36 refreshes, with frames held for one or two intervals in an irregular sequence — visible micro-stutter. 120 on a 165 Hz panel is 8 frames per 11 refreshes, same failure. Offer the free-form list only behind an Advanced toggle with a warning.
Three implementation constraints, all of which an earlier draft got wrong:
Engine.max_fpsis a throttle, not a pacer. It pads each frame with a post-frame sleep to hit1/max_fps; it has no knowledge of scanout and never phase-locks to a vblank. (Sleep-granularity jitter of roughly ±0.5–1 ms is inferred, not measured — verify on target platforms. The absence of phase locking is structural.)- Grey out the FPS cap whenever VSync is not Disabled. With both active, FIFO clamps presents to vblanks while
max_fpspushes some frames past the next one and not others — frame pacing worse than either setting alone. The menu must not permit the combination. - Godot cannot report the negotiated present mode.
DisplayServer.window_get_vsync_mode()echoes back the mode you stored, not theVkPresentModeKHRthe driver granted, and there is no GDScript API that exposes the latter. An earlier draft's "report what was actually applied" is not implementable, and neither is an in-engine present-latency measurement (that needs LDAT or a high-speed camera). Instead put a livePerformance.get_monitor(Performance.TIME_FPS)readout next to the dropdown: whether the player is above or below their refresh rate is the fact every one of these settings depends on.
The renderer is Forward+ (project.godot:21, config/features=PackedStringArray("4.7", "Forward Plus")), so the usual "Mailbox is unavailable on Compatibility" caveat does not apply as written — but rendering/renderer/rendering_method is not pinned in project.godot, so a --rendering-method gl_compatibility launch or a driver fallback loses it silently. Mailbox is also commonly unavailable on macOS/MoltenVK. (Needs empirical verification on target OS versions.)
5.5 Can this build produce frames at all?
§5.4's table describes a machine this project is not. Nothing in the repo has ever been profiled, and the render configuration is a showcase build, not a competitive one. Every item below is on by default and none is reachable from video_settings.gd, which persists exactly three values (:14-16: aa_mode, glow_scale, brightness).
From scenes/arena_base.tscn, the Environment every arena inherits:
arena_base.tscn |
Setting | Note |
|---|---|---|
:47-50 |
sdfgi_enabled, sdfgi_use_occlusion, sdfgi_bounce_feedback = 0.5 |
Godot 4's most expensive GI path; cascades re-voxelise as the camera moves, and this camera never stops (ship_camera.gd:126,137,156) |
:42-46 |
ssil_enabled, ssil_radius = 4.0 |
A full-resolution screen-space pass on top of SSAO |
:34-41 |
ssao_enabled, ssao_radius = 2.5, ssao_detail = 0.75 |
|
:18-29 |
glow_enabled, 5 levels |
Mip pyramid built and resolved every frame |
:61, 78, 87, 96, 105 |
1 directional + 4 shadow-casting OmniLight3Ds |
Omni shadows are cubemaps: 24 shadow-map faces per frame before the directional |
Plus project.godot [rendering]: msaa_3d=2 (4×) and screen_space_aa=1 (FXAA) and use_debanding=true — mirrored by video_settings.gd:14 defaulting to MSAA_FXAA. Stacking FXAA on resolved MSAA is redundant blur, and the menu (settings_menu.gd) offers no 2× rung between "off" and "4×".
Plus shaders/post_process.gdshader:4, uniform sampler2D screen_texture : hint_screen_texture — a full-screen backbuffer copy every frame, unconditionally. The shader's comment notes that non-turbo frames skip two texture taps, but the copy and the full-screen pass happen regardless because vignette_strength never reaches zero (ship_camera.gd:187 writes 0.22 + …, :243 restores 0.22).
What is not the problem, so nobody optimises the wrong thing:
- The 168 colliders (§1.4) cost zero frame time. They are
CollisionShape3Ds on aStaticBody3D— no draw calls, no vertices. The count is confirmed correct (168 generated + 4 authored slabs = 172 inobjects/arena_boundary.tscn). - The scene is not geometry- or draw-call-bound.
arena_boundary.gd's visual shell is ~1450 triangles in two surfaces of oneMeshInstance3D; the whole match is on the order of 100–150 draw calls and well under 50k vertices. That is nothing.
The project is bound entirely by full-screen passes the player cannot switch off. That inverts §5.4's conclusion about where the leverage is: the largest win per line of code is not a vsync dropdown, it is a graphics preset that gates SDFGI/SSIL/SSAO/omni shadows. Task 0.15b blocks 0.16 and 0.17 for exactly this reason — every number in §5.4 is a priori, and the first measurement may invalidate the fps list entirely.
One mitigating subtlety, which cuts both ways: project.godot [display] sets window/stretch/mode="viewport" with a 1920×1080 base and aspect="expand", so the 3D renders at a fixed ~1080p and is blitted to the window. A 1440p or 4K player therefore does not pay more for any of the above — but also cannot render at native resolution, and a 1080p player cannot render lower. Task 0.17c owns that decision; it interacts directly with render scaling (0.17b) and cannot be left implicit.
5.5.1 Measured (task 0.15b, 2026-08-18)
6-ship Match, 1080p, non-headless. Hardware: Apple M4 (Metal), 10-core — a development laptop, not a dedicated gaming reference machine; treat absolute fps as directional, not a promise to players on other hardware.
| p50 | p99 | fps (p50 / p99) | |
|---|---|---|---|
| All effects on (project defaults) | 17.93 ms | 20.39 ms | 55.8 / 49.0 |
| All effects off | ~17.2 ms | — | ~58 |
This invalidates the a priori §5.4/§5.5 fps list exactly as flagged. Default settings cannot sustain even 60 fps on this hardware, let alone 144 — and the surprising part is why: turning every toggleable effect off (SDFGI, SSIL, SSAO, glow, all 5 shadow casters, MSAA, FXAA, PostFX) only recovers the difference between ~56 and ~58 fps. The ~17 ms floor is not made of the full-screen passes this section blamed — something else (base forward-clustered shading, the ~150 draw calls, per-ship VFX materials, or fixed engine/CPU overhead at 6 ships) dominates, and 5.4's framing ("the project is bound entirely by full-screen passes") is wrong as measured on this hardware.
Per-effect isolated cost (each toggled off individually against a fixed baseline sample), for reference — treat these as low-confidence: they cluster tightly at 2.9–3.8 ms each with no clear outlier, which is consistent with most of that spread being sampling noise from a ~1 ms-jittery baseline rather than real per-effect attribution:
| Setting | Cost (ms) |
|---|---|
| SSAO | 3.77 |
| PostFX | 3.82 |
| Omni shadows (×4) | 3.69 |
| SSIL | 3.44 |
| FXAA | 3.37 |
| Directional shadow | 3.30 |
| SDFGI | 3.24 |
| MSAA 4× | 3.12 |
| Glow | 2.89 |
Consequence for 0.17/0.26/0.28: a graphics preset alone will not reach a 144 fps target on hardware in this class — Low-preset gets to only ~58 fps by this measurement, not the 2×+ jump §5.4 assumed. 0.26 (bake GI) and 0.28 (separate physics thread) need to re-justify their expected win against this floor before implementation.
Root-cause follow-up, attempted and inconclusive (2026-08-18). Three further remote-automated profiling passes (via godot-mcp game_eval sampling Performance.get_monitor() against a live instance, no human at the editor) were run to find what the ~17 ms floor actually is. They did not converge:
| Pass | Setup | Result |
|---|---|---|
| 1 (above) | 6-ship 3v3, sustained | 17.93 / 20.39 ms (p50/p99), all-off floor ~17.2 ms |
| 2 | Reportedly 6-ship, actually 1v1 (misconfigured) | CPU 17.64 ms + frame 10.75 ms — internally inconsistent (CPU time exceeding frame time from non-atomic sampling); agent also reported the game becoming unresponsive mid-run |
| 3 | 6-ship 3v3, atomic single-eval sampling, retried after pass 2's failures |
8.7–10.2 ms (98–115 fps), reported CPU time 0.013 ms — implausibly low for a frame running Jolt physics + GDScript bot inference across 6 ships, so not trusted either |
Passes 1 and 3 supposedly measured the same scenario and differ by ~2×. The likely explanation is the measurement method itself, not the game: each game_eval round-trip through the MCP bridge has its own latency and can perturb the very frame timing it's sampling, and nothing here confirms the scene state (ship count, bot activity, camera framing) was identical across passes. Read the specific numbers in this subsection as evidence a floor well under 144 fps exists, not as an attributed cause — the SSAO on/off screenshot check in pass 3 did confirm effect toggles are visually real (ruling out "the toggles are no-ops" as an explanation), which is the one finding that survived across passes.
What this needs next, and why an agent can't finish it remotely: a proper frame-time attribution needs either a human at the Godot editor reading the Debugger's built-in Monitors/Visual Profiler (which breaks GPU time down by pass — opaque, shadow, post-process, etc. — instead of one aggregate number), or an external GPU profiler (RenderDoc, Xcode GPU capture on this hardware). Both require eyes on a live UI, not remote eval polling. This is now the concrete blocker for 0.26/0.28, not further scripted measurement passes. 0.15b's original acceptance criterion (write a max-frame-rate number into §5.5) is still met by pass 1 — the floor is real and under both 60 and 144 fps — but the deeper "why" is open and parked here rather than guessed at.
Root cause of the pass-to-pass inconsistency, found (2026-08-18): a Godot editor and an orphaned headless training process had both been running on the profiling machine, untouched, for 11 days (since 2026-08-08) — leftover from earlier local work, unrelated to this investigation. godot-mcp's automated launches were plausibly contending with that stale editor instance rather than getting a clean process every pass, which is a much better explanation for a ~2× swing between "identical" scenarios than genuine frame-time variance. Both processes were killed and a clean re-check was run.
Is it just that we're on a Mac? Partly, but not via the mechanism first suspected. HiDPI/Retina resolution inflation was checked directly and ruled out: the live viewport renders at 2036×1080 against a target of 1920×1080 — about 6% more pixels, non-uniformly (width only; the 2× multiplier a true Retina backbuffer would apply is not happening, display/window/dpi/allow_hidpi=true notwithstanding). A 6% pixel-count difference cannot produce the ~2× frame-time swings seen above, so resolution is not the explanation for this session's inconsistency — that was the stale-process contention above. It's still worth a one-line fix later (0.17c owns display/stretch decisions) since 2036×1080 is a mildly wasteful, non-native render target.
What Mac hardware does plausibly bias is the shape of the result, not the run-to-run noise: Apple Silicon GPUs are tile-based deferred renderers (TBDR), architecturally unlike the immediate-mode AMD/Nvidia GPUs the target "reference hardware" (a Windows/Linux gaming PC) uses. TBDR keeps a frame in on-chip tile memory and is comparatively cheap at MSAA resolve, but any pass needing to read arbitrary neighbouring pixels across the whole frame — SSAO, SSIL, the glow downsample/upsample chain, the PostFX shader's screen_texture read — forces a break out of tile memory into a full system-memory resolve, an overhead that is largely constant per pass rather than proportional to what the pass computes. That lines up with pass 1's finding that SDFGI/SSIL/SSAO/MSAA/FXAA/shadows/PostFX all cost within a tight 2.9–3.8 ms band regardless of what each one actually does — consistent with a shared TBDR resolve tax dominating over each effect's real cost. Numbers measured on this machine should be treated as informative about relative ordering at best, not as a stand-in for target-platform (desktop GPU) behaviour — confirmed below.
5.5.2 Measured on real reference hardware — RTX 3090, Linux (2026-08-19)
Same 6-ship 3v3 Match, 1080p, via a purpose-built harness (Game/tools/gpu_profile_harness.gd) run directly against a real GPU-bound X session (not Xvfb — an earlier attempt through Xvfb silently fell back to Mesa's llvmpipe software rasterizer, ~35x slower and completely unrepresentative; caught via the harness's own adapter-name check, not assumed). This is the number that matters — an actual discrete immediate-mode GPU, the architecture players will actually have:
| 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 |
This overturns §5.5.1's conclusion, not just its numbers. On real hardware, disabling every effect gives a 3.5× speedup — the opposite of the Mac's ~1.03× — and the per-effect breakdown finally makes physical sense instead of clustering suspiciously:
| Setting off | Frame time | Implied cost |
|---|---|---|
| (baseline, all on) | 1.85 ms | — |
| SDFGI | 1.49 ms | 0.36 ms |
| SSIL | 1.60 ms | 0.25 ms |
| Glow | 1.75 ms | 0.10 ms |
| Shadows (all 5 casters) | 1.76 ms | 0.09 ms |
| SSAO | 1.82 ms | 0.03 ms |
| MSAA 4×, FXAA, PostFX | 1.87–2.12 ms | noise-level (see below) |
SDFGI and SSIL alone account for over half of the effects' total cost, matching §5.4's original expectation (voxel cone tracing and a full-res screen-space GI pass being the expensive ones) — the Mac's flat, undifferentiated cost profile was the anomaly, not this one. MSAA/FXAA/PostFX show negative "costs" (disabling FXAA measured as slightly slower than leaving it on) — at ~1-2 ms absolute frame times, OS scheduling jitter is larger than the real signal for cheap passes; those three need a longer sampling window or a proper GPU profiler to resolve, not this harness's coarse get_process_delta_time() sampling. Note also that all-off (0.53 ms) is faster than baseline-minus-sum-of-individual-savings (1.85 − 0.36 − 0.25 − 0.10 − 0.09 − 0.03 ≈ 1.02 ms) — the combined removal saves more than the parts, consistent with each full-screen pass carrying some fixed per-pass overhead (pipeline barriers, render-target switches) on top of its own work, which compounds when several stack.
Consequence for 0.17/0.26/0.28, revised: at 540 fps p50 with every effect enabled, this scene is nowhere near GPU-bound on reference-class hardware — the entire "must hit 144 fps" framing in §5.4/§5.5 was solving a problem that doesn't exist on the hardware tier it was written for. That reframes the two gated tasks rather than clearing them outright:
- 0.26 (bake GI, retire SDFGI) — the relative win is real and correctly targeted (SDFGI is the single largest line item, ~19% of the effects-on budget), and the preset design already bets on this being right (Low/Medium turn SDFGI+SSIL off first, matching exactly what this data says to cut). But "largest frame-time reduction of any task here" (its acceptance bar) oversells it on a 3090 — 0.36 ms off an already-tiny budget is not the headline win §5.7 implied. The task is worth doing for lower-end/integrated GPUs, where the same relative cost almost certainly scales to something that matters — but that's now the open question, unmeasured on this pass.
- 0.28 (physics/3d/run_on_separate_thread) — its whole motivation is smoothing frame-time variance caused by the physics tick sharing the render thread; at a 1.85 ms p50 / 2.98 ms p99 baseline (both far under even a 240 Hz frame budget), there's no variance problem to fix on this hardware. Deprioritize below 0.26 unless a lower-end-hardware pass shows otherwise.
- The preset ladder itself (task 0.17, done) needs no changes — its bundle choices (drop SDFGI/SSIL first) are now empirically justified rather than just plausible-sounding.
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 earns its keep. Re-run gpu_profile_harness.tscn on weaker hardware before spending more effort on 0.26/0.28.
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² (thrust_power 150 / mass 5) |
0.022 m | 0.069 m |
| position, turbo | 75 m/s² (turbo_multiplier 2.5) |
0.054 m | 0.173 m |
| yaw | 20 rad/s² (rotation_power 20 / inertia.y 1) |
0.8° | 2.6° |
| pitch / roll | 2.9 rad/s² (inertia.x/z 7) |
0.1° | 0.4° |
0.17 m and 2.6° worst case, against a 4 m hull. That is well under the width of the ship and an order of magnitude smaller than the 3.5 m staleness §4.1 was written to eliminate. 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.4b'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.
The reachable budget
| 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. That is inside the reference band, reached without disabling SDFGI, SSIL, SSAO or shadows. Even a client struggling at 30 fps on maximum settings lands near ≈120 ms.
Sequencing follows ms-per-unit-of-risk: L4 then L1 for v1 (≈127 ms, no bot retrain, no protocol change); L2 and L3 after, when a retrain is affordable. §5.5's preset system remains worth building — but for frame rate and thermals, which is what it actually buys, not for latency.
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. Phase 6 owns it, and it should be argued against these numbers.
Perspective on where this matters. Own ship and ball are already at ≈52 ms and are unaffected by every lever here — they are predicted locally. World response governs opponent ships. In a game whose subject is a ball, that ordering is favourable: the two objects a player tracks most closely are the two already at single-digit-tick latency.
5.7 The next tier — and where it stops paying
§5.5 and §5.6 are the first-order work. This section is what remains after them, and it is deliberately honest about the point where further effort stops being worth it.
Frame rate: SDFGI is the wrong tool for this arena
The single largest available win, and it costs no visual quality. arena.gd and 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. The only things that move are 6 ships and a ball, all small and all self-lit.
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 (ship_camera.gd:126, 137, 156). It is the most expensive thing in the frame, doing continuous work to solve a problem this project does not have.
- Replace
sdfgi_enabledwith baked GI —LightmapGIfor the static shell, orVoxelGIif bounce onto moving ships matters. Bake cost is offline; runtime cost is a texture fetch. The look is preserved or improved (baked bounce is higher quality than SDFGI's cascades), and it survives on the High preset rather than being the first thing a preset has to switch off. ssil_enabledbecomes largely redundant once bounce is baked. It is a full-resolution screen-space pass duplicating information the lightmap already has.
This is the answer to "lowest lag and highest fps without lowering settings": the expensive setting was solving the wrong problem.
Frame rate: expensive defaults that project.godot never overrides
[rendering] contains exactly three keys (msaa_3d, screen_space_aa, use_debanding). Everything else runs at engine defaults, including:
| Setting | Default | Note |
|---|---|---|
lights_and_shadows/positional_shadow/atlas_size |
4096 | Shared by all shadowed positional lights; 2048 is usually indistinguishable here |
lights_and_shadows/directional_shadow/size |
4096 | |
lights_and_shadows/directional_shadow/soft_shadow_filter_quality |
high | |
occlusion_culling/use_occlusion_culling |
off | Low value in an enclosed arena — measure before adding bake time |
mesh_lod/lod_change/threshold |
— | Irrelevant: the scene is ~1450 triangles of arena plus low-poly ships (§5.5) |
Also worth counting: _build_movement_vfx creates two OmniLight3Ds per ship (ship.gd:270-278), so a 3v3 has 12 dynamic lights on top of the arena's 5. They are correctly shadow_enabled = false and omni_range = 3.5, so they are cheap — noted so nobody "discovers" them and disables engine glow for nothing.
Frame rate: the CPU side, which §5.5 does not cover
§5.5 establishes the project is GPU-bound on full-screen passes. Once those are fixed it becomes CPU-bound, and §5.4's frame-time variance becomes the ceiling. Three levers:
physics/3d/run_on_separate_thread(not set; defaults off). This decouples the physics step from the render thread and directly attacks "one frame in four carries the whole tick." It is the highest-leverage item here and the riskiest — it changes when_integrate_forcesruns relative to script code, and this project puts real logic there (ship.gd:346-357) plus an RL training path. Prototype and measure; do not enable on faith.ArenaBoundary.get_surface_pullhas no early-out. It runs ato_local()plus five_falloffcalls for every dynamic body every tick, including for a ball sitting in the middle of the arena where every term is zero. A single bounds check againstwall_range/ceiling_rangeskips almost all of it in open play — 7 bodies × 120 Hz once L2 lands.- Bot inference is ~6.5k GDScript multiply-accumulates per bot (
policy_network.gd). Task 0.8 staggers them; beyond that, the lever is network width, which is a training decision, not a rendering one.
Latency: what is actually left
After L1–L4 and 120 Hz simulation, at 144 fps, the budget is ≈94 ms — and 60 of that is RTT. The remaining 34 ms of local overhead breaks down as input freshness 5.5, tick wait 4.2, jitter 4.2, server 4, extrapolation residual 8, physics interpolation 4.2, present 3.5. Every one of those is at or near a floor set by physics rate or hardware.
Two code ideas remain, both small and both with a cost:
- Forward-extrapolate the local
$Visualinstead of interpolating between the last two ticks — render the predicted ship at present time rather than up to one tick behind. Worth ~4 ms. Risk: overshoot at the moment of a collision, which is the most visually sensitive moment in the game. - Tighten the extrapolation-error smoothing (§5.6's 8 ms residual). Worth ~4 ms, paid for in more visible correction pops.
That is the whole remaining code budget: ~8 ms, both items trading visual stability for it. Meanwhile:
- Regional server siting takes a 60 ms RTT to 30 for most players: −30 ms, four times the remaining code budget, no code at all.
- Ping-weighted matchmaking and a server browser sorted by measured ping convert that into something players actually experience rather than something that is true on average.
- Steam Datagram Relay (Phase 7) is planned for NAT traversal and DDoS protection, but Valve's backbone frequently routes better than raw BGP paths — for some player pairs SDR is a latency reduction, not a tax. Measure it both ways rather than assuming it costs.
Where this stops paying
Two limits worth writing down before someone spends a month on the last 5 ms:
- Past ~100 ms, you are optimising 3–4 ms at a time against a 60 ms constant. The ratio of engineering effort to felt improvement collapses. Server siting and matchmaking dominate everything else from that point on.
- "Lowest lag" and "best feel" diverge at the end. Both remaining code levers, and L1 itself, buy milliseconds by predicting further ahead and correcting harder. Past a point that makes the game feel worse — twitchier, less stable, more prone to visible snapping — while the latency number keeps improving. The number is a proxy, not the goal. Task 4.7's tuning pass, with a human in the seat, 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. (A client at 30 Hz advances its sequence numbers at half rate and confuses every control loop.)auth_ticketis an emptyPackedByteArrayuntil Phase 7 — reserve the field now. - 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 —networked_match.tscnmust have no HUD child, becauseGameMode._ready()(game_mode.gd:44-45) would pick it up server-side. 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. That is what makes reliable-channel latency harmless: on a lossy link ENet's RTO can stretch a goal_scored → kickoff → state_change burst to ~600 ms. 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.
NetworkedMatch must declare all five signals HUDController duck-types on (HUDController.gd:65, 88, 100, 103, 106) — timer_updated, score_changed, match_ended, kickoff_countdown, overtime_started — and emit them from RPC handlers instead of from local logic. Otherwise the HUD silently omits rows.
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.
HUDController._initialize_hud() push_errors and bails when ship is null (HUDController.gd:41-46). Spectators need a path through that.
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, exactly the placeholdergame_mode.gd:216already uses.
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.
ai_ship_controller.gd currently caches teammate/opponent lists once with the comment "rosters never change mid-match (no despawn path exists anywhere in this codebase)". Do not let that be the justification — protecting a bot's implementation detail is tail-wagging-dog, and taken as an architectural constraint it permanently forecloses 3v3→2v2 shrink, mid-match rebalancing, and join-onto-a-new-slot. Fix the cache anyway (task 0.9): filter(is_instance_valid) plus a roster_changed signal, ~5 lines of cheap insurance.
7. Phase and task breakdown
[P] parallelisable within its phase · [D:x.y] hard dependency
Phase 0 — Non-networked refactors
Every task lands on master independently, is verifiable in single-player today, and cannot break anything. Near-total parallelism.
| # | Task | Files | Acceptance |
|---|---|---|---|
0.1 [P] |
DONE. Added ShipAction.copy(). Audit: the only get_action() call site (ship.gd:347) reassigns _current_action fresh each tick rather than buffering it, so no aliasing bug exists yet — copy() is a no-op today, ready for Phase 4's prediction ring |
ship_action.gd, player_ship_controller.gd |
Free Play unchanged; copy() returns a distinct object with equal fields |
0.2 [P] |
DONE. Inserted Visual (Node3D) into ship.tscn, reparented Nose/TailFin under it, redirected all four code-driven add_child calls onto $Visual (now a public @onready var visual), resolved _apply_team_color's lookup to "Visual/" + mesh_name |
objects/ship.tscn, scripts/ship.gd |
Child-type assertion holds; ship looks identical in Free Play; team colours still apply on both teams |
0.3 [D:0.2] |
DONE. ship_camera.gd's three target.global_transform reads (ball cam, ship cam ×2) now read target.visual.global_transform |
scripts/ship_camera.gd |
Camera behaviour unchanged in Free Play and Match — visual has identity transform relative to the body until Phase 4 writes an offset, so this is a no-op today |
0.4 [P] |
DONE. can_sleep = false on Ship and Ball |
objects/ship.tscn, objects/ball.tscn |
No behaviour change |
0.5 [P] |
DONE. continuous_cd = true on Ship (Ball already had it) |
objects/ship.tscn |
No tunnelling at max speed into the ball or walls |
0.6 [P] |
DONE. Spawned ships renamed to Ship_T%d_S%d |
game_mode.gd |
Names are (team, spawn_index)-derived, not insertion-order |
0.7 [P] |
DONE. _jittered now uses an owned RandomNumberGenerator, self-randomized in _ready() unless kickoff_rng_seed is set explicitly (a fresh RandomNumberGenerator defaults to a fixed internal state, unlike the global randf_range Godot auto-randomizes at startup — call this out for whoever reads the diff and expects .new() alone to be enough) |
game_mode.gd |
Kickoff jitter unchanged in feel; a fixed seed reproduces kickoffs exactly |
0.8 [P] |
DONE. _ticks_until_decision = randi_range(1, reaction_ticks) at spawn, after load_policy() (which still resets to 0 on later calls, e.g. league opponent swaps — harmless, those land at reset boundaries) |
ai_ship_controller.gd |
Six-bot Spectate shows no periodic frame spike |
0.9 [P] |
DONE. Roster validity checked (Array.any()) once per decision tick, not every physics tick; filter(is_instance_valid) + roster_changed signal only fire on an actual stale reference |
ai_ship_controller.gd |
Bots behave identically; freeing a ship mid-match no longer corrupts observations |
0.10 [D:0.12] [P] |
_owns_goal_logic(), _allows_time_scale_effects(), _goal_pause_seconds(), _owns_world_simulation()_allows_time_scale_effects() dropped: 0.12 deletes Engine.time_scale from the file entirely, so there is nothing left for it to gate. Implemented _owns_goal_logic(), _owns_world_simulation(), _goal_pause_seconds(), all behaviour-preserving (default true/GOAL_CELEBRATION_SECONDS), gating the goal-signal connection and _respawn_escaped_bodies() |
game_mode.gd |
Free Play, Match, Spectate and Training all behave identically — verified no other virtual was load-bearing today; these exist for a future networked-client mode |
0.11 [P] |
DONE. _handle_goal_scored checks is_inside_tree() after each await and bails before touching arena/hud state |
game_mode.gd |
A scene change mid-celebration cannot strand the flag |
0.12 [P] |
Engine.time_scale hit-stop and goal slow-mo with camera-only effectsShipCameraRig's "Impact Punch" group (punch_fov_kick/punch_vignette_kick/punch_chroma_kick/punch_decay, applied additively after _update_speed_feel each tick, decaying via move_toward over real delta) triggered from the existing _on_target_ball_contact; goal moments now rely on the pre-existing begin_goal_cut/end_goal_cut cinematic cut alone, no separate slow-mo effect needed. All Engine.time_scale fields/methods deleted from game_mode.gd (_hit_stop_*, _goal_slowmo_active, _restore_hit_stop, _run_hit_stop, GOAL_SLOWMO_SCALE) |
game_mode.gd, ship_camera.gd |
Goal and impact feel is at least as good; Engine.time_scale is never written — confirmed via grep -rn time_scale scripts/ |
0.13 [P] |
DONE. physics_jitter_fix = 0.0 set. CLAUDE.md's architecture section had stale prose dimensions ("inner x ±12, z ±18, height 12, goal lines z ±17") — corrected to reference the actual named constants (INNER_HALF_X 18, INNER_HALF_Z 27, INNER_HEIGHT 18, GOAL_LINE_Z = INNER_HALF_Z) instead of restating numbers that can drift out of sync again |
project.godot, CLAUDE.md |
Flight feel unchanged; CLAUDE.md matches arena_boundary.gd:8-14 |
0.14 [D:0.2] |
DONE. Added Ship.set_visual_action(thrust_z, turbo), Ball.set_visual_speed(speed) (with a _visual_speed_override field the trail prefers when ≥0), and Ship.net_vel_correction/net_visual_offset fields plus the guarded hook at the top of _integrate_forces (decays net_visual_offset via _tick_scaled, writes it to visual.position) |
ship.gd, ball.gd |
No-op until Phase 4; single-player unchanged — nothing calls any of these yet |
0.15 [P] |
DONE. Ship/Ball gained queue_teleport(to); _integrate_forces applies it via state.transform + zeroed velocities + reset_physics_interpolation(). GameMode._reset_body now calls body.call("queue_teleport", to) (dynamic dispatch — RigidBody3D itself has no such method) instead of set_deferred |
game_mode.gd, ship.gd, ball.gd |
Kickoff resets in Match are visually identical, with no interpolation smear |
| 0.15b | DONE, superseded by §5.5.2 — read that, not the Mac numbers below. First pass measured a live 6-ship Match, 1080p, on an Apple M4 dev laptop (§5.5.1): all-on p50 17.93 ms, all-off floor ~17.2 ms, with per-effect costs clustered suspiciously flat (2.9–3.8 ms each). That data turned out to be a poor stand-in for the target platform — Apple's tile-based GPU architecture, not a real bottleneck — and was superseded by a same-scenario re-run on real reference hardware (RTX 3090, §5.5.2): all-on p50 1.85 ms / all-off 0.53 ms, SDFGI+SSIL clearly dominant as originally expected, everything else cheap. Keep §5.5.1 for the record of what was tried and why it was distrusted, not as a performance reference | scenes/arena_base.tscn, shaders/post_process.gdshader, Game/tools/gpu_profile_harness.gd |
Measured max frame rate written into §5.5.2 from real reference hardware. At 540 fps p50 with everything on, this scene is nowhere near GPU-bound on a 3090-class GPU — the a priori §5.4 fps list was solving for a constraint that doesn't hold at that hardware tier. 0.17 (done) needed no changes: its preset bundle choices are now empirically validated. 0.26 stays open (real but smaller win than assumed); 0.28 closed (no variance problem exists to fix) |
0.16 [D:0.3] |
DONE. Camera rig moved _physics_process → _process; reads target.visual.get_global_transform_interpolated() in both ball-cam and ship-cam; rig itself has physics_interpolation_mode = OFF (it writes its own transform every rendered frame now, so Godot's built-in interpolation would just fight the manual smoothing). target setter primes interpolation (target.visual.reset_physics_interpolation()) and calls the new snap_to_target() so a freshly-assigned target (or a Spectate switch) doesn't lerp in from wherever the rig was previously. Shake re-derivation, implemented differently than drafted: rather than rescale frequency, _apply_shake now quantizes the noise-domain input to whole 60Hz ticks (floori(_shake_time * SHAKE_UPDATE_HZ)) — every render frame within one 1/60s window reuses the identical noise sample, so consecutive distinct samples stay exactly frequency (2.5) domain-units apart at any render frame rate, reproducing 60fps's original jitter character everywhere instead of smoothing out at high fps. snap_to_target() is called from game_mode.gd's reset_ships(), not directly from ship_camera.gd's own kickoff-adjacent code — reset_ships() is now async and awaits one get_tree().physics_frame before snapping, because _reset_body's queue_teleport (task 0.15) defers the actual transform write to the ship's next _integrate_forces; snapping immediately would read the pre-teleport position. Goal-cut shake decay extracted into _decay_shake(), called from the _goal_cut_active branch. Validated: scripts compile, Free Play renders correctly non-headless, reset produces no camera jump, all three headless scenes exit clean |
scripts/ship_camera.gd, scripts/game_mode.gd:reset_ships |
Turbo FOV kick and post-process are smooth at an uncapped frame rate; shake reads the same at 60 and 240 fps; a kickoff cuts the camera rather than lerping it across the arena |
0.17 [D:0.15b] |
DONE. VideoSettings gains Preset (Low/Medium/High/Custom) driving a bundle (sdfgi_enabled, ssil_enabled, ssao_enabled, shadows_enabled, glow_enabled, aa_mode, resolution_scale) via apply_preset(); a settings_changed signal lets an already-loaded arena re-apply live (arena.gd connects in _ready()) rather than only affecting the next arena load — meets "settings persist and apply without a restart" without needing a scene reload. Shadow gating targets the actual Light3D nodes (found once at load via find_children, cached, re-applied on every settings change — deliberately not re-derived from current state each time, since a light this code just turned off would otherwise become indistinguishable from FillLight, which is authored shadow_enabled = false on purpose and must never be turned on by the preset ladder). vsync_mode (Disabled/Enabled/Adaptive, Adaptive default) and fps_cap_divisor (0 = uncapped, else divides the live refresh rate at apply time rather than storing a raw fps number, so the same preference re-derives correctly on a different display) added to the settings menu; FPS cap dropdown is disabled (greyed) unless VSync is Disabled; refresh-rate query ≤0 falls back to "Uncapped" only. Live fps readout via _process reading Performance.TIME_FPS. main_menu.gd's _leave_to_gameplay now calls VideoSettings.apply_fps_cap() instead of hardcoding Engine.max_fps = 0, so the player's cap actually reaches gameplay scenes. Acceptance numbers: not run as a literal Low-vs-High preset A/B, but strongly implied by §5.5.2 — real hardware (RTX 3090) runs the High-equivalent (all effects on) at 540 fps p50 already, so Low (which additionally turns off the two dominant costs, SDFGI+SSIL) clearing "≥2×" is close to guaranteed rather than measured directly; the flat-p99-histogram claim genuinely wasn't tested (gpu_profile_harness.gd measures per-toggle cost, not vsync/cap histograms) |
scripts/video_settings.gd, scripts/settings_menu.gd, scenes/settings.tscn, scripts/arena.gd, scripts/main_menu.gd |
Low preset ≥2× the frame rate of High on the same hardware; settings persist and apply without a restart; every offered cap gives a flat frame-time histogram (p99−p50 < 1 ms) with VSync disabled on a 144 Hz and a 165 Hz display; refresh-rate query returning -1 falls back cleanly |
0.17b [D:0.15b] [P] |
DONE. VideoSettings.resolution_scale (0.5–1.0, default 1.0) drives Viewport.scaling_3d_mode/scaling_3d_scale/fsr_sharpness via apply_resolution_scale() — SCALING_3D_MODE_FSR2 below 1.0 (chosen over bilinear: this project already gave up native resolution at the fixed-1080p blit per 0.17c, so FSR2's sharpening recovers more of that loss than a plain bilinear upscale at the same internal scale), SCALING_3D_MODE_BILINEAR with scale pinned to 1.0 at the top of the range (a no-op scaling mode when the scale is 1:1). Low preset defaults to 0.8. Exposed as a slider in the settings menu; not yet measured against the "0.7 scale gives a large, measurable frame-time drop" bar — same real-hardware caveat as 0.17 |
scripts/video_settings.gd, settings_menu.gd |
0.7 scale gives a large, measurable frame-time drop with acceptable image quality; setting persists |
0.17c [D:0.17b] |
DONE — decided, not changed. Kept stretch/mode="viewport" fixed at 1080p rather than moving to "disabled", documented inline in project.godot [display] with rationale: 0.17b's scaling_3d_scale already covers "render lower than the window" independently of stretch mode (it scales the 3D viewport's internal resolution before this blit, not the window itself), and separately, task 0.15b found an unexplained ~6% non-uniform width scaling on the one machine this was tested on (2036×1080 measured against a 1920×1080 target — see §5.5.1) that needs understanding before stretch mode is touched, not blindly carried into a resolution-dependent change |
project.godot |
The decision and its rationale are written into §5.5; render resolution follows the player's setting |
0.17d [P] |
INVESTIGATED — no such lever exists in Godot 4.7. Searched the full project.godot schema (read_project_settings) for rendering/rendering_device/vsync/frame_queue_size and every variant (frame_queue, swapchain, present, present_queue) — none exist as a project-settable parameter in this engine version; the RenderingDevice backend may manage its own present queue internally but doesn't expose it. Adaptive vsync (task 0.17, done) is the only half of "L4" actually achievable through project settings. The §5.6 ~17 ms figure for a shallow present queue is therefore not obtainable as specced — closing this without a code change is correct here, not a shortfall; reaching it would need engine-level (C++/RenderingDevice) changes out of scope for a project-settings task |
||
0.18 [P] |
DONE, with one discovered GDScript constraint. New scripts/sim_constants.gd (class_name SimConstants, plain const TICK_HZ := 60, not an autoload) is the source of truth for ship.gd's _tick_scaled and training_mode.gd's TICKS_PER_SIM_SECOND — both reference it via const SimConstants = preload("res://scripts/sim_constants.gd") rather than the bare global class_name symbol, because a cross-script const X := f(OtherClass.CONST) initializer needs the reference resolved before the global class table is guaranteed populated. @export_range() upper bounds cannot take even a preloaded reference — export hint arguments must be true literals — so reaction_ticks/bot_*_reaction_ticks (ai_ship_controller.gd, match_mode.gd, spectate_mode.gd ×2) stay at a literal 60; these are editor-inspector slider bounds, not the timing math itself, so this doesn't reopen the bug the task exists to close, but it means the acceptance criterion below is met for tick-rate math and not for export-hint bounds |
ship.gd, training_mode.gd, new scripts/sim_constants.gd |
Tick-rate-derived timing math has no bare 60; changing TICK_HZ changes _tick_scaled and TICKS_PER_SIM_SECOND coherently. reaction_ticks export bounds remain literal by GDScript necessity |
0.19 [P] |
DONE. AAMode gained MSAA_2X, appended (not inserted) so existing user://settings.cfg ordinals keep their meaning; default aa_mode changed to FXAA; settings_menu.gd's AA_OPTIONS now lists five entries |
video_settings.gd, settings_menu.gd |
Five AA options; default is FXAA; existing saved preferences migrate without resetting |
0.20 [P] |
DONE. New autoload scripts/perf_overlay.gd (PerfOverlay), toggled by a new toggle_perf_overlay input action (F3 default). Headless-guarded; builds its own Label in code rather than touching HUD.tscn |
new scripts/perf_overlay.gd, project.godot [input] |
TIME_PROCESS vs total frame time tells the player whether they are CPU- or GPU-bound |
0.21 [P] |
DONE. Shared HudInstrument._throttled_redraw(delta) paces queue_redraw() to ~60/s; value smoothing itself still runs every _process call, only the repaint is throttled |
scripts/hud_instrument.gd, scripts/hud_gauge.gd, scripts/hud_attitude_indicator.gd, scripts/hud_heading_tape.gd |
HUD is visually identical; instrument _draw call count is capped at ~60/s regardless of frame rate |
0.22 [P] |
DONE. Engine.max_physics_steps_per_frame = 4 set in GameMode._ready(), applies to every mode including headless Training |
scripts/game_mode.gd |
A client throttled to 20 fps degrades smoothly instead of compounding |
0.23 [P] |
DONE. New autoload scripts/background_fps.gd (BackgroundFPS) drops to 30 fps on NOTIFICATION_APPLICATION_FOCUS_OUT / restores on focus-in, independent of scene. main_menu.gd/settings_menu.gd each cap to DisplayServer.screen_get_refresh_rate() in _ready() (falling back to uncapped on a -1 query); leaving the main menu for a gameplay scene uncaps again via a new _leave_to_gameplay() helper, since gameplay has no cap of its own yet (0.17) |
new scripts/background_fps.gd, main_menu.gd, settings_menu.gd |
An unfocused window and an idle menu both stop rendering at 900 fps |
0.24 [P] |
DONE. Both guarded with if DisplayServer.get_name() == "headless": return — arena.gd:_ready() skips the whole Environment block, video_settings.gd:_ready() skips apply_aa() |
scripts/arena.gd, scripts/video_settings.gd |
--headless allocates no Environment and no AA state |
0.25 [P] |
DONE. _process still calls to_local() every frame (needed for the comparison itself) but skips set_shader_parameter() — the actual GPU-facing cost — below a 0.05 m movement threshold |
scripts/arena_boundary.gd |
Field shader behaves identically; the expensive call is skipped on most frames |
0.26 [D:0.15b] |
Bake the arena GI and retire SDFGI (§5.7). arena.gd/goal.gd have no _process, no animation — the arena is fully static, and SDFGI is paying continuously to solve a dynamic-world problem this project does not have. Add UV2 to the arena shell, bake LightmapGI (or VoxelGI if bounce onto ships matters), disable sdfgi_enabled and re-evaluate ssil_enabled |
scenes/arena_base.tscn, scenes/arena_0*.tscn, scripts/arena_boundary.gd |
Largest frame-time reduction of any task here, with equal or better image quality; High preset keeps its look; bake is reproducible from a documented step |
0.27 [P] |
DONE. lights_and_shadows/positional_shadow/atlas_size and directional_shadow/size set to 2048 (from the 4096 engine default), soft_shadow_filter_quality=2 |
project.godot |
Measurable frame-time reduction; no visible shadow-quality regression at 1080p |
0.28 [D:0.15b] |
CLOSED, not implemented — the problem it targets doesn't exist. Was: prototype physics/3d/run_on_separate_thread (§5.7) to attack frame-time variance from the physics tick sharing the render thread — the riskiest item in this phase, since it changes when _integrate_forces runs relative to script code, and both ship.gd:346-357 and the RL training path depend on that. §5.5.2's real-hardware measurement (RTX 3090) found a 1.85 ms p50 / 2.98 ms p99 baseline with every graphics effect enabled — both comfortably under even a 240 Hz frame budget, with no meaningful p99-over-p50 variance to explain away. Taking on this task's real risk (reordering _integrate_forces relative to script code, with the RL training path depending on today's ordering) for a variance problem that isn't measurably present is a bad trade. Reopen only if a lower-end-hardware pass (§5.5.2's "still open" item) finds real physics-tick-driven variance that 0.26 and the preset ladder don't already cover |
— | (closed without a code change; see §5.5.2 for the evidence) |
0.29 [P] |
DONE. Bounds check against wall_range/ceiling_range at the top of get_surface_pull, returning Vector3.ZERO before to_local() and the five _falloff calls whenever every term would be zero mid-arena |
scripts/arena_boundary.gd |
Identical flight feel and identical RL observations; measurable tick-time reduction with 7 bodies |
These tasks exist because of the high-refresh-rate mandate, and their order matters. 0.15b blocked everything else, and did invalidate the a priori fps list — but not in the direction first assumed (see §5.5.1 vs §5.5.2): on the Mac the game looked GPU-bound and undifferentiated; on real reference hardware (RTX 3090, §5.5.2) it runs at 540 fps p50 with everything on, nowhere near bound by anything. 0.17/0.17b/0.19 (done) are still the right frame-rate levers — SDFGI/SSIL genuinely dominate the optional-effects cost, exactly as originally assumed, just at a much smaller absolute scale than feared on this hardware tier. 0.16 and 0.20–0.25 are the per-frame hygiene that makes a high frame rate worth having. 0.18 buys nothing today — it is what keeps a future 120 Hz simulation a config change plus a retrain rather than a protocol rewrite. 0.28 closed without a code change (§5.5.2) — the frame-time variance it targeted isn't measurably present on reference hardware.
0.19–0.29 are all pure single-player wins with no netcode content. If the multiplayer effort is ever paused, they should still land. Within them, 0.26 (bake the GI) is the largest single frame-time win in the document and costs no image quality — the arena is fully static, so SDFGI is paying continuously for a problem this project does not have (§5.7). 0.28 is the riskiest; it is the only Phase 0 task that can plausibly need reverting.
Correction — task 0.2 is wider than an earlier draft claimed. That draft argued the refactor was "narrow" because
_build_merged_hulland_build_movement_vfx"onlyadd_child()". That is exactly the problem: theyadd_child()ontoself, theRigidBody3D—ship.gd:208(MergedHull: Hull, Canopy, EngineGlowL/R),:241(engine cores),:268(flames),:278(lights). Leave those and §4.4's soft correct offsets onlyNoseandTailFinwhile the hull, canopy, glows, flames and lights stay welded to the corrected collider — every correction visibly tears the ship in half. The old acceptance criterion ("looks identical in Free Play") passes either way, which is why the criterion is now a child-type assertion.ship.gd:218's controlleradd_childcorrectly stays on the body;CollisionShape3Dstays on the body.Still true from that draft, and re-verified:
ship.gd:44-47documents whyNose/TailFinremain separateMeshInstance3Ds, and the RL path is untouched —ship_observations.gdreads onlyglobal_position, basis, velocities andPhysicsServer3Dcontacts, andtraining_mode.gd's onlyget_nodeisarena.get_node("Boundary").
Phase gate: the game plays identically to master in Free Play, Match, Spectate, and headless Training, with Engine.time_scale never written — and additionally: §5.5 contains a real measured frame-time table (0.15b), the Low preset roughly doubles the frame rate of High (0.17), and the game looks correct uncapped on a high-refresh display with no 60 Hz stepping in FOV, shake or post-process.
Phase 1 — Transport, connection, lobby
| # | Task | Acceptance |
|---|---|---|
| 1.0 | DONE, two real bugs found and fixed after adversarial review. tests/test_runner.tscn + test_runner.gd: discovers every *.gd under tests/cases/, instances it, calls every test_*() method via get_method_list(), aggregates failures, get_tree().quit(1 if failed else 0). tests/test_case.gd is the assertion base (assert_true/assert_eq/assert_almost_eq); case scripts use extends "res://tests/test_case.gd" (path-based) and the runner uses preload(), not a bare class_name reference — the global script-class cache isn't guaranteed populated on a fresh headless run (same class of issue as task 0.18's SimConstants). tests/cases/test_smoke.gd proves discovery/dispatch/aggregation and is the first real case file. An Opus subagent's adversarial review found: (1) GDScript has no exceptions, so a test that hit a runtime error before its first assert_* call left failures empty — exactly like every assertion passing — and was silently counted as a PASS. Fixed: TestCase now tracks assertions_made, incremented by every assert_*; the runner treats zero assertions as a failure in its own right ("made no assertions"). (2) A case file with a parse/compile error hung the whole runner forever — load() on a broken script does not return null here, it returns a non-null but uninstantiable GDScript resource, so a plain null check doesn't catch it; calling .new() on it threw an error severe enough to abort _ready() before ever reaching quit(). Fixed with Script.can_instantiate() as the real guard |
godot --headless --path Game res://tests/test_runner.tscn runs and exits 0; verified exit 1 with a deliberately-failing assertion, then removed. Re-verified both fixes with scratch case files (not committed): a test that null-derefs before asserting now correctly fails with "made no assertions" (exit 1, not a false pass); an uncompilable case file now fails loudly and promptly (exit 1, not a 124-timeout hang) while the other valid case files in the same run still execute normally |
1.1 [D:1.0] [D:0.18] |
DONE. scripts/net_codec.gd: protocol constants, PacketType enum, channel ids, i16/i8/thrust-z-bin quantisers, pack_input/unpack_input, pack_snapshot_body_segment/pack_snapshot_client_header/pack_snapshot/unpack_snapshot. New scripts/net_body_state.gd is the plain per-body data holder the snapshot functions read/write (not Ship/Ball themselves, so the codec stays callable with no scene tree). NetCodec.TICK_HZ derives from SimConstants.TICK_HZ via preload() (same cache-timing reason as 0.18); ring sizes / seq windows / INTERP_DELAY / timeouts don't exist as constants yet — they land with the tasks that consume them (3.1+), so "derives from TICK_HZ" is satisfied for what exists today |
scripts/net_codec.gd, scripts/net_body_state.gd, tests/cases/test_net_codec.gd |
1.2 [D:1.1] |
DONE, strengthened after adversarial review. scripts/network_manager.gd autoload (NetworkManager in project.godot [autoload]): host(port, max_clients)/join(address, port)/shutdown(), client_connected/client_disconnected/connected_to_server/connection_failed/disconnected_from_server signals forwarded from multiplayer's own, server_relay = false set the moment a peer exists, is_server/is_client state. Gained a shutting_down() signal, emitted at the top of every shutdown() regardless of role or reason — see task 1.4's row for why |
An Opus subagent's adversarial review (independently verified by the primary session before applying fixes) found the original tests/net_smoke.gd only proved each process exits cleanly on its own initiative, never that the OTHER peer actually observes the disconnect. Rewrote it: the host now waits for both client_connected and client_disconnected before passing; the client explicitly calls shutdown() mid-test (not just on process exit) and gives it a beat before quitting, same reasoning as §9 gotcha 26 for connects — a clean disconnect notice still needs a few poll() cycles to reach the wire, or the other side falls back to its ~5s peer timeout (gotcha 11) instead of a prompt one. Re-verified passing with both directions actually observed |
1.3 [D:1.2] |
DONE for what exists today. NetworkManager._ready() calls get_tree().set_multiplayer_poll_enabled(false) (Godot 4.7's actual method name — the doc's set_multiplayer_poll(false) was shorthand) and exposes NetworkManager.poll() as the one entry point every caller uses instead. Verified against tests/net_smoke.gd, updated to poll from both _process and _physics_process every frame — connect/disconnect still works cleanly under manual-only polling (§9 gotcha 26 still applies: give a beat after a connect signal before shutdown). The per-call-site placement this task specifies (client: end-of-physics-tick flush after input send, top-of-frame receive; server: tick-start drain, tick-end flush) has no real per-tick caller yet — there is no input/snapshot traffic until tasks 1.4+/Phase 2 exist to send any, so there's nothing to place a flush after. That placement, and the RTT/staleness measurement below, land with the input pipeline, not as a separate task |
godot --headless two-process test still connects/disconnects cleanly with automatic polling off (verified). RTT/staleness improvement not yet measured — deferred until Phase 2/3's real per-tick traffic exists to measure against, same honesty as task 1.1's "constants that don't fully exist yet" |
1.4 [D:1.2] |
DONE. scripts/match_net.gd autoload (MatchNet): _hello/_welcome/_player_joined/_player_left/_rejected RPCs, protocol_version (NetCodec.PROTOCOL_VERSION) and physics_ticks_per_second (SimConstants.TICK_HZ) checked on the server before a peer is added to roster; on mismatch, server sends _rejected with a readable string then disconnect_peer()s after a 0.3s beat (§9 gotcha 26 applies here too — a bare RPC then immediate disconnect would drop the rejection message). roster: Dictionary[int, PlayerInfo] never contains peer 1 (§1.1 decision 2). A new peer is told about the existing roster via targeted RPCs before the broadcast that tells everyone (including itself) about the new peer, so no client ever observes an unexplained peer_id |
Verified with a real two/three-process test (tests/match_net_smoke.gd/.tscn): matched client → both sides see player_joined/welcomed; deliberately wrong protocol version → client receives rejected("protocol version mismatch: server=1 client=100") and is disconnected. Caught and fixed one real bug in the process: the server's own roster update in _hello() didn't locally emit player_joined (the broadcast RPC is call_remote, never loops back to the sender) |
| — | Two more real bugs found by an Opus subagent's adversarial review, both confirmed independently and fixed. (1) _hello's player_name was completely unvalidated and broadcast verbatim to every peer — a demonstrated DoS: a multi-MB name relayed to all peers head-of-line-blocked the reliable control channel hard enough that a concurrently-joining client's own _welcome never arrived. Fixed with a hard MAX_INPUT_LENGTH = 256 reject (any legitimate client only ever sends local_player_name, which the UI already keeps short — anything past this is a bug or an attacker, not a name to politely truncate) followed by _sanitize_player_name(): strips control/formatting characters, clamps to MAX_PLAYER_NAME_LENGTH = 24, falls back to "Player" if empty. (2) MatchNet.roster was never cleared when a HOST stopped hosting — only the client-side disconnect path cleared it, so Host → Lobby → Leave → Host again left a phantom player in roster permanently, mis-balancing teams and getting broadcast to every future joiner. Fixed via NetworkManager's new shutting_down() signal (task 1.2), which MatchNet now clears roster on unconditionally, regardless of role or reason |
_sanitize_player_name is static (pure function of its argument) with 5 dedicated unit tests in tests/cases/test_match_net.gd, plus a live rejection test (match_net_smoke.gd --role=client-longname, a 500 KB name, confirmed rejected before ever reaching a broadcast). New regression test match_net_smoke.gd --role=host_recycle: host, client joins (roster.size()==1), host leaves and re-hosts, confirms roster.is_empty() before any new connection — reproduced the bug pre-fix, confirmed fixed post-fix |
1.5 [D:1.4] |
DONE, strengthened after adversarial review. scenes/lobby.tscn + scripts/lobby.gd: roster split into two team columns (dynamically rebuilt Label rows on MatchNet.player_joined/player_left/player_state_changed/welcomed), Switch Team + Ready CheckButton (server process gets a read-only view — never a roster member, §1.1 decision 2), Leave. MatchNet grew team/ready fields on PlayerInfo, a balanced-team auto-assign on join (_pick_balanced_team), and request_set_team/request_set_ready + their server-authoritative RPCs, broadcasting _state_changed the same way _player_joined already did |
Verified with a real two-process test (tests/lobby_smoke.gd/.tscn) that loads lobby.tscn via change_scene_to_file exactly as main_menu.gd's Host/Join flow (task 1.7) does, then presses the real %SwitchTeamButton/%ReadyButton nodes via a persistent test-only helper (tests/lobby_test_hooks.gd, not a project autoload — parented under get_tree().root so it survives the scene swap, never referenced by production code). An Opus subagent's adversarial review found the original test's host role never actually loaded lobby.tscn at all — it only hosted and waited, so lobby.gd's is_server branch (the read-only view a self-hosting player reaches via main_menu.gd's own Host button — a real, production-reachable path, not a hypothetical) had never run under this task's own suite. Fixed: the host role now loads lobby.tscn too and a new run_host_test() in the shared test helper verifies %ControlsRow is hidden, the roster row renders, and the status text is correct, holding the connection open long enough (MIN_HOST_LIFETIME_SECONDS) for the client's own longer flow to finish against it. Confirmed: roster renders correctly server- and client-side (now genuinely, not just asserted), team switch moves the row to the other column, ready toggle updates the checkbox and the label's ✓ marker, row count matches roster size on both peers |
1.6 [D:1.4] [P] |
DONE. scenes/server_boot.tscn + scripts/server_boot.gd: --port=/--max-clients=/--log-level= from OS.get_cmdline_user_args(), Engine.max_fps = 60, structured [elapsed] LEVEL event key=value… log lines for server_started/peer_connected/player_joined/player_left/peer_disconnected, and a physics-overrun watchdog comparing Engine.get_physics_frames() deltas frame-to-frame. Does not spawn a match yet — that's Phase 2's networked_match.gd — this is just the process shell: listen, log, idle cheaply. Two real bugs caught and fixed while verifying, both in the watchdog: (1) the very first _process() after boot compared against a pre-_ready() baseline and logged a spurious one-time steps=5; skip the first measurement. (2) the initial steps > 1 threshold fired continuously (every 30–100ms) on a perfectly idle, healthy server — because §9 gotcha 6 means frames legitimately alternate between 0 and 2 physics ticks under physics_jitter_fix = 0.0, not a flat 1/frame; that's quantisation, not backlog. Raised the threshold to steps > 2 (3+ ticks = the accumulator actually failing to drain), which produced zero false positives over a 4.8s idle run |
Verified with real headless runs: idle CPU measured via ps -o %cpu at 0.0% (bar is <5%); a real client connect/disconnect via tests/net_smoke.gd --port= produces exactly the expected 4-line log sequence with no spurious warnings |
1.7 [D:1.5] [P] |
DONE. main_menu.tscn gained a Multiplayer section (Host button; Join row with an IP LineEdit, default 127.0.0.1; inline error label) and a full-screen ConnectingOverlay (status label + Cancel). main_menu.gd: _on_host_pressed calls NetworkManager.host() then goes straight to lobby.tscn (synchronous — no overlay needed); _start_join calls NetworkManager.join(), shows the overlay, and starts an app-level CONNECT_TIMEOUT_SECONDS = 6.0 timer; _on_connected_to_server/_on_connection_failed/Cancel/timeout each resolve to the overlay hiding and either lobby.tscn or a visible error, gated by a token counter so a late/stray signal after the attempt was already resolved is a no-op |
Verified with real multi-process runs of scenes/main_menu.tscn itself (not a wrapper — driven by a temporary-autoload test helper, tests/main_menu_test_hooks.gd, pressing the real HostButton/JoinButton/ConnectingCancelButton) across all four paths: Host → lobby.tscn; Join → connects → lobby.tscn; Join with nothing listening → times out → error shown, stays on menu; Join → Cancel → overlay hidden, stays on menu, is_client false. Two real bugs found and fixed in the process, both pre-existing from earlier Phase 1 tasks, not new to 1.7: (1) NetworkManager's clock ping (task 1.8) gated only on is_client, which turns true the instant join() is called — a slow or refused connect attempt spammed "Trying to call an RPC via a multiplayer peer which is not connected" every frame; fixed by also requiring _peer.get_connection_status() == CONNECTION_CONNECTED. (2) ENet's own connection_failed proved unbounded in practice — verified empirically against a genuinely refused loopback connection, it hadn't fired even 14s in — which would have left a player staring at "Connecting…" indefinitely; task 1.7's own CONNECT_TIMEOUT_SECONDS is what actually satisfies "connection-refused reaches a sane UI state", not the built-in signal alone |
1.8 [D:1.2] [P] |
DONE, strengthened after adversarial review. Folded into network_manager.gd: client pings the server once a second (_ping/_pong RPCs, reliable, channel 0); clock_offset_ms is the min-RTT sample in a rolling 5s window (_clock_samples, pruned by wall time); get_server_time_estimate_ms() is the public API later phases (INTERP_DELAY, tick_offset seeding) will actually call; clock_updated(rtt_ms, offset_ms) signal for observers. New scripts/net_debug_overlay.gd autoload (F4, toggle_net_overlay input action) mirrors perf_overlay.gd's headless-guarded pattern, shows RTT + offset client-side or peer count server-side |
Verified with a real two-process test (tests/clock_smoke.gd/.tscn) on localhost: first sample at t=0.95s, offset converged to 1534.50ms by t=2.0s (well inside the 2s bar), and stayed within 1.5ms of that value through t=3.96s — comfortably under the ±1 tick (16.67ms) bar. An Opus subagent's adversarial review correctly pointed out this self-consistency check couldn't have caught a systematically-wrong-but-stable offset (e.g. a missing /2 on RTT, or a sign flip — it would converge just as cleanly). Fixed by adding an independent ground-truth cross-check: both host and client compute Time.get_unix_time_from_system()*1000.0 - Time.get_ticks_msec() (each process's own offset from the shared OS wall clock — the same real clock on both, since they're on the same machine), exchanged via a shared temp file written by the host, purely for test orchestration and touching no production code. The true required offset is just the difference of those two numbers; re-run measured the converged offset against it and found 0.99ms of error, comfortably inside a deliberately loose 250ms tolerance (OS wall-clock read resolution and sampling-instant skew, not NetworkManager's own precision, is what sets the tolerance floor here). Note the converged offset value itself is large and arbitrary (~1.5s) because Time.get_ticks_msec() counts from each process's own start, not a shared epoch — expected, and exactly what clock_offset_ms exists to absorb |
main_menu.gdgains its first async flow. Every existing handler isGameSettings.x = y; change_scene_to_file(...)— there is no loading screen, no error state, and no back-navigation state machine to extend. Budget for that.
Phase gate: two clients connect to a headless server, appear in a shared lobby, ready up, and disconnect cleanly.
Phase 2 — Server-authoritative simulation, dumb client
No own-ship prediction yet: the client renders everything, including its own ship, from the interpolation buffer. Unplayable over the internet, fine on LAN, and it proves the whole state pipeline before prediction complicates the picture.
This phase is load-bearing, not throwaway — the codec, slot mapping, snapshot pipeline, interpolator and HUD signal surface all survive into Phase 4. Roughly ten lines get discarded.
| # | Task | Acceptance |
|---|---|---|
2.1 [D:1.4] |
DONE. New MatchSim autoload (scripts/match_sim.gd) carries all Phase 2 hot-path RPCs (match_config, input, snapshot, score_update) per §1.1's "hot RPCs live on autoloads" decision — NetworkedMatch itself (scripts/networked_match.gd + scenes/networked_match.tscn, no HUD child) stays a plain scene node with no networking identity of its own. Server builds deterministic team/spawn-index slots by iterating MatchNet.roster.keys() sorted, loads a random arena via ArenaRegistry.random_path(), spawns ball/ships, then send_match_config()s. Client validates the received arena_path against ArenaRegistry.ARENAS before loading it |
Both peers spawn an identical tree in real two-process runs (tests/networked_match_smoke.gd/.tscn); an invalid arena path is refused before load |
2.2 [D:2.1] |
DONE. Server reuses RLShipController as the remote-input controller exactly as the architecture doc anticipated — each connected peer's real Ship is driven by one, fed by MatchSim.input_received. _broadcast_snapshot() runs every physics tick (60 Hz), packing NetBodyState for every ship + ball via NetCodec.pack_snapshot_body_segment and sending per-slot, filtered through multiplayer.get_peers() so a disconnected peer doesn't get an RPC send attempt |
Server-side snapshot cadence confirmed stable at 60 Hz across multiple two-process runs; no "unknown peer ID" spam after the get_peers() filter fix (found via a real disconnect-mid-test case) |
2.3 [D:2.2] |
DONE. New scripts/net_interpolator.gd (class_name NetInterpolator, RefCounted) buffers up to MAX_SAMPLES=16 timestamped NetBodyStates per remote body and produces interpolated (or clamped-extrapolated, MAX_EXTRAPOLATION_MS=150) states at any fractional server tick via sample_at(). Client-side _on_snapshot_received feeds each body's decoded state into its interpolator; ships/ball spawn FREEZE_MODE_KINEMATIC so they never call _integrate_forces/get_action() |
Client observed 31.43 m of real, physics-verified movement over a 2s held-thrust drive purely from interpolated snapshots, no local simulation |
2.4 [D:2.3] |
DONE — dual-time remote entities (§4.1). Collider updates happen in _physics_process at server_time_est (present-time, correct contact resolution); $Visual updates happen separately in _process at server_time_est - INTERP_DELAY (physics_interpolation_mode = OFF, since the node's transform is overwritten every rendered frame). _current_interp_delay_ms() computes a simplified INTERP_DELAY (one_way + interval*1.5, clamped [25,200] ms) — no jitter term yet, that lands with Phase 3's jitter buffer |
Verified via the smoke test's separate collider/visual checks; Engine.get_physics_frames()/Time.get_ticks_msec() epoch correlation (NetInterpolator.to_tick()) confirmed working with no extra sync handshake needed |
2.5 [D:2.3] [P] |
DONE. _send_local_input() samples via a stateless, never-added-to-tree PlayerShipController instance (reading real Input state) and sends the resulting ShipAction every physics tick, no redundancy/buffering yet (Phase 3) |
Input reaches the server and visibly moves the ship — confirmed via a real held move_forward keypress driving 31.43 m of server-authoritative movement |
2.6 [D:2.3] [P] |
DONE, and empirically verified, not just inferred — turned out to already be satisfied as a natural consequence of 2.1–2.5's implementation (_apply_ship_visual_state already calls set_visual_action for remote ships; _process's ball branch already calls set_visual_speed) |
Smoke test explicitly reads interpolator.latest().thrust_z mid-drive and asserts >0.5 while move_forward is held (not inferred from movement alone) — measured thrust_z=1.00 |
2.7 [D:2.3] [P] |
DONE, also a natural consequence of the above — spawn_camera_rig(_my_slot.ship) and _spawn_hud() are called once the client's own ship is identified in _on_match_config_received |
Smoke test asserts _camera_rig and hud both is_instance_valid() on the client; confirmed true in every clean run |
2.8 [D:1.1] [P] |
DONE. New NetSim autoload (scripts/net_sim.gd): seeded (--net-sim-seed=, fixed default so a bad run reproduces), CLI-driven (--net-sim-latency=/--net-sim-jitter=/--net-sim-loss=/--net-sim-dup=), a pure passthrough (send() calls the dispatch immediately) unless at least one flag is non-zero — confirmed byte-for-byte inert against every Phase 1/2 regression test with no flags set. Wraps MatchSim.send_input/send_snapshot per this row's original scope, plus NetworkManager's _ping/_pong dispatch — a deliberate scope addition, since that's the only RTT measurement that already exists and is already tested (task 1.8), so it's what makes this task's own acceptance criterion checkable today without waiting on Phase 3's per-peer snapshot echo. "Asymmetric-capable" needs no special-case code: each process reads only its own CLI args and delays only its own outgoing sends, so hosting and joining with different flags is already asymmetric. One correctness subtlety, caught before it shipped: callers that embed a timestamp in a wrapped RPC (_ping/_pong) must capture Time.get_ticks_msec() before calling NetSim.send(), not inside the wrapped Callable — capturing it inside would silently absorb that process's own added delay out of the round-trip measurement instead of adding to it, since the timestamp would then reflect "after my delay" rather than "when I actually tried to send". A second real bug, found by actually running Phase 2's own milestone gate (a full networked_match_smoke run under --net-sim-latency=80 --net-sim-jitter=20, not just the isolated ping/pong test above): a delayed send can outlive the window its target was valid in — the host hit "Attempt to call RPC with unknown peer ID" (the client had disconnected during the ~80-100ms hold, after _broadcast_snapshot's existing get_peers() filter had already passed at schedule time) and the client hit "'_recv_input' on yourself is not allowed by selected mode" (its own shutdown() had already reset multiplayer_peer to a fresh OfflineMultiplayerPeer before a still-pending delayed send fired, so peer id 1 now meant itself). Fixed by having send() accept an optional target_peer_id and re-validating it — plus that this process still has a real (non-Offline) peer at all — at fire time inside a new _fire(), not just at schedule time; the synchronous/inactive path is deliberately left unvalidated so NetSim stays a true no-op when idle |
Verified with a real two-process test (tests/net_sim_smoke.gd/.tscn): baseline (no flags) observed rtt_ms=7.00 on loopback; --net-sim-latency=80 on the host alone raised the client's observed rtt_ms to 83.00 (want ≥70, confirmed measurably higher than baseline); --net-sim-loss=1.0 on the host produced zero pong samples over 7s (rtt_ms stayed -1, confirmed the drop path actually drops rather than relabels). Phase 2's own milestone gate re-run and passing: networked_match_smoke under --net-sim-latency=80 --net-sim-jitter=20 on both peers — client still observed 26.08m of clean server-authoritative movement via interpolation, thrust_z=1.00 confirmed mid-drive, zero RPC errors (the fire-time-revalidation fix above). Re-ran the full Phase 1 + Phase 2 regression suite (test_runner, net_smoke, match_net_smoke, clock_smoke, lobby_smoke, server_boot, networked_match_smoke) with NetSim present but inactive — all still pass with unchanged behaviour (clock offset converged to the same value, networked_match_smoke still showed clean server-authoritative movement) |
net_sim.gdbelongs in this phase, not Phase 3. A LAN-only phase gate passes even with §4.1's flaw fully present, because LANINTERP_DELAYsits at the clamp floor and closing-speed error is small. Phases 2 and 3 would both go green and Phase 4 would discover the architecture is wrong.
Phase gate — MILESTONE: a real 1v1 at --net-sim-latency 80 --net-sim-jitter 20, not just on LAN. Ships fly, the ball moves, goals detect server-side.
Phase 3 — Input pipeline hardening
| # | Task | Acceptance |
|---|---|---|
3.1 [D:2.5] |
Input redundancy (last 4) and sequence numbering in server-tick space | A 3-packet burst loss produces no starvation |
3.2 [D:3.1] |
Server jitter buffer: fixed 32-entry ring, repeat-last on starve, zero after 500 ms, target_depth = 1, depth reported in every snapshot |
Starvation events logged and visible in the overlay |
3.3 [D:3.2] [P] |
Client-owned input_lead control loop: fast attack (+3 immediate), slow release (−1 per 60 ticks after 2 s clean) |
A simulated 60 ms latency spike is absorbed within ~200 ms |
3.4 [D:3.1] [P] |
Rate limiting, malformed-packet counting, seq > server_tick + 20 rejection, server-side input_lead enforcement from arrival times, disconnect policy |
A flooding or seq-poisoning client is disconnected; honest clients unaffected |
3.5 [D:3.2] [P] |
Unit tests: jitter-buffer policy against scripted arrival traces | Starvation, surplus, and reorder traces all produce the specified actions |
3.6 [D:2.8] |
--test-bot client mode driven by the existing AIShipController, plus a CI driver launching a headless server and two headless test-bot clients |
Exits 0 on a clean run; asserts snapshot count, p95/p99 prediction error, snap count, cross-peer score agreement, and clean stderr |
3.7 [D:2.8] [P] |
Debug net overlay: RTT, jitter, loss, buffer depth, snapshot age, bandwidth, prediction error | All values live and plausible |
AIShipControllerruns a policy in pure GDScript with no Python or ONNX dependency, so 3.6 gets a competent automated player for free.
Phase gate: the match stays smooth at --net-sim-latency 80 --net-sim-loss 0.05; the CI smoke test is green.
Phase 4 — Prediction and reconciliation, ship and ball
| # | Task | Acceptance |
|---|---|---|
4.1 [D:3.1] |
LocalNetShipController: single sample per tick, copy(), input history ring |
Exactly one get_action() per tick; no aliasing in the history |
4.2 [D:4.1] |
Prediction state ring (128) and snapshot→predicted[A] matching |
predicted[A] resolves for every snapshot on a clean link |
4.3 [D:4.2, 0.14] |
net_ship_predictor.gd: snap-vs-blend decision, full-immediate velocity correction, teleport queue, reset_gen handling, ring backfill after a snap |
A snap is never followed by an immediately-forced second snap |
4.4 [D:4.3, 0.2] |
Visual offset with _tick_scaled(0.88) decay, MAX_VISUAL_OFFSET = 0.4, reset_physics_interpolation() on body and $Visual |
No mesh smear on snap; no visible offset beyond 0.4 m |
4.5 [D:4.3] [P] |
Catch-up by replaying stored ShipActions through the ship's own force formulas |
No directional bias under sustained turbo; ship does not feel rubbery |
4.6 [D:4.3] |
Ball local prediction: dynamic locally from the tick your predicted ship contacts it for min(RTT, 250 ms); server ball applied to a shadow copy throughout; blend back over 150 ms, hard-snap past 3 m. Triggered by the existing Ship.ball_contact (ship.gd:115) |
Your own touches register visually on contact, not ~RTT later; behind a setting |
4.7 [D:4.4] [P] |
Tuning pass with debug-menu sliders: snap thresholds, decay k, MAX_VISUAL_OFFSET, INTERP_DELAY |
Values recorded in this document once settled |
4.8 [D:4.4] [P] |
Prediction-error telemetry (p50/p95/p99, snap rate) into the overlay and the CI assertions | Snap rate <1/min in normal 1v1 play at 80 ms simulated RTT |
4.9 [D:4.4] |
L1 — extrapolate remote $Visual to present time (§5.6): render remote ships and the ball at server_time_est rather than server_time_est - INTERP_DELAY, feeding the residual through 4.4's soft-correct pipeline. Collapses §4.1's dual clock — collider and visual share one time, so §5.4b's _process/_physics_process split for remote bodies is removed. Keep interpolation behind a flag for A/B |
≈30 ms off world response (174 → ~144 before L4). Measured p99 extrapolation error < 0.3 m and < 5°; correction pops are visible on hard direction changes and nowhere else; A/B against the interpolated path is a deliberate, recorded judgement |
4.10 [D:4.9] [P] |
L3 — adaptive jitter-buffer depth: target 0 on links with jitter below a threshold, rising to 1+ under jitter, replacing §3.3's fixed target_depth = 1 |
≈8 ms off world response on clean links with no increase in starve rate; degrades to today's behaviour under --net-sim-jitter 20 |
Ball prediction is not optional and not Phase 8. With §4.1 in place the touch registers correctly on the server, but the ball still renders a third of a beat late — your ship visibly passes through it before it moves. In a game whose entire point is hitting a ball, that is the difference between "networked" and "broken", and it is the same machinery as own-ship prediction applied to one more body. Do it while the prediction code is warm. Buffering server ball state into a shadow copy (rather than discarding it) is what lets you measure disagreement continuously instead of discovering a 3 m error at window end.
Phase gate — MILESTONE: at simulated 100 ms RTT both the ship and the ball feel local; corrections are invisible in free flight and read as bumps on contact. World response measures ≤130 ms at 60 ms RTT (§5.6's L1 + L4 target), on whatever graphics settings the machine is running.
Phase 5 — Match lifecycle
| # | Task | Acceptance |
|---|---|---|
5.1 [D:2.1] |
Server state machine, state_change broadcast, match_state snapshot byte |
Clients follow every transition |
5.2 [D:5.1] |
Tick-derived clock replacing the Timer + _process polling; clock_state RPC; goal-time freeze as end_tick += (resume_tick - goal_tick) |
Clocks agree across peers to within a tick; no float drift across 10 goals |
5.3 [D:5.1] |
kickoff RPC with broadcast transforms, freeze/unfreeze, reset_gen, countdown derived from server_tick, and the specified late-arrival behaviour |
A kickoff delayed past resume_tick applies immediately without a negative countdown |
5.4 [D:5.1] |
goal_scored RPC; server-side pause window via _goal_pause_seconds() and _set_frozen() (never Engine.time_scale); client cinematic split from timing |
Server reset no longer fires while clients are mid-celebration |
5.5 [D:5.1] [P] |
Full time, overtime, results, return-to-lobby; remove get_tree().paused |
Clients keep sending inputs and processing snapshots throughout the results screen |
5.6 [D:5.1] [P] |
Disconnect → controller swap; 30 s identity-keyed slot reservation and reconnect; --fill-bots / --no-fill-bots; stalled flag and nameplate |
A disconnect never despawns a ship; reconnect within 30 s restores the slot |
5.7 [D:5.6] |
Null MatchNet's controller reference in the same transaction as the swap, and is_instance_valid-guard every use |
No freed-object access on repeated disconnect/reconnect |
5.8 [D:5.1] [P] |
Spectators and late join; spectator-safe HUDController path; camera target cycling |
A spectator can watch a live match and cycle targets |
5.9 [D:5.3] [P] |
Server-only _respawn_escaped_bodies() with a reset_gen bump |
Clients hard-snap on an escape respawn instead of fighting it |
5.10 [D:5.1] [P] |
Server replay log: append-only binary (tick, inputs received, snapshot sent) |
A recorded match replays deterministically enough to reproduce a reported snap |
Ship.set_controller(ship.gd:213-218) callsqueue_free()on the outgoing controller. Task 5.7 exists because the takeover path in 5.6 otherwise leavesMatchNetholding a freed reference — the exact class of bug that surfaces as a random server crash weeks later.
Task 5.10 is the highest-value debuggability investment here. The packets are already flat bytes, so it is ~50 lines. Without it, "my ship snapped" is permanently unreproducible from a field report — the CI gate catches regressions, but it cannot debug a player's bad night.
Phase gate: a full 3v3 start-to-finish including a mid-match disconnect and a late joiner.
Phase 6 — Dedicated server productionisation
| # | Task | Acceptance |
|---|---|---|
6.1 [P] |
Export preset (dedicated_server=true, custom_features="dedicated_server") and run/main_scene.dedicated_server, mirroring the existing run/main_scene.training mechanism |
Preset builds |
6.2 [D:6.1] |
Verify the stripped export boots and scores a goal | Exported binary runs a full match headless |
6.3 [P] |
Full CLI surface plus a config-file fallback | --help documents every flag |
6.4 [P] |
Structured logging (join, leave, goal, kick, rate-limit, tick overrun) with --log-level |
Logs are greppable and rotate sanely |
6.5 [P] |
Arena rotation between matches; --max-matches N drain-and-exit |
Server cycles arenas and exits cleanly after N |
6.6 [P] |
systemd unit, Dockerfile, SERVER.md (ports, firewall, sizing per §1.4, and the SIGTERM caveat) |
A third party can host from the docs alone |
6.7 [D:3.6] [P] |
CI builds the server export and runs the smoke test against the exported binary, not source | Green on a clean checkout |
dedicated_server=trueenables Godot's strip-visuals export mode, which replaces meshes and textures with placeholders per resource. Every relevant site is already headless-guarded —ship.gd:167,ball.gd:25,goal.gd,arena_boundary.gd— so the code should be safe. Verify it against a real stripped build anyway; this is the kind of thing that fails silently.
Docker/VPS is the primary v1 deployment path. Raw ENet self-hosting needs port forwarding, and SDR is Phase 7 — so Phases 1–6 ship something that works on LAN or a VPS and nowhere else. That is fine, but say it out loud rather than letting a player discover it.
Godot 4 gives GDScript no SIGTERM hook.
SIGTERM/Ctrl-Ckills the process immediately and clients see an ENet timeout (~5 s). Acceptable — but document it rather than letting it be discovered.--max-matches Nunder a process supervisor covers planned drains.
Rcon is deferred past v1. An authenticated remote command channel is a real security surface, and
--max-matchesplus a supervisor covers most of the need with none of it.
Phase gate: docker run a server, connect from another machine over the internet, play a full match.
Phase 7 — Steam transport, browser, identity
| # | Task | Acceptance |
|---|---|---|
7.1 [D:1.2] |
GodotSteam integration and custom export templates — client and headless server | Both templates build and run |
7.2 [D:7.1] |
Extract a NetTransport boundary now, concretely, from two working implementations; add steam_transport.gd (SteamMultiplayerPeer, SDR, advertise() via ISteamGameServer) |
Transport swap is one line in NetworkManager |
7.3 [D:7.2] [P] |
server_browser.tscn via ISteamMatchmakingServers |
Internet, LAN, favourites and history lists all populate |
7.4 [D:7.2] [P] |
Auth tickets in hello → BeginAuthSession; Steam identity in the roster; persistent ban list |
Ownership, VAC and ban state verified server-side |
7.5 [D:7.2] [P] |
Feature-gate every Steam call behind OS.has_feature("steam") and ClassDB.class_exists("SteamMultiplayerPeer"); verify the ENet path end to end |
Non-Steam build is fully functional |
The transport interface is written here, not in Phase 1. Eight virtual methods (
begin_auth,advertise,get_identity,supports_server_browser…) designed against an API nobody on the project has used will be wrong. WriteNetworkManager._make_peer()concretely in Phase 1 and extract the boundary once there are two real implementations. Locked decision 3 guarantees the ENet path is never deleted, so there is no migration risk in waiting.
GodotSteam requires custom engine builds and export templates — including for the headless server. That is the part people discover three weeks in. Budget for it.
8. What needs refactoring, not extending
| # | Location | Why extension is insufficient |
|---|---|---|
| 1 | objects/ship.tscn, ship.gd:175-180, 189-208, 241-278 |
No node exists to carry a render-only offset — meshes hang directly off the RigidBody3D. Needs $Visual. |
| 2 | ship_camera.gd:115, 149, 150 |
Camera reads the body's transform, so it would jump the full correction error while the mesh smoothly lags. |
| 3 | match_mode.gd:36, 59-64, 76-82, 93-96, 107-109 |
The Timer + _process clock is frame-rate and time_scale coupled. Must become tick-derived. Five call sites. |
| 4 | match_mode.gd:162-171 |
get_tree().paused = true stops the client's own send loop and snapshot processing, and the return-to-lobby RPC lands in a tree that cannot act on it. |
| 5 | game_mode.gd:95-121, 171-194 |
Engine.time_scale is fundamentally incompatible with a shared tick clock — sequence numbers ride on Engine.get_physics_frames(), so a hit-stop at 0.06 starves the jitter buffer within a few frames. The effects must be reimplemented, not merely disabled. |
| 6 | game_mode.gd:85-92 |
_handle_goal_scored interleaves timing with presentation. On a headless server _play_goal_celebration returns synchronously, so the reset fires on the same frame as the goal — while clients are 1.6 s into a cinematic. |
| 7 | game_mode.gd:248-263 |
_jittered uses global RNG; _reset_body uses set_deferred. Both must become authoritative-broadcast plus a Jolt-correct teleport. |
| 8 | game_mode.gd:54-55, 284-285 |
Unconditional goal-signal connection (an interpolated ball entering a client's local Goal would score locally) and unconditional escape-respawn both write authoritative state on clients. |
| 9 | main_menu.gd (all handlers) |
Every mode launch is a synchronous change_scene_to_file. Connecting is async and can fail — a genuinely new UI state, not another button. |
| 10 | HUDController.gd:41-46 |
Hard-requires a ship; spectators have none. |
| 11 | player_ship_controller.gd |
Single reused ShipAction instance; buffering aliases every history entry. |
| 12 | ship_camera.gd:86 (whole rig) |
Runs in _physics_process, so on a 240 Hz display the FOV kick (:182) and PostFX parameters (:186-187) step at 60 Hz — neither is a transform, so global physics interpolation does not cover them — and the shake noise (:200-212) loses its high-frequency character. Must become _process + get_global_transform_interpolated() (§5.4a, task 0.16). |
| 13 | video_settings.gd:14-16, settings_menu.gd |
Persists AA, glow and brightness only — three values. The three genuinely expensive settings (SDFGI, SSIL, SSAO) and the five shadow-casting lights are unreachable, and neither vsync_mode nor max_fps is set anywhere. A player chasing 240 fps has exactly one lever: turn glow off. Needs a preset system, not another checkbox (§5.5, tasks 0.17/0.17b). |
| 14 | scenes/arena_base.tscn:18-50, 61-105 |
The Environment every arena inherits enables SDFGI + SSIL + SSAO + a 5-level glow pyramid simultaneously, with four shadow-casting OmniLight3Ds (24 cubemap faces/frame). Not tunable per-arena around a preset; the preset must gate the shared base (§5.5). |
| 15 | shaders/post_process.gdshader:4 |
hint_screen_texture forces a full-screen backbuffer copy every frame, not only during turbo — vignette_strength never reaches 0 (ship_camera.gd:187, 243). Either bake the static vignette into Environment.adjustment_* and hide PostProcess when chromatic_aberration is at rest, or drop the screen read for a plain gradient overlay and keep it only for the turbo chroma. |
| 16 | project.godot [display] |
stretch/mode="viewport" + 1920×1080 base + aspect="expand" fixes the 3D render at ~1080p and blits. A 4K player cannot render native; a 1080p player cannot render lower. Blocks any render-scaling setting until decided (task 0.17c). |
On Engine.time_scale: replace hit-stop and goal slow-mo with the camera-based effects in single-player as well (task 0.12), so there is one code path and one game feel to maintain rather than a networked variant that drifts away from the single-player one. ShipCameraRig already has _shake_strength, shake_decay, max_shake_offset and a PostFX ShaderMaterial to build on.
What does not need surgery: the ShipController seam, the Arena/GameMode split, code-driven spawning, group-based discovery, and the dumb Goal sensor all extend cleanly. CLAUDE.md's claim about the three load-bearing seams is accurate — they hold. rl_ship_controller.gd is already the remote-input controller (a public action field that something else writes, pulled each tick), so no new class is needed for it.
9. Godot 4.7 + Jolt gotchas
ENetMultiplayerPeer.server_relaydefaults totrue— clients can RPC each other through your server. Set itfalse.MultiplayerAPI.poll()runs on the idle frame, so anrpc()from_physics_processwaits up to a full frame — andEngine.max_fps = 60on the server is what creates that delay on the return leg. Take manual control (task 1.3). ~16–33 ms of round-trip, for ~10 lines.- Jolt sleeps bodies. A ship corrected to near-zero velocity can sleep and then ignore
state.linear_velocitywrites.can_sleep = falseon Ship and Ball. - Teleporting a rigid body:
state.transforminside_integrate_forcesis the only path with no frame of lag.set_deferred("global_transform", …)lands between frames and interacts badly with Jolt's sleep/wake ordering. reset_physics_interpolation()is not automatic forstate.transformwrites (it is when you setglobal_transformdirectly). Call it explicitly, on the body and on$Visual.physics_jitter_fix = 0.0does not give you "a flat 60 Hz." You still get occasional 0-tick and 2-tick frames, because frame time is never exactly 16.667 ms. The real reason to set it to 0 is that you never want a tick's input delayed by the accumulator smoother. The send path must therefore transmit both ticks' actions on a 2-tick frame — redundancy-4 covers this, but only if you actually send both._integrate_forcesis not called on frozen bodies, so remote ships never pullget_action()— henceset_visual_action. UseFREEZE_MODE_KINEMATIC, notSTATIC, or contact velocity transfer breaks.- Never write
linear_velocityto a frozen body — Godot/Jolt zeroes and holds it. Engine.max_physics_steps_per_framedefaults to 8. If a server tick overruns 16.7 ms the accumulator backs up and the next frame runs multiple ticks, spiking CPU further. Log overruns (task 1.6).- ENet channel indices are offset by Godot's reserved system channels — verify the mapping empirically.
- ENet peer timeout defaults to ~5 s. Tune via
ENetPacketPeer.set_timeout()for faster drop detection. - Jolt is not bit-deterministic across platforms or across differing contact orderings. Never rely on it anywhere, including in "obviously safe" places like a client-side goal check.
dedicated_server=trueexports strip visual resources. Verify against a real stripped build (task 6.2).- MTU: ENet fragments above ~1400 B. At 219 B/snapshot there is ~6× headroom; recheck if per-body cosmetic state is ever added.
- RPC NodePath caching: the first
rpc()to a node sends the full path, later calls send a cached int. Routing hot paths through autoloads warms the cache once at connect and never invalidates it on scene change. - Physics tick rate is 60 for v1 — and must never be a literal. Every policy in
Game/bots/is tick-coupled throughship.gd:450's_tick_scaled(defined at a 60 Hz reference) andai_ship_controller.gd'sreaction_ticks, so raising it toward Rocket League's 120 invalidates every trained model and halves server density. But it is the largest single latency term left (§5.4), so it will be revisited: derive everything fromTICK_HZ(tasks 0.18, 1.1) so that day is a config change plus a retrain. Node3D.get_global_transform_interpolated()is the only correct way to track a physics-interpolated body from_process.global_transformreturns the last physics tick's pose, so a per-frame camera reading it chases a 60 Hz staircase. Per the engine docs the method "creates an interpolation pump… the first time it is called" — call it once before anyreset_physics_interpolation()on that node, or the first hard snap streaks (§4.5).- Physics interpolation covers transforms only.
camera.fov, shader parameters, light energy and anything else written from_physics_processsteps at 60 Hz on a 240 Hz display. Either write them from_processor accept the stepping deliberately. display/window/vsync_modedefaults to enabled (FIFO) andmax_fpsto uncapped. Neither is set inproject.godot. FIFO present latency is 1.5–3 refresh intervals depending on swapchain image count (2 vs 3) and whether the present queue is full — §5's tables use the optimistic 1.5, which assumes the renderer is not GPU-bound. The model does not hold below refresh, where a missed vblank under strict FIFO halves the effective rate and roughly doubles present latency. Prefer Adaptive as the default, not Mailbox (§5.4). (Swapchain image count per platform needs empirical verification.)Engine.max_fpsis a throttle, not a frame pacer. It pads each frame with a post-frame sleep; it has no vblank phase lock. Caps that are not integer divisors of the refresh rate beat against scanout, and combining a cap with an active vsync paces worse than either alone (§5.4). Derive the offered caps fromDisplayServer.screen_get_refresh_rate().DisplayServer.window_get_vsync_mode()echoes your request, not the driver's grant. There is no GDScript API for the negotiatedVkPresentModeKHR, so a UI cannot honestly report what was applied. Show a live fps readout instead and let the player infer it.Engine.max_physics_steps_per_frame = 8is a client problem too, not just a server one (gotcha 9). A client hitching to 20 fps runs 3 ticks per frame, and each of those frames also runs the per-frame camera rig and remote-visual sampling. Set it to 4 client-side (task 0.22). On a multi-tick frame the send path must transmit every tick's action (gotcha 6) — §4.3's_physics_processsampling does this naturally, but nothing else guarantees it.hint_screen_textureforces a full-screen backbuffer copy on every frame the node is drawn, regardless of what the shader then does with it. Branching inside the shader saves taps, not the copy. Hide the node when the effect is at rest.physics_jitter_fixmatters less the higher the frame rate. Its purpose is smoothing when frame rate ≈ tick rate; at 240 fps against 60 Hz physics most frames run zero ticks and the accumulator is never near an edge. Gotcha 6's reasoning for setting it to0.0still holds, but do not expect a visible difference on a high-refresh machine — test that change at 60 fps.MultiplayerAPI.multiplayer_peer's default value is anOfflineMultiplayerPeersentinel, notnull. Resetting it withmultiplayer_peer = null(rather than a freshOfflineMultiplayerPeer.new()) leaves the API in a state distinct from its own default and is a known source of "the server never seespeer_connected,get_peers()stays empty" bugs (godotengine/godot#81540) — confirmed the hard way while building task 1.2'sNetworkManager.shutdown(). Always reset to a realOfflineMultiplayerPeer.- Don't tear down a peer the instant its own connect signal fires.
connected_to_server(client-side) fires once the client's local view of the handshake completes, but the final ACK the server needs to consider its side complete may not have hit the wire yet — closing the peer or quitting the process in the same callback can drop it, and the other side then never seespeer_connected/connected_to_serverat all, even though your own side looked successful. This isn't a corner case: it reproduced on every attempt until fixed, is easy to misdiagnose as a server-side bug (the server-side symptom —get_peers()staying empty — is identical to gotcha 25's), and cost significant debugging time before the actual cause (client-side premature teardown) was found. Give at least one frame — in practicetests/net_smoke.gduses 0.3 s — between a fresh connect signal and callingshutdown()/quit(). Directly relevant to task 5.6's disconnect/reconnect controller swap and any CLI test client that connects, asserts, and exits quickly. change_scene_to_file()must be called on (or from a descendant of) the actualget_tree().current_scene, and never synchronously from_ready(). Both failure modes were hit building task 1.5'slobby.tscn/tests/lobby_smoke.gd: (a) a test harness that instantiatedlobby.tscnas a plain child of a driver node — rather than loading it as the real current scene, the waymain_menu.gd's Host/Join flow will — causedlobby.gd's own (entirely correct, standard-pattern)change_scene_to_file(ScenePaths.MAIN_MENU)disconnect handler to hang the process completely on a real disconnect, with near-zero CPU (blocked, not spinning) and no error output; the fix was to load the scene the way production actually will, not to change the production code. (b) callingchange_scene_to_file()(oradd_child()onget_tree().root) synchronously from inside_ready()throws "Parent node is busy … Consider using.call_deferred()", because the tree is still mid-traversal adding the very node whose_ready()is running;main_menu.gd's real button-press handlers won't hit this (they run outside any_ready()), but anything that needs to trigger a scene change during its own initialization must.call_deferred()it.ENetMultiplayerPeer'sconnection_failedsignal is not bounded to anything a UI should make a player wait for. Verified empirically (task 1.7): against a genuinely refused loopback connection (nothing listening on the target port),connection_failedhad still not fired 14 seconds in. Don't rely on it alone to end a "Connecting…" state — run your own app-level timeout (main_menu.gd'sCONNECT_TIMEOUT_SECONDS = 6.0) that shuts the peer down and shows an error regardless of whether ENet ever gets around to reporting failure itself.- A
MultiplayerPeer's "am I a client" flag (however you track it —NetworkManager.is_clienthere) turns true the instantjoin()/create_client()is called, not once the connection actually completes. Anything gated on that flag alone (task 1.8's clock ping, innetwork_manager.gd's_process) will try torpc_id()on a peer that's stillCONNECTING— or has already failed — during a slow or refused connect attempt, and Godot logs "Trying to call an RPC via a multiplayer peer which is not connected" every single frame until it resolves. Gate on the peer's actualget_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED, not just the higher-level intent flag. load()on a.gdfile with a parse/compile error does not returnnull. Found via adversarial review oftests/test_runner.gd: it returns a non-null but uninstantiableGDScriptresource, soif script == nullsilently fails to catch the failure — and the natural next line,script.new(), throws "Invalid call: Nonexistent function 'new'", severe enough to abort the entire calling function (not just that statement) without ever reaching whatever cleanup/exit code follows. In a loop over multiple files with no per-iteration error boundary, this reads as a hang: the loop that would have moved to the next file, and the code that would have calledquit(), both never run. The real guard isScript.can_instantiate().- An
@rpcmethod named_inputcollides withNode's built-in_input(event: InputEvent)virtual. Found building task 2.1'sMatchSimautoload: naming the client→server input RPC_input(bytes: PackedByteArray)produced a parse error ("function signature doesn't match the parent") — and because this was on an autoload, the error broke the entire autoload from loading, cascading into unrelated failures across every scene that touchedMatchSimat all, none of which mentioned RPCs or_inputin their own error output. Renamed to_recv_input. General lesson: on an autoload especially, treat any bare virtual-sounding method name (_input,_process,_ready,_unhandled_input, …) as reserved regardless of what you intend it to do — a signature mismatch there doesn't fail locally, it fails the whole autoload. - Disabling automatic multiplayer polling (task 1.3) is global, not autoload-scoped — every scene that touches an RPC, not just
NetworkManager-adjacent code, must callNetworkManager.poll()itself every frame it wants traffic to move. Building task 2.1–2.3,networked_match.gd's_physics_process/_processsent and listened for RPCs (MatchSim.request_match_config,send_input, snapshot RPCs) but never calledpoll()— nothing sent viarpc()in this scene ever reached the wire in either direction, silently, with no error in either process's log. Confirmed via debug prints: the client's request fired, but the host's handler print never appeared. The first (wrong) hypothesis was a startup race between the server's broadcast and the client's listener connecting — that fix (a request/response retry pattern, still worth keeping for the genuine late-join case) didn't resolve it alone. The real fix was addingNetworkManager.poll()at the top of both_physics_processand_processin the new scene. If a scene sends or receives RPCs and nothing arrives with no errors at all, check for a missingpoll()before anything else. - A request/response fallback for a one-shot broadcast can double-deliver, and the receiving handler must be idempotent. Once gotcha 32's fix made polling actually work,
_on_match_config_receivedran twice per client — once from the server's original one-shot_match_config.rpc()broadcast (queued the whole time, since it had been sent before polling was fixed) and again from the request/response retry — producing two arenas, two ship sets, two HUDs (_slots.size() == 2instead of 1). Any handler reachable via both an original broadcast and a "resend on request" path needs its own guard (here:if not _slots.is_empty(): returnat the top) rather than assuming "only sent once" from the RPC design alone. - Anything that deliberately delays an RPC dispatch (task 2.8's
net_sim.gd) must re-validate its target at fire time, not just at the moment it was scheduled. Found by actually running Phase 2's own gate (networked_match_smokeunder--net-sim-latency=80 --net-sim-jitter=20), not the isolated ping/pong test alone:_broadcast_snapshot's existingget_peers()filter (gotcha from task 2.2's own fix) only proves the target was valid when the send was queued — a target that legitimately disconnects during the ~80–100ms hold produces "Attempt to call RPC with unknown peer ID" anyway, and if the delay outlives this process's ownshutdown(),multiplayer_peerhas already been reset to a freshOfflineMultiplayerPeer(§9 gotcha re: never resetting to rawnull), so a stalerpc_id(1, …)now means "call yourself" and Godot rejects it. Any deliberate-delay layer needs to re-check both "do I still have a real peer at all" and "is this specific target still inget_peers()" inside the delayed callback itself, immediately before dispatching — not reuse whatever validation the caller did before the delay was added.
10. Testing
Editor. Debug → Run Multiple Instances, 2–3 instances with per-instance args (-- --server, -- --connect 127.0.0.1:27015) and --position so windows don't stack.
CLI.
godot --headless --path Game res://scenes/server_boot.tscn -- --port 27015 --team-size 1 --auto-start
godot --path Game -- --connect 127.0.0.1:27015 --name Alice
CI smoke test (task 3.6). Headless server plus two headless --test-bot clients, driven by the existing AIShipController. Asserts:
- snapshots received ≥
N * snapshot_hz * 0.9 - own-ship prediction error p95 < 0.5 m, p99 < 2.0 m, hard-snap count < 3
- final score identical on the server and both clients
- no
push_erroremitted (scrape stderr)
Network conditions. net_sim.gd (task 2.8) is first-class: seeded so failures reproduce, works in CI, needs no display, and can be applied asymmetrically — which OS tools make painful. tc netem / Network Link Conditioner / clumsy for a pre-release realism pass only. A real remote host once per phase from Phase 4 onward is the only true test of the jitter buffer's adaptivity.
Unit tests (task 1.0). No test framework exists today, so keep it minimal — a scene that runs pure-function assertions and exits with a code. High-value targets, all zero-engine-state: codec quantise/dequantise round-trip and bounds; quaternion max error; snapshot pack→unpack identity; input packet framing; jitter-buffer policy against scripted arrival traces; ShipAction.copy() non-aliasing. These are exactly where a bug is invisible in play and catastrophic in aggregate.
11. Flagged, not solved
Low-latency present and graphics presets — now specified, see §5.4, §5.5 and tasks 0.17/0.17b. Left here as a pointer because they are the largest wins in the document per line of code changed, and they are video settings rather than netcode.
120 Hz simulation — deliberately deferred, not dismissed. §5.4 and §5.6 record what it would buy (≈21 ms of world response once L1 has taken the interpolation buffer out, plus ≈8 ms of own-ship feel — the difference between ≈127 ms and ≈107 ms), what it costs (a full bot retrain, half the server density, double the bandwidth), and the one rule that keeps the door open: TICK_HZ, never 60.
The latency gap to the reference has a plan but not yet a measurement. §5.2 lands at ≈174 ms as designed; §5.6 routes that to ≈127 ms (tasks 0.17d, 4.9) and ≈103 ms (tasks 4.10 plus 120 Hz simulation), against ~90–110 ms for the reference class at the same RTT. Every figure in §5.6 is arithmetic on the budget, not a measurement — task 4.9's acceptance criterion exists to make it one. Beyond that the residual is RTT, which is a server-siting problem (§6) rather than a code one and is worth more than every remaining code lever combined.
Audio. TODO.md records that there is none. set_visual_action / set_visual_speed (task 0.14) is precisely where remote-ship engine audio will hang, and "ball feel" (task 4.6) is half auditory. Design those hooks with that in mind rather than retrofitting.
Split-screen. Tracked separately in TODO.md; unrelated to this effort, though the camera-outside-the-ship structure that enables it is the same structure this plan relies on.