From 39a41c016c13304d9ebb706a519c661ce28fc378 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:42:13 +0100 Subject: [PATCH] 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. --- Game/project.godot | 1 + Game/scenes/networked_match.tscn | 6 + Game/scripts/match_sim.gd | 95 ++++++ Game/scripts/net_interpolator.gd | 114 +++++++ Game/scripts/networked_match.gd | 360 +++++++++++++++++++++++ Game/tests/networked_match_smoke.gd | 71 +++++ Game/tests/networked_match_smoke.tscn | 6 + Game/tests/networked_match_test_hooks.gd | 116 ++++++++ multiplayer-todo.md | 19 +- 9 files changed, 780 insertions(+), 8 deletions(-) create mode 100644 Game/scenes/networked_match.tscn create mode 100644 Game/scripts/match_sim.gd create mode 100644 Game/scripts/net_interpolator.gd create mode 100644 Game/scripts/networked_match.gd create mode 100644 Game/tests/networked_match_smoke.gd create mode 100644 Game/tests/networked_match_smoke.tscn create mode 100644 Game/tests/networked_match_test_hooks.gd diff --git a/Game/project.godot b/Game/project.godot index 58a4744f..e16a976e 100644 --- a/Game/project.godot +++ b/Game/project.godot @@ -46,6 +46,7 @@ BackgroundFPS="*res://scripts/background_fps.gd" PerfOverlay="*res://scripts/perf_overlay.gd" NetworkManager="*res://scripts/network_manager.gd" MatchNet="*res://scripts/match_net.gd" +MatchSim="*res://scripts/match_sim.gd" NetDebugOverlay="*res://scripts/net_debug_overlay.gd" [display] diff --git a/Game/scenes/networked_match.tscn b/Game/scenes/networked_match.tscn new file mode 100644 index 00000000..0ae770c1 --- /dev/null +++ b/Game/scenes/networked_match.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/networked_match.gd" id="1_nm"] + +[node name="NetworkedMatch" type="Node3D"] +script = ExtResource("1_nm") diff --git a/Game/scripts/match_sim.gd b/Game/scripts/match_sim.gd new file mode 100644 index 00000000..46474143 --- /dev/null +++ b/Game/scripts/match_sim.gd @@ -0,0 +1,95 @@ +extends Node + +# Autoload (project.godot [autoload] MatchSim). Phase 2 simulation RPCs: +# match_config (server assigns arena + deterministic slot order from +# MatchNet.roster), input (client -> server, per-tick action), snapshot +# (server -> client, NetCodec-packed body state), and a small score_update +# for the HUD. Lives on an autoload per §1.3's derived decision ("All +# hot-path RPCs live on autoloads") even though these are scoped to +# whichever match happens to be running — a scene-node RPC target would +# need matching NodePaths across peers, which an autoload sidesteps +# entirely, and it's what lets NetworkedMatch itself stay a plain scene +# node with no networking-identity concerns of its own. +# +# Channel intent per §2.1: 0 reliable (match_config, score_update), 1 +# unreliable-ordered (input), 2 unreliable-ordered (snapshot) — not yet +# verified against ENet's own reserved system channel offset (§2.1's own +# "verify empirically" hedge); if that turns out to matter these indices +# will need adjusting, not the RPC design itself. + +const NetCodec = preload("res://scripts/net_codec.gd") + +signal match_config_received(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) +signal input_received(peer_id: int, decoded: Dictionary) # decoded: see NetCodec.unpack_input +signal snapshot_received(decoded: Dictionary) # decoded: see NetCodec.unpack_snapshot +signal score_update_received(score: Dictionary) + +# Server only: the last match_config actually sent, so a client whose own +# scene load (and therefore its match_config_received listener) finishes +# AFTER the server already broadcast can still get it — a one-shot +# broadcast alone is racy against however long the client takes to reach +# the point where it's listening, and Godot signals never buffer for a +# late connection. request_match_config() closes that race by turning +# delivery into "ask until you get it" instead of "hope you were already +# listening." Also covers a late joiner mid-match (Phase 5 will still need +# to add live match *state*, not just this static config, for that case). +var _last_match_config: Dictionary = {} + + +func send_match_config(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void: + _last_match_config = { + "arena_path": arena_path, "peer_ids": peer_ids, "teams": teams, "spawn_indices": spawn_indices, + } + _match_config.rpc(arena_path, peer_ids, teams, spawn_indices) + + +func request_match_config() -> void: + _request_match_config.rpc_id(1) + + +func send_input(bytes: PackedByteArray) -> void: + _recv_input.rpc_id(1, bytes) + + +func send_snapshot(peer_id: int, bytes: PackedByteArray) -> void: + _snapshot.rpc_id(peer_id, bytes) + + +func send_score_update(score: Dictionary) -> void: + _score_update.rpc(score) + + +@rpc("authority", "call_remote", "reliable", 0) +func _match_config(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void: + match_config_received.emit(arena_path, peer_ids, teams, spawn_indices) + + +@rpc("any_peer", "call_remote", "reliable", 0) +func _request_match_config() -> void: + if not multiplayer.is_server() or _last_match_config.is_empty(): + return + var peer_id := multiplayer.get_remote_sender_id() + _match_config.rpc_id( + peer_id, _last_match_config["arena_path"], _last_match_config["peer_ids"], + _last_match_config["teams"], _last_match_config["spawn_indices"] + ) + + +@rpc("any_peer", "call_remote", "unreliable_ordered", 1) +func _recv_input(bytes: PackedByteArray) -> void: + if not multiplayer.is_server(): + return + var peer_id := multiplayer.get_remote_sender_id() + var decoded := NetCodec.unpack_input(bytes) + input_received.emit(peer_id, decoded) + + +@rpc("authority", "call_remote", "unreliable_ordered", 2) +func _snapshot(bytes: PackedByteArray) -> void: + var decoded := NetCodec.unpack_snapshot(bytes) + snapshot_received.emit(decoded) + + +@rpc("authority", "call_remote", "reliable", 0) +func _score_update(score: Dictionary) -> void: + score_update_received.emit(score) diff --git a/Game/scripts/net_interpolator.gd b/Game/scripts/net_interpolator.gd new file mode 100644 index 00000000..c0575adb --- /dev/null +++ b/Game/scripts/net_interpolator.gd @@ -0,0 +1,114 @@ +class_name NetInterpolator +extends RefCounted + +# Buffers recent snapshot samples for ONE remote body and produces +# interpolated states at any requested (possibly fractional) server tick — +# used twice per body (multiplayer-todo.md §4.1/§4.6, "dual-time remote +# entities"): once at the present-time estimate for the collider, once +# further back at present-minus-INTERP_DELAY for $Visual. +# +# server_tick (Engine.get_physics_frames() at send time) maps to an +# estimated server wall-clock time via TICK_HZ without any extra +# synchronization: both Engine.get_physics_frames() and Time.get_ticks_msec() +# count from the same process-start epoch, and physics has been running at +# a steady TICK_HZ the whole time, so tick_ms_of(tick) = tick * (1000/TICK_HZ) +# is a valid estimate of "what Time.get_ticks_msec() read on the server when +# it sent that tick." Callers convert a NetworkManager.get_server_time_estimate_ms() +# reading into the same tick-space with to_tick(ms) before calling sample_at(). + +const NetBodyState = preload("res://scripts/net_body_state.gd") +const SimConstants = preload("res://scripts/sim_constants.gd") + +const MAX_SAMPLES := 16 +# §4.6: "never extrapolate indefinitely — a stuck ship reads better than one +# flying through a wall." +const MAX_EXTRAPOLATION_MS := 150.0 +const TICK_MS := 1000.0 / SimConstants.TICK_HZ + +var _samples: Array[Dictionary] = [] # [{tick:int, state:NetBodyState}], oldest first +var reset_gen := -1 # -1: no sample yet, so the first real sample is never treated as a mid-flight reset + + +static func to_tick(server_time_ms: float) -> float: + return server_time_ms / TICK_MS + + +# Returns true if this sample's reset_gen differs from the last one seen — +# the caller's cue to hard-snap instead of interpolating across a +# server-authoritative teleport (kickoff, goal reset) rather than sliding +# across the arena. Clears buffered history on a reset so a stale +# pre-reset sample can never bracket a post-reset one. +func add_sample(server_tick: int, state: NetBodyState, sample_reset_gen: int) -> bool: + var is_reset := reset_gen != -1 and sample_reset_gen != reset_gen + if is_reset: + _samples.clear() + reset_gen = sample_reset_gen + if not _samples.is_empty() and server_tick <= _samples.back()["tick"]: + return is_reset # stale/duplicate (unreliable_ordered should already prevent this, but don't trust it blindly) + _samples.append({"tick": server_tick, "state": state}) + if _samples.size() > MAX_SAMPLES: + _samples.pop_front() + return is_reset + + +func has_samples() -> bool: + return not _samples.is_empty() + + +func latest() -> NetBodyState: + return _samples.back()["state"] if not _samples.is_empty() else null + + +# target_tick may be fractional (a point in time between two integer ticks). +func sample_at(target_tick: float) -> NetBodyState: + if _samples.is_empty(): + return null + if _samples.size() == 1: + return _samples[0]["state"] + if target_tick <= _samples[0]["tick"]: + return _samples[0]["state"] + var newest: Dictionary = _samples.back() + if target_tick >= newest["tick"]: + return _extrapolate(newest, target_tick) + for i in range(_samples.size() - 1): + var a: Dictionary = _samples[i] + var b: Dictionary = _samples[i + 1] + if a["tick"] <= target_tick and target_tick <= b["tick"]: + var a_tick: float = a["tick"] + var b_tick: float = b["tick"] + var span := b_tick - a_tick + var t: float = (target_tick - a_tick) / span if span > 0.0 else 0.0 + return _lerp_state(a["state"], b["state"], t) + return newest["state"] + + +func _lerp_state(a: NetBodyState, b: NetBodyState, t: float) -> NetBodyState: + var out := NetBodyState.new() + out.position = a.position.lerp(b.position, t) + out.rotation = a.rotation.slerp(b.rotation, t) + out.linear_velocity = a.linear_velocity.lerp(b.linear_velocity, t) + out.angular_velocity = a.angular_velocity.lerp(b.angular_velocity, t) + out.frozen = b.frozen + out.turbo = b.turbo + out.thrust_z = b.thrust_z + out.stalled = b.stalled + out.avel_range = b.avel_range + return out + + +func _extrapolate(newest: Dictionary, target_tick: float) -> NetBodyState: + var state: NetBodyState = newest["state"] + var ticks_ahead: float = target_tick - float(newest["tick"]) + var ms_ahead := ticks_ahead * TICK_MS + var clamped_ms := clampf(ms_ahead, 0.0, MAX_EXTRAPOLATION_MS) + var out := NetBodyState.new() + out.position = state.position + state.linear_velocity * (clamped_ms / 1000.0) + out.rotation = state.rotation + out.linear_velocity = state.linear_velocity + out.angular_velocity = state.angular_velocity + out.frozen = state.frozen + out.turbo = state.turbo + out.thrust_z = state.thrust_z + out.stalled = state.stalled + out.avel_range = state.avel_range + return out diff --git a/Game/scripts/networked_match.gd b/Game/scripts/networked_match.gd new file mode 100644 index 00000000..ac3e213d --- /dev/null +++ b/Game/scripts/networked_match.gd @@ -0,0 +1,360 @@ +class_name NetworkedMatch +extends GameMode + +# Phase 2: server-authoritative simulation, dumb client (multiplayer-todo.md +# §7 Phase 2). The server runs the real physics for every ship — via +# RLShipController, fed by each connected player's forwarded input — and +# the ball, and broadcasts NetCodec snapshots at 60Hz. The client renders +# everything, including its own ship, from the interpolation buffer; there +# is no local prediction yet (that's Phase 4), so every body on the client +# is FREEZE_MODE_KINEMATIC and driven entirely by incoming snapshots. +# +# No HUD/Arena child in networked_match.tscn — both are built in code, once +# the arena is actually known (the server picks one; the client learns it +# from match_config), which is why this overrides _ready() completely +# rather than relying on GameMode's default (arena-required-synchronously) +# flow. + +signal timer_updated(minutes: int, seconds: int) +signal score_changed(score: Dictionary) +signal match_ended(winning_team: int, score: Dictionary) +signal kickoff_countdown(count: int) +signal overtime_started + +const NetCodec = preload("res://scripts/net_codec.gd") +const NetBodyState = preload("res://scripts/net_body_state.gd") +const NetInterpolator = preload("res://scripts/net_interpolator.gd") +const HUD_SCENE = preload("res://scenes/HUD.tscn") + +# Minimum plausible interpolation delay even on a same-machine/LAN link — +# §4.6's INTERP_DELAY clamp floor. The full formula (one_way + snapshot +# interval*1.5 + 2.5*jitter_ewma) is simplified here to one_way + interval*1.5 +# with no jitter term yet (no jitter EWMA is tracked before Phase 3) — close +# enough for Phase 2's "smooth, not exactly latency-optimal" bar. +const INTERP_DELAY_MIN_MS := 25.0 +const INTERP_DELAY_MAX_MS := 200.0 +const SNAPSHOT_INTERVAL_MS := 1000.0 / 60.0 + + +class SlotInfo: + var peer_id: int + var team: int + var spawn_index: int + var ship: Ship + var controller: RLShipController # server only + var interpolator := NetInterpolator.new() # client only + + +var _slots: Array[SlotInfo] = [] +var _my_slot: SlotInfo = null # client only +var _ball_interpolator := NetInterpolator.new() # client only +var _local_input_sampler := PlayerShipController.new() # client only: reads local input each tick to forward; never added to a Ship, never in the tree — get_action() only touches the global Input singleton +var _input_seq := 0 # client only +var _reset_gen := 0 # server only: bumped on every kickoff/goal reset so the client hard-snaps instead of interpolating across the teleport + + +func _ready() -> void: + add_to_group("game") + Engine.max_physics_steps_per_frame = 4 + if kickoff_rng_seed == 0: + _kickoff_rng.randomize() + if multiplayer.is_server(): + _start_server() + else: + MatchSim.match_config_received.connect(_on_match_config_received) + MatchSim.snapshot_received.connect(_on_snapshot_received) + MatchSim.score_update_received.connect(_on_score_update_received) + _request_match_config_until_received() + + +# The one-shot server broadcast in _start_server() is racy against however +# long this client's own scene load took to reach this line — it may have +# already fired into a MatchSim with no listener connected yet, or the +# server may not have even started the match yet. Keep asking until +# _on_match_config_received actually populates _slots. +func _request_match_config_until_received() -> void: + while _slots.is_empty() and is_inside_tree(): + MatchSim.request_match_config() + await get_tree().create_timer(0.5).timeout + + +func _owns_goal_logic() -> bool: + return multiplayer.is_server() + + +func _owns_world_simulation() -> bool: + return multiplayer.is_server() + + +# ============================================================ +# Server +# ============================================================ + +func _start_server() -> void: + var arena_path := ArenaRegistry.random_path() + arena = (load(arena_path) as PackedScene).instantiate() + add_child(arena) + for goal in arena.get_goals(): + goal.goal_scored.connect(_handle_goal_scored) + + spawn_ball() + + var peer_ids := PackedInt32Array() + var teams := PackedInt32Array() + var spawn_indices := PackedInt32Array() + var team_counts := {0: 0, 1: 0} + var sorted_peer_ids: Array = MatchNet.roster.keys() + sorted_peer_ids.sort() + for peer_id in sorted_peer_ids: + var info: MatchNet.PlayerInfo = MatchNet.roster[peer_id] + var spawn_index: int = team_counts.get(info.team, 0) + team_counts[info.team] = spawn_index + 1 + var slot := SlotInfo.new() + slot.peer_id = peer_id + slot.team = info.team + slot.spawn_index = spawn_index + slot.controller = RLShipController.new() + slot.ship = spawn_ship(info.team, spawn_index, slot.controller) + _slots.append(slot) + peer_ids.append(peer_id) + teams.append(info.team) + spawn_indices.append(spawn_index) + + MatchSim.send_match_config(arena_path, peer_ids, teams, spawn_indices) + MatchSim.input_received.connect(_on_input_received) + + +func _on_input_received(peer_id: int, decoded: Dictionary) -> void: + for slot in _slots: + if slot.peer_id == peer_id: + var actions: Array = decoded["actions"] + # Newest-first; no redundancy handling yet (task 3.x) — just take + # the newest one every time a packet arrives. + if not actions.is_empty(): + slot.controller.action = actions[0] + return + + +func _on_goal_registered(conceding_team: int) -> void: + _record_goal(1 - conceding_team) + MatchSim.send_score_update(score.duplicate()) + + +func _on_goal_scored(_conceding_team: int) -> void: + _reset_gen = (_reset_gen + 1) % 256 + reset_ball() + reset_ships() + + +func _broadcast_snapshot() -> void: + var server_tick := Engine.get_physics_frames() + var bodies: Array[NetBodyState] = [] + for slot in _slots: + if is_instance_valid(slot.ship): + bodies.append(_ship_to_net_body_state(slot.ship)) + if is_instance_valid(ball): + bodies.append(_ball_to_net_body_state(ball)) + var segment := NetCodec.pack_snapshot_body_segment(server_tick, 0, _reset_gen, bodies) + # Per-client header fields (last_input_seq/input_buffer_depth/echo) aren't + # tracked yet — that's the jitter-buffer work in Phase 3 (tasks 3.1-3.2). + # Building the shared body segment once and reusing it per peer (rather + # than re-encoding per client) is the whole reason §2.4 splits the wire + # format into a per-client header + a shared body segment in the first + # place — see pack_snapshot_body_segment's own doc comment. + # "No ship is ever despawned" (§6.4) means _slots outlives a disconnect — + # a real one will be handled by Phase 5's reconnect/controller-swap + # logic, but sending an RPC to a peer_id ENet no longer knows about + # (found via the smoke test: a client that exits mid-match spammed + # "Attempt to call RPC with unknown peer ID" every tick for the rest of + # the host's run) throws instead of silently no-op'ing. Guard against it. + var connected_peers := multiplayer.get_peers() + for slot in _slots: + if connected_peers.has(slot.peer_id): + MatchSim.send_snapshot(slot.peer_id, NetCodec.pack_snapshot(0, 0, 0, segment)) + + +func _ship_to_net_body_state(ship: Ship) -> NetBodyState: + var s := NetBodyState.new() + s.position = ship.global_position + s.rotation = ship.global_transform.basis.get_rotation_quaternion() + s.linear_velocity = ship.linear_velocity + s.angular_velocity = ship.angular_velocity + s.frozen = false + s.turbo = ship.is_turbo_active() + # Matches Ship._update_movement_vfx's own read of thrust.z: only positive + # forward thrust drives the visible flame (see task 2.6). + s.thrust_z = clampf(maxf(ship.controller.get_action().thrust.z if ship.controller else 0.0, 0.0), 0.0, 1.0) + s.avel_range = NetCodec.SHIP_AVEL_RANGE + return s + + +func _ball_to_net_body_state(b: RigidBody3D) -> NetBodyState: + var s := NetBodyState.new() + s.position = b.global_position + s.rotation = b.global_transform.basis.get_rotation_quaternion() + s.linear_velocity = b.linear_velocity + s.angular_velocity = b.angular_velocity + s.avel_range = NetCodec.BALL_AVEL_RANGE + return s + + +# ============================================================ +# Client +# ============================================================ + +func _on_match_config_received(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void: + if not _slots.is_empty(): + # Not idempotent by accident: the original broadcast from + # _start_server() and a reply to this client's own + # request_match_config() (see _request_match_config_until_received) + # can both legitimately arrive — the retry loop exists specifically + # because either one alone isn't reliably delivered, so seeing both + # is expected, not a protocol error. Processing this twice would + # double-spawn the whole match (found via the two-process smoke + # test: two arenas, two ships, two HUDs, _slots.size() == 2 instead + # of 1). Once is enough. + return + var known := false + for a in ArenaRegistry.ARENAS: + if a["path"] == arena_path: + known = true + break + if not known: + push_error("NetworkedMatch: server sent unknown arena path '%s', refusing match_config" % arena_path) + return + + arena = (load(arena_path) as PackedScene).instantiate() + add_child(arena) + # _owns_goal_logic() is false here, so GameMode's usual goal-signal wiring + # never happens — a client's local (interpolated, laggy) Goal sensor must + # never be allowed to decide a score, only the server's real one can. + + spawn_ball() + ball.freeze = true + ball.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC + + var my_id := multiplayer.get_unique_id() + for i in peer_ids.size(): + var slot := SlotInfo.new() + slot.peer_id = peer_ids[i] + slot.team = teams[i] + slot.spawn_index = spawn_indices[i] + slot.ship = spawn_ship(slot.team, slot.spawn_index, null) + slot.ship.freeze = true + slot.ship.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC + # §4.6: manual, per-render-frame $Visual updates must not fight + # Godot's own built-in physics interpolation. + if is_instance_valid(slot.ship.visual): + slot.ship.visual.physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF + _slots.append(slot) + if slot.peer_id == my_id: + _my_slot = slot + + _spawn_hud() + if is_instance_valid(_my_slot) and is_instance_valid(_my_slot.ship): + spawn_camera_rig(_my_slot.ship) + + +func _spawn_hud() -> void: + hud = HUD_SCENE.instantiate() + add_child(hud) + + +func _send_local_input() -> void: + if _slots.is_empty(): + return # match_config hasn't arrived yet + var action := _local_input_sampler.get_action().copy() + _input_seq += 1 + var bytes := NetCodec.pack_input(_input_seq, 0, Time.get_ticks_msec(), [action]) + MatchSim.send_input(bytes) + + +func _on_snapshot_received(decoded: Dictionary) -> void: + var server_tick: int = decoded["server_tick"] + var reset_gen: int = decoded["reset_gen"] + var bodies: Array = decoded["bodies"] + for i in _slots.size(): + if i < bodies.size(): + _slots[i].interpolator.add_sample(server_tick, bodies[i], reset_gen) + if bodies.size() > _slots.size(): + _ball_interpolator.add_sample(server_tick, bodies[_slots.size()], reset_gen) + + +func _current_interp_delay_ms() -> float: + var rtt := NetworkManager.rtt_ms + var one_way := (rtt / 2.0) if rtt >= 0.0 else INTERP_DELAY_MIN_MS + return clampf(one_way + SNAPSHOT_INTERVAL_MS * 1.5, INTERP_DELAY_MIN_MS, INTERP_DELAY_MAX_MS) + + +# Collider time: present-time estimate, applied once per physics tick. +func _physics_process(_delta: float) -> void: + # Automatic multiplayer polling is disabled project-wide (task 1.3) — + # every scene that sends/receives RPCs has to poll manually, and this + # one is no exception. Missing this meant NOTHING sent after entering + # this scene ever actually reached the wire in either direction + # (queued but never flushed) — found via the two-process smoke test, + # not by inspection. + NetworkManager.poll() + if _owns_world_simulation(): + _respawn_escaped_bodies() + if multiplayer.is_server(): + _broadcast_snapshot() + return + + _send_local_input() + var server_time_est := NetworkManager.get_server_time_estimate_ms() + var collider_tick := NetInterpolator.to_tick(server_time_est) + for slot in _slots: + if is_instance_valid(slot.ship) and slot.interpolator.has_samples(): + _apply_collider_state(slot.ship, slot.interpolator.sample_at(collider_tick)) + if is_instance_valid(ball) and _ball_interpolator.has_samples(): + _apply_collider_state(ball, _ball_interpolator.sample_at(collider_tick)) + + +# Visual time: present-minus-INTERP_DELAY, applied once per rendered frame — +# separate from the collider update above so a high-refresh client samples +# remote motion at true render rate instead of repeating the same 60Hz value +# several times in a row (§2.4's "240 distinct positions/s, not 60"). +# +# Ball only gets the VFX half of this (trail speed), not a transform write: +# unlike Ship, Ball has no separate $Visual child to offset from its +# collider (task 0.2's Visual-node split was scoped to Ship only) — giving +# it one is a bigger structural change than Phase 2's remit, so for now the +# ball's rendered position is whatever _physics_process's present-time +# collider update leaves it at, one tick behind true dual-time smoothness. +func _process(_delta: float) -> void: + # §7 task 1.3: poll for receive unconditionally at the top of both + # _process and _physics_process, not just physics — a snapshot that + # lands between ticks can be rendered immediately at high refresh rates + # instead of waiting for the next physics step. + NetworkManager.poll() + if multiplayer.is_server() or _slots.is_empty(): + return + var server_time_est := NetworkManager.get_server_time_estimate_ms() + var visual_tick := NetInterpolator.to_tick(server_time_est - _current_interp_delay_ms()) + for slot in _slots: + if is_instance_valid(slot.ship) and slot.interpolator.has_samples(): + _apply_ship_visual_state(slot.ship, slot.interpolator.sample_at(visual_tick)) + if is_instance_valid(ball) and _ball_interpolator.has_samples(): + var state := _ball_interpolator.sample_at(visual_tick) + if state != null: + (ball as Ball).set_visual_speed(state.linear_velocity.length()) + + +func _apply_collider_state(body: RigidBody3D, state: NetBodyState) -> void: + if state == null: + return + body.global_transform = Transform3D(Basis(state.rotation), state.position) + + +func _apply_ship_visual_state(ship: Ship, state: NetBodyState) -> void: + if state == null: + return + if is_instance_valid(ship.visual): + ship.visual.global_transform = Transform3D(Basis(state.rotation), state.position) + ship.set_visual_action(state.thrust_z, state.turbo) + + +func _on_score_update_received(new_score: Dictionary) -> void: + score = new_score.duplicate() + score_changed.emit(score.duplicate()) diff --git a/Game/tests/networked_match_smoke.gd b/Game/tests/networked_match_smoke.gd new file mode 100644 index 00000000..2298f87b --- /dev/null +++ b/Game/tests/networked_match_smoke.gd @@ -0,0 +1,71 @@ +extends Node + +# Manual two-process smoke test for Phase 2 (tasks 2.1-2.5): match_config, +# server-authoritative simulation, snapshot broadcast, client interpolation. +# Not part of tests/test_runner.tscn — needs real ENet peers and a real +# physics-driven ship. Run: +# +# godot --headless --path Game res://tests/networked_match_smoke.tscn -- --role=host +# godot --headless --path Game res://tests/networked_match_smoke.tscn -- --role=client + +const PORT := 7812 +const SETTLE_SECONDS := 2.0 # time to let match_config + a few snapshots land before checking spawn state +const DRIVE_SECONDS := 2.0 # time to hold forward thrust and let the ship actually move +const HOST_LIFETIME_SECONDS := 10.0 + +var _role := "" + + +func _ready() -> void: + for arg in OS.get_cmdline_user_args(): + if arg.begins_with("--role="): + _role = arg.substr("--role=".length()) + + match _role: + "host": + var err := NetworkManager.host(PORT) + if err != OK: + print("SMOKE FAIL: host() failed: %s" % error_string(err)) + get_tree().quit(1) + return + print("SMOKE: hosting on port %d, waiting for a client to join the roster..." % PORT) + MatchNet.player_joined.connect(_on_host_player_joined) + "client": + MatchNet.local_player_name = "NetTest" + var err := NetworkManager.join("127.0.0.1", PORT) + if err != OK: + print("SMOKE FAIL: join() failed: %s" % error_string(err)) + get_tree().quit(1) + return + print("SMOKE: joining ...") + MatchNet.welcomed.connect(_on_client_welcomed) + _: + print("SMOKE FAIL: missing or unrecognised --role=") + get_tree().quit(1) + return + + +func _process(_delta: float) -> void: + NetworkManager.poll() + + +func _physics_process(_delta: float) -> void: + NetworkManager.poll() + + +func _on_host_player_joined(_peer_id: int, _name: String) -> void: + MatchNet.player_joined.disconnect(_on_host_player_joined) + print("SMOKE: host loading networked_match.tscn ...") + get_tree().change_scene_to_file.call_deferred("res://scenes/networked_match.tscn") + var hooks := preload("res://tests/networked_match_test_hooks.gd").new() + get_tree().root.add_child.call_deferred(hooks) + hooks.run_host_check.call_deferred(HOST_LIFETIME_SECONDS) + + +func _on_client_welcomed() -> void: + MatchNet.welcomed.disconnect(_on_client_welcomed) + print("SMOKE: client loading networked_match.tscn ...") + get_tree().change_scene_to_file.call_deferred("res://scenes/networked_match.tscn") + var hooks := preload("res://tests/networked_match_test_hooks.gd").new() + get_tree().root.add_child.call_deferred(hooks) + hooks.run_client_check.call_deferred(SETTLE_SECONDS, DRIVE_SECONDS) diff --git a/Game/tests/networked_match_smoke.tscn b/Game/tests/networked_match_smoke.tscn new file mode 100644 index 00000000..fd1ad294 --- /dev/null +++ b/Game/tests/networked_match_smoke.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/networked_match_smoke.gd" id="1_nms"] + +[node name="NetworkedMatchSmoke" type="Node"] +script = ExtResource("1_nms") diff --git a/Game/tests/networked_match_test_hooks.gd b/Game/tests/networked_match_test_hooks.gd new file mode 100644 index 00000000..064a3be7 --- /dev/null +++ b/Game/tests/networked_match_test_hooks.gd @@ -0,0 +1,116 @@ +extends Node + +# Test-only helper (tests/networked_match_smoke.gd). Not a project autoload +# — production code never references this. Same reason as +# tests/lobby_test_hooks.gd: networked_match.tscn is loaded via +# change_scene_to_file(), which frees whatever node initiated the load, so +# a driver can't keep orchestrating from a node that just got freed. The +# smoke test add_child()s this directly under get_tree().root instead (a +# sibling of current_scene, not a descendant of it), so it survives the swap. +# +# Uses preload(), not the bare `NetworkedMatch` class_name, and leaves +# `match_scene` itself untyped (Node) throughout — same global-script-class- +# cache-timing reason as tests/test_case.gd, plus every member access off an +# untyped Node returns Variant, which then needs explicit `: Type` +# annotations wherever `:=` would otherwise fail to infer one. + +const NetworkedMatchScript = preload("res://scripts/networked_match.gd") + + +func _is_networked_match(node: Node) -> bool: + return node != null and node.get_script() == NetworkedMatchScript + + +func run_host_check(lifetime_seconds: float) -> void: + await get_tree().create_timer(lifetime_seconds * 0.4).timeout + var match_scene := get_tree().current_scene + var ok := _is_networked_match(match_scene) + var ship_count := 0 + var ball_ok := false + var arena_name := "null" + if ok: + ship_count = match_scene.ships.size() + ball_ok = is_instance_valid(match_scene.ball) + if match_scene.arena: + arena_name = match_scene.arena.name + print("SMOKE INFO: host is_networked_match=%s ship_count=%d ball_ok=%s arena=%s" % [ + str(ok), ship_count, str(ball_ok), arena_name + ]) + var success := ok and ship_count == 1 and ball_ok + print("SMOKE %s: host spawn check (ship_count=%d, ball_ok=%s)" % ["PASS" if success else "FAIL", ship_count, str(ball_ok)]) + + await get_tree().create_timer(lifetime_seconds * 0.6).timeout + if _is_networked_match(match_scene) and not match_scene.ships.is_empty(): + var ship: Ship = match_scene.ships[0] + print("SMOKE INFO: host ship final position=%s (spawned, driven by client input if any arrived)" % str(ship.global_position)) + NetworkManager.shutdown() + get_tree().quit(0 if success else 1) + + +func run_client_check(settle_seconds: float, drive_seconds: float) -> void: + await get_tree().create_timer(settle_seconds).timeout + + var match_scene := get_tree().current_scene + if not _is_networked_match(match_scene): + print("SMOKE FAIL: current_scene is not NetworkedMatch after %.1fs" % settle_seconds) + get_tree().quit(1) + return + + var slots_ok: bool = match_scene._slots.size() == 1 + var ball_ok: bool = is_instance_valid(match_scene.ball) + var my_slot = match_scene._my_slot + var my_slot_ok: bool = my_slot != null and is_instance_valid(my_slot.ship) + var camera_ok: bool = is_instance_valid(match_scene._camera_rig) + var hud_ok: bool = is_instance_valid(match_scene.hud) + var start_position := Vector3.ZERO + if my_slot_ok: + start_position = my_slot.ship.visual.global_position + + print("SMOKE INFO: client slots_ok=%s ball_ok=%s my_slot_ok=%s camera_ok=%s hud_ok=%s start_pos=%s" % [ + str(slots_ok), str(ball_ok), str(my_slot_ok), str(camera_ok), str(hud_ok), str(start_position) + ]) + + if not (slots_ok and ball_ok and my_slot_ok and camera_ok and hud_ok): + print("SMOKE FAIL: spawn/wiring check failed") + get_tree().quit(1) + return + + # Drive forward thrust (a real, held key state — exercises the actual + # client input path, not a synthetic RPC call) and confirm the ship + # the CLIENT renders (its interpolated $Visual, not a raw snapshot + # value) actually moved — proving input reached the server, the server + # applied real thruster force, broadcast it back, and the client's + # interpolator produced smooth motion from it. + Input.action_press("move_forward") + await get_tree().create_timer(drive_seconds * 0.5).timeout + + # Task 2.6: the server-computed thrust_z it broadcast in the snapshot + # should have reached this client's interpolator and be readable off + # the latest sample — this is what set_visual_action's engine-flame + # wiring actually reads, so it's the real thing to check, not just + # "the ship physically moved" (which 2.6 doesn't claim on its own). + var latest_state = my_slot.interpolator.latest() + var thrust_z_ok: bool = latest_state != null and latest_state.thrust_z > 0.5 + print("SMOKE INFO: mid-drive thrust_z=%.2f (expect >0.5 while holding forward)" % (latest_state.thrust_z if latest_state != null else -1.0)) + + await get_tree().create_timer(drive_seconds * 0.5).timeout + Input.action_release("move_forward") + + var end_position: Vector3 = my_slot.ship.visual.global_position + var moved := start_position.distance_to(end_position) + print("SMOKE INFO: client ship moved %.2fm (start=%s end=%s) while holding forward thrust for %.1fs" % [ + moved, str(start_position), str(end_position), drive_seconds + ]) + + # thrust_power 150 / mass 5 = 30 m/s^2 nominal acceleration (see ship.gd) — + # over 2s even with drag/ramp-up this should clear a couple of metres. + # A generous, not-tuned-to-the-decimal bound: this is a wiring smoke + # test, not a physics-accuracy test (net_codec's own tests already cover + # quantisation precision). + var success := moved > 1.0 and thrust_z_ok + print("SMOKE %s: client observed %.2fm of server-authoritative movement via interpolation, thrust_z_ok=%s" % [ + "PASS" if success else "FAIL", moved, str(thrust_z_ok) + ]) + await get_tree().create_timer(0.3).timeout + NetworkManager.shutdown() + get_tree().quit(0 if success else 1) diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 299babe9..4eed4200 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -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 1–6 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.1–2.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.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]` | **`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.1–2.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. ---