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.
This commit is contained in:
Josh Creek
2026-08-20 13:32:16 +01:00
parent b290f49143
commit 9d8a8080ba
4 changed files with 102 additions and 2 deletions
+36
View File
@@ -52,10 +52,42 @@ class _PeerInputState:
var _peer_input_state: Dictionary = {} # peer_id -> _PeerInputState, server only 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: func _ready() -> void:
NetworkManager.client_disconnected.connect(func(peer_id: int) -> void: _peer_input_state.erase(peer_id)) 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 # Server only: the last match_config actually sent, so a client whose own
# scene load (and therefore its match_config_received listener) finishes # scene load (and therefore its match_config_received listener) finishes
# AFTER the server already broadcast can still get it — a one-shot # AFTER the server already broadcast can still get it — a one-shot
@@ -80,12 +112,14 @@ func request_match_config() -> void:
func send_input(bytes: PackedByteArray) -> void: func send_input(bytes: PackedByteArray) -> void:
_track_sent(bytes.size())
# bytes is already fully packed (any timestamps it carries are already # bytes is already fully packed (any timestamps it carries are already
# fixed), so wrapping the dispatch itself is enough — task 2.8. # fixed), so wrapping the dispatch itself is enough — task 2.8.
NetSim.send(func() -> void: _recv_input.rpc_id(1, bytes), 1) NetSim.send(func() -> void: _recv_input.rpc_id(1, bytes), 1)
func send_snapshot(peer_id: int, bytes: PackedByteArray) -> void: 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) NetSim.send(func() -> void: _snapshot.rpc_id(peer_id, bytes), peer_id)
@@ -113,6 +147,7 @@ func _request_match_config() -> void:
func _recv_input(bytes: PackedByteArray) -> void: func _recv_input(bytes: PackedByteArray) -> void:
if not multiplayer.is_server(): if not multiplayer.is_server():
return return
_track_received(bytes.size())
var peer_id := multiplayer.get_remote_sender_id() var peer_id := multiplayer.get_remote_sender_id()
var state: _PeerInputState = _peer_input_state.get(peer_id) var state: _PeerInputState = _peer_input_state.get(peer_id)
@@ -173,6 +208,7 @@ func _disconnect_abusive_peer(peer_id: int, reason: String) -> void:
@rpc("authority", "call_remote", "unreliable_ordered", 2) @rpc("authority", "call_remote", "unreliable_ordered", 2)
func _snapshot(bytes: PackedByteArray) -> void: func _snapshot(bytes: PackedByteArray) -> void:
_track_received(bytes.size())
var decoded := NetCodec.unpack_snapshot(bytes) var decoded := NetCodec.unpack_snapshot(bytes)
snapshot_received.emit(decoded) snapshot_received.emit(decoded)
+21 -2
View File
@@ -33,11 +33,30 @@ func _process(_delta: float) -> void:
if not _label or not _label.visible: if not _label or not _label.visible:
return return
if NetworkManager.is_server: if NetworkManager.is_server:
_label.text = "NET: server, %d peer(s)" % (MatchNet.roster.size()) _label.text = "NET: server, %d peer(s) out %s in %s" % [
MatchNet.roster.size(), _format_kbps(MatchSim.bytes_sent_per_sec), _format_kbps(MatchSim.bytes_received_per_sec),
]
elif NetworkManager.is_client: elif NetworkManager.is_client:
if NetworkManager.rtt_ms < 0.0: if NetworkManager.rtt_ms < 0.0:
_label.text = "NET: client, connecting (no clock sample yet)" _label.text = "NET: client, connecting (no clock sample yet)"
else: else:
_label.text = "NET: client RTT %.1fms clock offset %.1fms" % [NetworkManager.rtt_ms, NetworkManager.clock_offset_ms] # task 3.7: RTT, jitter, loss, buffer depth, snapshot age,
# bandwidth all live here now. Prediction error is intentionally
# absent — there is no client-side prediction until Phase 4, so
# there is nothing honest to show for it yet.
var stats := {}
var game := get_tree().get_first_node_in_group("game")
if game and game.has_method("get_net_debug_stats"):
stats = game.get_net_debug_stats()
_label.text = "NET: client RTT %.1fms jitter %.1fms offset %.1fms\nbuf depth %s lead %s loss %.1f%% snap age %.1fms\nout %s in %s" % [
NetworkManager.rtt_ms, NetworkManager.jitter_ms, NetworkManager.clock_offset_ms,
str(stats.get("input_buffer_depth", -1)), str(stats.get("input_lead", "-")),
stats.get("snapshot_loss_pct", 0.0), stats.get("snapshot_age_ms", 0.0),
_format_kbps(MatchSim.bytes_sent_per_sec), _format_kbps(MatchSim.bytes_received_per_sec),
]
else: else:
_label.text = "NET: offline" _label.text = "NET: offline"
func _format_kbps(bytes_per_sec: float) -> String:
return "%.2f KB/s" % (bytes_per_sec / 1000.0)
+14
View File
@@ -71,6 +71,14 @@ var clock_offset_ms := 0.0 # add to a local Time.get_ticks_msec() reading to
var _clock_samples: Array[Dictionary] = [] var _clock_samples: Array[Dictionary] = []
var _ping_accum_sec := 0.0 var _ping_accum_sec := 0.0
# Jitter (task 3.7's debug overlay): RFC3550-style EWMA of the deviation
# between consecutive RAW (not min-filtered) RTT samples — rtt_ms itself is
# a min-RTT, deliberately insensitive to jitter by design (§4.7), so a
# separate, unfiltered running estimate is needed to actually see it.
const JITTER_EWMA_ALPHA := 1.0 / 16.0 # matches RFC3550's own smoothing factor
var jitter_ms := 0.0
var _last_raw_rtt_ms := -1.0
func _ready() -> void: func _ready() -> void:
get_tree().set_multiplayer_poll_enabled(false) get_tree().set_multiplayer_poll_enabled(false)
@@ -162,6 +170,8 @@ func shutdown() -> void:
clock_offset_ms = 0.0 clock_offset_ms = 0.0
_clock_samples.clear() _clock_samples.clear()
_ping_accum_sec = 0.0 _ping_accum_sec = 0.0
jitter_ms = 0.0
_last_raw_rtt_ms = -1.0
@rpc("any_peer", "call_remote", "reliable") @rpc("any_peer", "call_remote", "reliable")
@@ -192,6 +202,10 @@ func _pong(client_send_ms: int, server_now_ms: int) -> void:
var now_ms := Time.get_ticks_msec() var now_ms := Time.get_ticks_msec()
var sample_rtt := float(now_ms - client_send_ms) var sample_rtt := float(now_ms - client_send_ms)
var sample_offset := float(server_now_ms) + sample_rtt / 2.0 - float(now_ms) var sample_offset := float(server_now_ms) + sample_rtt / 2.0 - float(now_ms)
if _last_raw_rtt_ms >= 0.0:
var deviation := absf(sample_rtt - _last_raw_rtt_ms)
jitter_ms += (deviation - jitter_ms) * JITTER_EWMA_ALPHA
_last_raw_rtt_ms = sample_rtt
_clock_samples.append({"t": now_ms, "rtt": sample_rtt, "offset": sample_offset}) _clock_samples.append({"t": now_ms, "rtt": sample_rtt, "offset": sample_offset})
var cutoff := now_ms - int(CLOCK_WINDOW_SEC * 1000.0) var cutoff := now_ms - int(CLOCK_WINDOW_SEC * 1000.0)
+31
View File
@@ -91,6 +91,15 @@ var _input_history: Array[ShipAction] = []
var _last_received_snapshot_tick := 0 # client only: echoed back as ack_snapshot_tick var _last_received_snapshot_tick := 0 # client only: echoed back as ack_snapshot_tick
var _input_lead_controller := InputLeadController.new() # client only (§3.3) var _input_lead_controller := InputLeadController.new() # client only (§3.3)
var _last_known_input_buffer_depth := -1 # client only: -1 = no snapshot with this field yet var _last_known_input_buffer_depth := -1 # client only: -1 = no snapshot with this field yet
# Loss estimate (task 3.7's debug overlay), client only: snapshots go out
# at a steady one-tick cadence, so a server_tick that jumps by more than 1
# since the last received one is direct evidence of a dropped or reordered
# snapshot on the unreliable channel. EWMA over each reception's own
# "missed / (missed + 1)" fraction rather than a flat drop-count, so it
# reads as a live percentage and decays naturally once loss stops.
const SNAPSHOT_LOSS_EWMA_ALPHA := 1.0 / 16.0
var _snapshot_loss_ewma := 0.0
var _expected_next_snapshot_tick := -1
# §3.1 step 4. Not 120: InputLeadController.LEAD_MAX is 12, so anything # §3.1 step 4. Not 120: InputLeadController.LEAD_MAX is 12, so anything
# claiming to be further ahead of the current server tick than this is # claiming to be further ahead of the current server tick than this is
# broken or hostile, not just an honest client running a legitimately fast # broken or hostile, not just an honest client running a legitimately fast
@@ -405,6 +414,11 @@ func _on_snapshot_received(decoded: Dictionary) -> void:
var server_tick: int = decoded["server_tick"] var server_tick: int = decoded["server_tick"]
var reset_gen: int = decoded["reset_gen"] var reset_gen: int = decoded["reset_gen"]
var bodies: Array = decoded["bodies"] var bodies: Array = decoded["bodies"]
if _expected_next_snapshot_tick >= 0:
var missed := maxi(0, server_tick - _expected_next_snapshot_tick)
var sample := float(missed) / float(missed + 1)
_snapshot_loss_ewma += (sample - _snapshot_loss_ewma) * SNAPSHOT_LOSS_EWMA_ALPHA
_expected_next_snapshot_tick = server_tick + 1
_last_received_snapshot_tick = server_tick _last_received_snapshot_tick = server_tick
# Per-client header (§2.4): unlike the shared body segment, this is # Per-client header (§2.4): unlike the shared body segment, this is
# genuinely this recipient's own — input_buffer_depth is THIS client's # genuinely this recipient's own — input_buffer_depth is THIS client's
@@ -468,6 +482,23 @@ func _current_interp_delay_ms() -> float:
return clampf(one_way + SNAPSHOT_INTERVAL_MS * 1.5, INTERP_DELAY_MIN_MS, INTERP_DELAY_MAX_MS) return clampf(one_way + SNAPSHOT_INTERVAL_MS * 1.5, INTERP_DELAY_MIN_MS, INTERP_DELAY_MAX_MS)
# Client-only stats for task 3.7's debug overlay, discovered via the "game"
# group the same way HUDController finds this node — no direct reference
# needed, and the overlay degrades gracefully (has_method check) against
# any mode that doesn't implement this at all.
func get_net_debug_stats() -> Dictionary:
var snapshot_age_ms := 0.0
if NetworkManager.rtt_ms >= 0.0:
var estimated_now_tick := _estimated_tick(NetworkManager.get_server_time_estimate_ms())
snapshot_age_ms = (estimated_now_tick - float(_last_received_snapshot_tick)) * NetInterpolator.TICK_MS
return {
"input_buffer_depth": _last_known_input_buffer_depth,
"input_lead": _input_lead_controller.lead,
"snapshot_age_ms": snapshot_age_ms,
"snapshot_loss_pct": _snapshot_loss_ewma * 100.0,
}
# Collider time: present-time estimate, applied once per physics tick. # Collider time: present-time estimate, applied once per physics tick.
func _physics_process(_delta: float) -> void: func _physics_process(_delta: float) -> void:
# Automatic multiplayer polling is disabled project-wide (task 1.3) — # Automatic multiplayer polling is disabled project-wide (task 1.3) —