Files
CosmicClash/Game/scripts/match_sim.gd
T
Josh Creek 9d8a8080ba feat(multiplayer): Phase 3 task 3.7 - debug net overlay extension
Extends net_debug_overlay.gd (Phase 1's RTT/offset display) with the
rest of task 3.7's list: jitter (new RFC3550-style EWMA in
NetworkManager, computed from raw per-sample RTT before Phase 1's own
min-filtering, since that filter is deliberately jitter-insensitive by
design), input buffer depth and input_lead (both already tracked
client-side for task 3.3), snapshot loss (a new EWMA in networked_match.gd
over each received snapshot's own server_tick gap - snapshots go out at a
steady one-tick cadence, so a gap is direct evidence of a drop or
reorder), snapshot age (computed on demand from the same bias-corrected
tick estimate the interpolator itself uses), and bandwidth (new rolling
per-second byte counters in MatchSim, on the two 60Hz hot-path channels
only). Prediction error is deliberately omitted with a comment explaining
why: there's no client-side prediction to measure until Phase 4.

Verified values are live and plausible, not just present, by calling
get_net_debug_stats() directly in a real two-process test and checking
the numbers make sense: bandwidth matched the wire format's own byte
math almost exactly (measured ~2400 B/s sent against a computed 40B x
60Hz, ~3540 B/s received against 59B x 60Hz), and buffer depth/lead/loss
all moved in the correct direction between a clean LAN run and one under
simulated 60ms latency + 10% loss. Full regression suite re-run clean.
2026-08-20 13:32:16 +01:00

