Files
CosmicClash/Game/scripts/match_sim.gd
T
Josh Creek 39a41c016c 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.
2026-08-20 08:42:13 +01:00

96 lines
4.0 KiB
GDScript

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)