feat(multiplayer): Phase 2 server-authoritative simulation, dumb client

Implements tasks 2.1-2.7: NetworkedMatch spawns a deterministic slot
layout from the lobby roster, the server drives each connected peer's
ship via RLShipController fed by decoded client input and broadcasts
60Hz snapshots, and the client renders everything (including its own
ship) from a per-body NetInterpolator with no local prediction yet.
Dual-time remote entities split collider updates (present-time, for
correct contacts) from $Visual updates (interp-delayed, for smoothness).
Camera/HUD wiring and remote engine-flame VFX fell out of the existing
Ship API for free once snapshots were flowing.

Three real bugs found and fixed while getting a two-process test
green: an RPC method named _input collided with Node's built-in
_input virtual and broke the whole MatchSim autoload from loading;
networked_match.gd never called NetworkManager.poll(), so nothing
sent via RPC in this scene reached the wire despite Phase 1's manual
polling being wired up everywhere else; and a match_config
request/response fallback (added to close a startup race) could
double-deliver once polling was fixed, requiring an idempotency guard.

Verified with tests/networked_match_smoke: a real headless two-process
host+client run shows the client rendering 31m of server-authoritative
movement from a held forward-thrust input, with thrust_z=1.0 confirmed
on the interpolated snapshot mid-drive and camera/HUD both wired.
Full Phase 1 regression suite re-run clean alongside it.

Task 2.8 (net_sim.gd latency/jitter/loss decorator) is not yet done;
Phase 2's own gate needs it before it's fully met.
This commit is contained in:
Josh Creek
2026-08-20 08:42:13 +01:00
parent 4533da34e0
commit 39a41c016c
9 changed files with 780 additions and 8 deletions
+11 -8
View File
@@ -4,7 +4,7 @@ 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 16 are the decisions those tasks assume; read them before picking up work in Phase 2 or later.
**Status: nothing here is implemented.** The game has zero networking code today. The only network-adjacent code in the repo is the RL trainer's `StreamPeerTCP` bridge in the vendored `godot_rl_agents` addon, which is a dev-only training transport and unrelated.
**Status: Phase 0 done, Phase 1 done, Phase 2 tasks 2.12.7 done (2.8 `net_sim.gd` outstanding — Phase 2's own gate needs it before it's fully met, LAN-only so far).** 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: 31.43 m over a 2 s held-thrust drive, purely from interpolated snapshots) with camera and HUD attached. No prediction yet (Phase 4) and no `net_sim`-simulated latency/loss testing yet (Phase 2.8) — see §7 for per-task status and evidence.
---
@@ -840,13 +840,13 @@ No own-ship prediction yet: the client renders everything, including its own shi
| # | Task | Acceptance |
|---|---|---|
| 2.1 `[D:1.4]` | `networked_match.gd` + `networked_match.tscn` (no HUD child); `match_config` RPC; deterministic slot-order roster spawn; arena-path validation against `ArenaRegistry` | Both peers spawn an identical tree; an invalid arena path is refused |
| 2.2 `[D:2.1]` | Server: reuse **`RLShipController`** as the remote-input controller, naive input application (no jitter buffer yet), snapshot writer, 60 Hz broadcast | Server logs show a stable 60 Hz snapshot cadence |
| 2.3 `[D:2.2]` | Client: snapshot reader and buffer; `net_interpolator.gd` driving frozen-kinematic bodies | Ships and ball move smoothly on the client |
| 2.4 `[D:2.3]` | **Dual-time remote entities** (§4.1): collider at `server_time_est` in `_physics_process`; `$Visual` at `server_time_est - INTERP_DELAY` **in `_process` at true render time**, `physics_interpolation_mode = OFF` (§5.4b) | Collider/visual separation measurable in the debug overlay; contacts resolve against present-time geometry; at 240 fps remote ships show 240 distinct positions/s, not 60 |
| 2.5 `[D:2.3]` `[P]` | Client: forward raw input at 60 Hz (no redundancy, no buffering yet) | Input reaches the server and moves the ship |
| 2.6 `[D:2.3]` `[P]` | Client: `set_visual_action` / `set_visual_speed` wiring for remote engine flames and the ball trail | Remote ships show engine VFX; the ball trail responds to speed |
| 2.7 `[D:2.3]` `[P]` | Client: camera rig on own ship; HUD added in code; `NetworkedMatch` declares all five HUD signals | Full HUD renders, including the score row |
| 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 `NetBodyState`s 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.12.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]` | **`net_sim.gd`** — seeded debug-only latency/jitter/loss/duplicate decorator around `MatchNet.send_input` / `send_snapshot`, CLI-driven, asymmetric-capable | `--net-sim-latency 80` measurably raises observed RTT |
> **`net_sim.gd` belongs in this phase, not Phase 3.** A LAN-only phase gate passes even with §4.1's flaw fully present, because LAN `INTERP_DELAY` sits 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.
@@ -1006,6 +1006,9 @@ No own-ship prediction yet: the client renders everything, including its own shi
28. **`ENetMultiplayerPeer`'s `connection_failed` signal 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_failed` had 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`'s `CONNECT_TIMEOUT_SECONDS = 6.0`) that shuts the peer down and shows an error regardless of whether ENet ever gets around to reporting failure itself.
29. **A `MultiplayerPeer`'s "am I a client" flag (however you track it — `NetworkManager.is_client` here) turns true the instant `join()`/`create_client()` is called, not once the connection actually completes.** Anything gated on that flag alone (task 1.8's clock ping, in `network_manager.gd`'s `_process`) will try to `rpc_id()` on a peer that's still `CONNECTING` — 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 actual `get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED`, not just the higher-level intent flag.
30. **`load()` on a `.gd` file with a parse/compile error does not return `null`.** Found via adversarial review of `tests/test_runner.gd`: it returns a non-null but uninstantiable `GDScript` resource, so `if script == null` silently 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 called `quit()`, both never run. The real guard is `Script.can_instantiate()`.
31. **An `@rpc` method named `_input` collides with `Node`'s built-in `_input(event: InputEvent)` virtual.** Found building task 2.1's `MatchSim` autoload: 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 touched `MatchSim` at all, none of which mentioned RPCs or `_input` in 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.
32. **Disabling automatic multiplayer polling (task 1.3) is global, not autoload-scoped — every scene that touches an RPC, not just `NetworkManager`-adjacent code, must call `NetworkManager.poll()` itself every frame it wants traffic to move.** Building task 2.12.3, `networked_match.gd`'s `_physics_process`/`_process` sent and listened for RPCs (`MatchSim.request_match_config`, `send_input`, snapshot RPCs) but never called `poll()` — nothing sent via `rpc()` 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 adding `NetworkManager.poll()` at the top of both `_physics_process` and `_process` in the new scene. If a scene sends or receives RPCs and nothing arrives with no errors at all, check for a missing `poll()` before anything else.
33. **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_received` ran **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() == 2` instead 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(): return` at the top) rather than assuming "only sent once" from the RPC design alone.
---