219 lines
9.5 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)
# Input validation (multiplayer-todo.md §3.1 steps 2-3, task 3.4). Deliberately
# lives here rather than in NetworkedMatch: framing/rate abuse is a protocol-
# level concern independent of any particular match's roster/slot state, and
# this autoload already owns the RPC that receives the raw bytes.
#
# 60Hz * 1.5 + 20, per §3.1 step 2's own numbers.
const RATE_LIMIT_PACKETS_PER_SEC := 110
# "Same for a byte budget" (§3.1 step 2) — the worst-case legitimate packet
# is a full-redundancy input (INPUT_HEADER_SIZE + MAX_REDUNDANCY entries,
# the "40 B input" §2.3 sizes to), so the byte budget is just the packet
# budget scaled by that worst-case size — no separate constant to keep in
# sync by hand.
const RATE_LIMIT_BYTES_PER_SEC := RATE_LIMIT_PACKETS_PER_SEC * (NetCodec.INPUT_HEADER_SIZE + NetCodec.MAX_REDUNDANCY * NetCodec.INPUT_ENTRY_SIZE)
const RATE_LIMIT_WINDOW_MS := 1000
const RATE_LIMIT_OVER_BUDGET_SECONDS_TO_DISCONNECT := 3
const MALFORMED_LIMIT_TO_DISCONNECT := 20
class _PeerInputState:
var window_start_ms := 0
var packets_this_window := 0
var bytes_this_window := 0
var over_budget_seconds := 0
var malformed_count := 0
var _peer_input_state: Dictionary = {} # peer_id -> _PeerInputState, server only
# Bandwidth (task 3.7's debug overlay): only the two 60Hz hot-path channels
# (input, snapshot) — match_config/score_update are low-frequency control
# messages, not what §2's byte-budget analysis or a live overlay cares
# about. Rolling per-second counters, recomputed opportunistically on each
# send/receive rather than on a timer — nothing needs the rate outside of
# an on-demand overlay read anyway.
const BANDWIDTH_WINDOW_MS := 1000
var bytes_sent_per_sec := 0.0
var bytes_received_per_sec := 0.0
var _sent_window_start_ms := 0
var _sent_window_bytes := 0
var _received_window_start_ms := 0
var _received_window_bytes := 0
func _ready() -> void:
NetworkManager.client_disconnected.connect(func(peer_id: int) -> void: _peer_input_state.erase(peer_id))
func _track_sent(n: int) -> void:
var now := Time.get_ticks_msec()
if now - _sent_window_start_ms >= BANDWIDTH_WINDOW_MS:
bytes_sent_per_sec = _sent_window_bytes * 1000.0 / maxf(1.0, float(now - _sent_window_start_ms))
_sent_window_start_ms = now
_sent_window_bytes = 0
_sent_window_bytes += n
func _track_received(n: int) -> void:
var now := Time.get_ticks_msec()
if now - _received_window_start_ms >= BANDWIDTH_WINDOW_MS:
bytes_received_per_sec = _received_window_bytes * 1000.0 / maxf(1.0, float(now - _received_window_start_ms))
_received_window_start_ms = now
_received_window_bytes = 0
_received_window_bytes += n
# 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:
_track_sent(bytes.size())
# bytes is already fully packed (any timestamps it carries are already
# fixed), so wrapping the dispatch itself is enough — task 2.8.
NetSim.send(func() -> void: _recv_input.rpc_id(1, bytes), 1)
func send_snapshot(peer_id: int, bytes: PackedByteArray) -> void:
_track_sent(bytes.size())
NetSim.send(func() -> void: _snapshot.rpc_id(peer_id, bytes), peer_id)
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
_track_received(bytes.size())
var peer_id := multiplayer.get_remote_sender_id()
var state: _PeerInputState = _peer_input_state.get(peer_id)
if state == null:
state = _PeerInputState.new()
_peer_input_state[peer_id] = state
# Rolling 1s window (§3.1 step 2). Rolled over lazily on the first
# packet past the window boundary, not on a timer — this RPC only ever
# runs when a packet actually arrives, so there's nothing to roll over
# when nothing is arriving anyway.
var now_ms := Time.get_ticks_msec()
if now_ms - state.window_start_ms >= RATE_LIMIT_WINDOW_MS:
var was_over_budget := state.packets_this_window > RATE_LIMIT_PACKETS_PER_SEC or state.bytes_this_window > RATE_LIMIT_BYTES_PER_SEC
state.over_budget_seconds = (state.over_budget_seconds + 1) if was_over_budget else 0
state.window_start_ms = now_ms
state.packets_this_window = 0
state.bytes_this_window = 0
if state.over_budget_seconds >= RATE_LIMIT_OVER_BUDGET_SECONDS_TO_DISCONNECT:
_disconnect_abusive_peer(peer_id, "input rate limit exceeded for %d consecutive seconds" % state.over_budget_seconds)
return
state.packets_this_window += 1
state.bytes_this_window += bytes.size()
if state.packets_this_window > RATE_LIMIT_PACKETS_PER_SEC or state.bytes_this_window > RATE_LIMIT_BYTES_PER_SEC:
return # over budget for the current window — drop, counted above at the next window roll
# Framing (§3.1 step 3), validated before decoding — unpack_input can't
# be trusted to catch this itself: StreamPeerBuffer silently zero-fills
# past EOF rather than erroring (found during Phase 2's adversarial
# review's hostile-client stress test), so a too-short or size-mismatched
# payload would otherwise decode "successfully" into garbage actions
# instead of being rejected.
if bytes.size() < NetCodec.INPUT_HEADER_SIZE:
_count_malformed(peer_id, state)
return
var count: int = bytes[5] # type_version(1) + seq(4) precede count — see pack_input's own layout
if count == 0 or count > NetCodec.MAX_REDUNDANCY or bytes.size() != NetCodec.INPUT_HEADER_SIZE + count * NetCodec.INPUT_ENTRY_SIZE:
_count_malformed(peer_id, state)
return
var decoded := NetCodec.unpack_input(bytes)
input_received.emit(peer_id, decoded)
func _count_malformed(peer_id: int, state: _PeerInputState) -> void:
state.malformed_count += 1
if state.malformed_count >= MALFORMED_LIMIT_TO_DISCONNECT:
_disconnect_abusive_peer(peer_id, "too many malformed input packets (%d)" % state.malformed_count)
func _disconnect_abusive_peer(peer_id: int, reason: String) -> void:
push_warning("MatchSim: disconnecting peer %d for abuse: %s" % [peer_id, reason])
_peer_input_state.erase(peer_id)
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
@rpc("authority", "call_remote", "unreliable_ordered", 2)
func _snapshot(bytes: PackedByteArray) -> void:
_track_received(bytes.size())
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)