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 # Task 5.10. A packet this autoload dropped before it could ever reach a match, # with the verbatim bytes — the replay log's whole reason to exist is the field # report "my input did nothing", and an accepted-input-only log has thrown away # exactly the evidence that would explain it. `reason` is an InputRejectReason; # the transport layer deliberately does not know about the replay format's own # record kinds, so the mapping lives at the listener. signal input_rejected(peer_id: int, reason: int, bytes: PackedByteArray) signal snapshot_received(decoded: Dictionary) # decoded: see NetCodec.unpack_snapshot signal score_update_received(score: Dictionary) signal state_change_received(state: int, at_tick: int) # §6.1 MatchState.State # §6.2 step 6. positions/rotations are body-order: every slot in order, then # the ball — the same order the snapshot uses, so one convention covers both. # rotations is 4 floats per body (x, y, z, w). signal kickoff_received(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int) signal goal_scored_received(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int) signal clock_state_received(running: bool, end_tick: int, remaining_ticks: int, at_tick: int) signal match_bootstrap_received(state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int) # §6.3 task 5.8: a spectator has been given a vacated slot at a kickoff. signal slot_assigned_received(peer_id: int, slot_index: int) # Input validation (MULTIPLAYER_SPEC.md §3.1 steps 2-3; multiplayer-next.md 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 # Leaky-bucket excess tolerance, expressed in the same "N seconds' worth of # budget" terms the original consecutive-streak design used. An adversarial # review found that design — a streak counter that HARD-RESET to 0 on any # single clean window — was trivially evaded by a duty-cycled flood (burst, # then one clean window, repeat): reproduced sustaining ~33x the packet # budget indefinitely with zero disconnect warnings. A leaky bucket doesn't # care how the excess is distributed in time — see the window-roll logic # below for how it accumulates and drains. const RATE_LIMIT_EXCESS_PACKETS_TO_DISCONNECT := RATE_LIMIT_PACKETS_PER_SEC * 3 const RATE_LIMIT_EXCESS_BYTES_TO_DISCONNECT := RATE_LIMIT_BYTES_PER_SEC * 3 const MALFORMED_LIMIT_TO_DISCONNECT := 20 # Task 5.10: how many rejected packets per peer per rate-limit window are # forwarded to `input_rejected`. Sized so an honest client — whose rejects are # occasional by definition, since a client rejected every tick is a bug the log # is meant to catch — is never sampled away, while a flood cannot turn the log # into unbounded attacker-controlled disk writes. const REJECTS_RECORDED_PER_WINDOW := 8 # Server-stall grace (found by task 5.10's own reject recording, which is the # only reason it was visible at all). # # When the server stalls — a 2s SIGSTOP stands in for a GC/IO/scheduler hitch — # the client keeps sending at 60Hz throughout, and ENet delivers that entire # backlog in the first window after resume. Measured: 70 of an HONEST client's # input packets rejected as "rate limit exceeded", against a limit the client # never came close to violating on its own. Redundancy does not cover it: the # dropped packets are CONTIGUOUS, so each one's redundancy window falls inside # the same dropped run — 0 of 70 were rescued, and 82 of 923 sequences (8.88%, # ~1.4s of that player's input) never reached the server at all, versus 0.00% # missing on an otherwise identical run with no stall. Every prediction gate # still passed, which is exactly why this needed the log to find. # # So: don't rate-limit a backlog the server itself caused. The grace is capped, # expires after two windows, and is granted only to peers already being # tracked, so it cannot be farmed by a peer that connects during the stall. An # attacker who can induce server stalls to earn budget already has a strictly # worse capability than sending extra input packets. const STALL_DETECT_MS := 250 const MAX_STALL_GRACE_PACKETS := SimConstants.TICK_HZ * 4 # 4s of a 60Hz client's backlog const STALL_GRACE_WINDOWS := 2 enum InputRejectReason { MALFORMED = 0, RATE_LIMIT = 1, } class _PeerInputState: var window_start_ms := 0 var packets_this_window := 0 var bytes_this_window := 0 # Leaky bucket: grows by this window's actual total, drains by one # window's worth of budget, every window — regardless of whether that # window was itself over or under budget. A steady rate at or under # budget nets to zero forever (never accumulates); any sustained AVERAGE # above budget accumulates over time no matter how it's shaped into # bursts, unlike a streak counter a clean gap can reset to 0. var excess_packets := 0.0 var excess_bytes := 0.0 var malformed_count := 0 # Reject-recording budget for the current window. Without it the diagnostic # is a remote disk-fill amplifier: the attacker chooses the flood rate, and # every dropped packet would otherwise become a disk write. Capped per # window, reset with the window itself, so an honest client's occasional # reject is always captured while a flood contributes a bounded sample. var rejects_recorded_this_window := 0 # Extra packets this peer may send before the limiter treats it as abuse, # granted when the SERVER stalls and expiring shortly after. var grace_packets := 0 var grace_windows_left := 0 var logged_rate_limit_this_window := false var _peer_input_state: Dictionary = {} # peer_id -> _PeerInputState, server only # Uncapped lifetime reject totals, so the sampled log can be read against the # true figure — "8 rate-limit rejects recorded" means nothing on its own when # the recorder itself stops at 8 per window. Deliberately NOT part of # _PeerInputState, which is erased the moment a peer disconnects: a departed # peer's reject history is exactly what the post-mortem wants, and the first # version of this lost it (every summary printed an empty dictionary, because # the client had always disconnected by the time the server tore the match # down). peer_id -> {"malformed": int, "rate_limit": int}. var _reject_totals: Dictionary = {} var _last_physics_ms := 0 # 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. Use get_bytes_sent_per_sec() / # get_bytes_received_per_sec() to READ these, not the raw fields directly # — see those functions for why. 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 # An adversarial review found bytes_*_per_sec only ever gets recomputed # INSIDE _track_sent()/_track_received() — i.e. only when traffic actually # arrives — so if traffic stops entirely (right before a disconnect, or # during exactly the kind of outage this overlay exists to diagnose), the # last computed rate displays forever instead of decaying toward zero. # Report zero once meaningfully more than one window has passed with # nothing tracked, rather than trusting a stale field. func get_bytes_sent_per_sec() -> float: if Time.get_ticks_msec() - _sent_window_start_ms > BANDWIDTH_WINDOW_MS * 2: return 0.0 return bytes_sent_per_sec func get_bytes_received_per_sec() -> float: if Time.get_ticks_msec() - _received_window_start_ms > BANDWIDTH_WINDOW_MS * 2: return 0.0 return bytes_received_per_sec func _ready() -> void: NetworkManager.client_disconnected.connect(func(peer_id: int) -> void: _peer_input_state.erase(peer_id)) # Seeded here, not left at 0, so the first physics frame measures a frame # gap rather than the whole process uptime. _last_physics_ms = Time.get_ticks_msec() # Server-side stall watchdog. A SIGSTOPped or hitching process doesn't run this # either, so the first physics frame after the stall is the one that sees the # whole wall-clock gap — which is precisely the size of the client backlog # about to arrive. Grace is handed only to peers ALREADY sending input, so a # peer that connects during the stall gets none of it. func _physics_process(_delta: float) -> void: var now := Time.get_ticks_msec() var gap := now - _last_physics_ms _last_physics_ms = now # NetworkManager.shutdown() swaps in an OfflineMultiplayerPeer before the # smoke harness's deferred quit runs. Querying MultiplayerAPI.is_server() # during that hand-off can call get_unique_id() on an inactive ENet peer and # emit errors every physics frame; the NetworkManager role flag is the safe # lifecycle guard at this boundary. if not NetworkManager.is_server or _peer_input_state.is_empty(): return if gap < STALL_DETECT_MS: return var credit: int = mini(int(float(gap) * SimConstants.TICK_HZ / 1000.0), MAX_STALL_GRACE_PACKETS) for peer_id in _peer_input_state: var state: _PeerInputState = _peer_input_state[peer_id] state.grace_packets = mini(state.grace_packets + credit, MAX_STALL_GRACE_PACKETS) state.grace_windows_left = STALL_GRACE_WINDOWS push_warning("MatchSim: server stalled %dms — granting %d packets of rate-limit grace to %d peer(s)" % [ gap, credit, _peer_input_state.size() ]) ServerLog.warn("server_stalled", {"gap_ms": gap, "grace_packets": credit, "peers": _peer_input_state.size()}) 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) # Also the client's cue to ask for live match state — see # NetworkedMatch._on_match_config_requested. A late joiner's bootstrap has the # SAME race match_config has: the server sends it when the peer joins the # roster, which is before that peer has loaded the match scene and connected # its listeners, so a one-shot send is simply missed. Delivery has to be # "ask until you get it" for both. signal match_config_requested(peer_id: int) 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: # A server-side disconnect can leave peer_id in get_peers() until the # current poll batch settles. Do not enter Godot's RPC path for that stale # target; NetSim repeats this check at fire time for delayed sends. if not NetworkManager.can_send_to_peer(peer_id): return _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) # §6.1 task 5.1. Reliable channel 0, and it carries the ABSOLUTE tick the # transition happened on rather than a duration — §6.2's closing note: on a # lossy link ENet's RTO can stretch a lifecycle burst to ~600ms, and a # duration would then be applied from whenever it happened to arrive. # The same state also rides every snapshot's match_state byte, so a client # that misses this entirely still converges (see NetworkedMatch's own # _on_snapshot_received) — this RPC exists to make the transition PROMPT and # to carry `at_tick`, not to be the sole channel. func send_state_change(state: int, at_tick: int) -> void: _state_change.rpc(state, at_tick) # §1's "seeded RNG for kickoff jitter" decision, enforced: the server sends the # resulting TRANSFORMS, never a seed. Shared-seed determinism would require # both sides to consume the RNG stream in identical order forever, and the # first randf() anyone later adds to the reset path silently desyncs kickoff # positions with no error message. A few hundred bytes once per kickoff cannot # rot that way. func send_kickoff(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int) -> void: _kickoff.rpc(positions, rotations, countdown_start_tick, reset_gen) func send_goal_scored(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int) -> void: _goal_scored.rpc(scoring_team, score, goal_tick, resume_tick) # remaining_ticks is authoritative while `running` is false: a stopped clock # cannot be derived from end_tick minus the current tick, or it drains through # every goal pause and kickoff countdown. func send_clock_state(running: bool, end_tick: int, remaining_ticks: int, at_tick: int) -> void: _clock_state.rpc(running, end_tick, remaining_ticks, at_tick) # §6.2 step 2 / §6.3: everything a peer needs to reconstruct the CURRENT match # on arrival, sent to one peer rather than broadcast. # # match_config alone is not enough and never was: it carries arena and roster # only, so a late joiner or a reconnecting player had no score, no clock, and # no match state until the next goal or transition happened to fire. An # adversarial review caught that; §6.2 step 2's `welcome` is specified to carry # exactly this set, so this is that message under a name that does not clash # with MatchNet's own lobby-level welcome. func send_match_bootstrap(peer_id: int, state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int) -> void: _match_bootstrap.rpc_id(peer_id, state, at_tick, score, end_tick, clock_running, reset_gen, remaining_ticks) # §6.3's late-joiner promotion. BROADCAST, not addressed to the new owner # alone: every client holds its own copy of the slot list, and a peer_id that # only the promoted client learns about leaves everyone else's copy naming a # player who is no longer in that seat. Reliable channel 0 — a client that # misses this keeps flying somebody else's ship as a remote body forever, and # unlike match_state there is no per-snapshot field that would re-converge it. func send_slot_assigned(peer_id: int, slot_index: int) -> void: _slot_assigned.rpc(peer_id, slot_index) @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("authority", "call_remote", "reliable", 0) func _slot_assigned(peer_id: int, slot_index: int) -> void: slot_assigned_received.emit(peer_id, slot_index) @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"] ) match_config_requested.emit(peer_id) @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: # The leaky bucket drains against the SAME budget the window itself was # policed with, grace included — otherwise a server stall would still # accumulate excess toward a disconnect for traffic the server just # explicitly allowed. state.excess_packets = maxf(0.0, state.excess_packets + float(state.packets_this_window) - float(_packet_budget(state))) state.excess_bytes = maxf(0.0, state.excess_bytes + float(state.bytes_this_window) - float(_byte_budget(state))) state.window_start_ms = now_ms state.packets_this_window = 0 state.bytes_this_window = 0 state.rejects_recorded_this_window = 0 state.logged_rate_limit_this_window = false if state.grace_windows_left > 0: state.grace_windows_left -= 1 if state.grace_windows_left == 0: state.grace_packets = 0 if state.excess_packets > RATE_LIMIT_EXCESS_PACKETS_TO_DISCONNECT or state.excess_bytes > RATE_LIMIT_EXCESS_BYTES_TO_DISCONNECT: # Record before disconnecting, same reasoning as _count_malformed: # the log should contain the packet that ended the connection, not # stop one short of it. _emit_reject(peer_id, state, InputRejectReason.RATE_LIMIT, bytes) _disconnect_abusive_peer(peer_id, "input rate limit exceeded (excess_packets=%.0f excess_bytes=%.0f)" % [state.excess_packets, state.excess_bytes]) return state.packets_this_window += 1 state.bytes_this_window += bytes.size() if state.packets_this_window > _packet_budget(state) or state.bytes_this_window > _byte_budget(state): # Over budget for the current window — drop, counted above at the next # window roll. if not state.logged_rate_limit_this_window: # ONCE per window, not per packet: a flood is thousands of packets a # second and the log line must not become the amplifier the replay # recorder was capped to avoid being. state.logged_rate_limit_this_window = true ServerLog.warn("rate_limited", { "peer_id": peer_id, "packets": state.packets_this_window, "budget": _packet_budget(state), "grace": state.grace_packets, }) _emit_reject(peer_id, state, InputRejectReason.RATE_LIMIT, bytes) return # 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, bytes) 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, bytes) return var decoded := NetCodec.unpack_input(bytes) # Carry the verbatim wire bytes alongside the decode. Task 5.10's replay # log stores exactly what arrived rather than a re-serialisation, which is # the whole reason it can reproduce a reported snap: a re-encode would # launder away precisely the malformed or edge-case payload being chased. decoded["raw"] = bytes input_received.emit(peer_id, decoded) # The budget a peer is actually policed against right now: the standing limit # plus any outstanding server-stall grace. Bytes scale with packets by the same # worst-case-packet factor RATE_LIMIT_BYTES_PER_SEC itself is derived from, so # the two budgets can never drift apart by hand. func _packet_budget(state: _PeerInputState) -> int: return RATE_LIMIT_PACKETS_PER_SEC + state.grace_packets func _byte_budget(state: _PeerInputState) -> int: return _packet_budget(state) * (NetCodec.INPUT_HEADER_SIZE + NetCodec.MAX_REDUNDANCY * NetCodec.INPUT_ENTRY_SIZE) func _count_malformed(peer_id: int, state: _PeerInputState, bytes: PackedByteArray) -> void: state.malformed_count += 1 # Emitted before the disconnect check so the packet that finally crossed # the limit is itself in the log, not just the 19 before it. _emit_reject(peer_id, state, InputRejectReason.MALFORMED, bytes) if state.malformed_count >= MALFORMED_LIMIT_TO_DISCONNECT: _disconnect_abusive_peer(peer_id, "too many malformed input packets (%d)" % state.malformed_count) func _emit_reject(peer_id: int, state: _PeerInputState, reason: int, bytes: PackedByteArray) -> void: var totals: Dictionary = _reject_totals.get(peer_id, {"malformed": 0, "rate_limit": 0}) var key := "rate_limit" if reason == InputRejectReason.RATE_LIMIT else "malformed" totals[key] = int(totals[key]) + 1 _reject_totals[peer_id] = totals if state.rejects_recorded_this_window >= REJECTS_RECORDED_PER_WINDOW: return state.rejects_recorded_this_window += 1 input_rejected.emit(peer_id, reason, bytes) # Server-side, diagnostic. peer_id -> {"malformed": int, "rate_limit": int}, # uncapped and surviving the peer's disconnect. Peers with no rejects at all # never appear, so an empty dictionary means a clean session. func get_reject_totals() -> Dictionary: return _reject_totals.duplicate(true) func _disconnect_abusive_peer(peer_id: int, reason: String) -> void: push_warning("MatchSim: disconnecting peer %d for abuse: %s" % [peer_id, reason]) # Task 6.4: the one server event an operator is most likely to be asked # about ("why was I kicked?"), and it was previously only a push_warning — # which does not carry the peer, the reason or a timestamp into the log # stream a container actually captures. ServerLog.warn("peer_kicked", {"peer_id": peer_id, "reason": reason}) NetworkManager.invalidate_peer(peer_id) _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 _state_change(state: int, at_tick: int) -> void: # "authority" already means a forging client is rejected by Godot itself # (verified for _match_config/_score_update/_snapshot during Phase 2), but # an authoritative server sending a state this build doesn't know about is # a real forward-compatibility case — drop it rather than driving the # client into an undefined state. if not MatchState.is_valid(state): push_warning("MatchSim: ignoring unknown match_state %d from server" % state) return state_change_received.emit(state, at_tick) @rpc("authority", "call_remote", "reliable", 0) func _kickoff(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int) -> void: # 4 quaternion floats per body. A mismatch means a corrupt or hostile # payload; dropping it is safe because the snapshot stream still carries # authoritative poses and the next kickoff will re-sync. if rotations.size() != positions.size() * 4: push_warning("MatchSim: kickoff payload mismatch (%d positions, %d rotation floats)" % [positions.size(), rotations.size()]) return kickoff_received.emit(positions, rotations, countdown_start_tick, reset_gen) @rpc("authority", "call_remote", "reliable", 0) func _goal_scored(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int) -> void: goal_scored_received.emit(scoring_team, score, goal_tick, resume_tick) @rpc("authority", "call_remote", "reliable", 0) func _clock_state(running: bool, end_tick: int, remaining_ticks: int, at_tick: int) -> void: clock_state_received.emit(running, end_tick, remaining_ticks, at_tick) @rpc("authority", "call_remote", "reliable", 0) func _match_bootstrap(state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int) -> void: if not MatchState.is_valid(state): push_warning("MatchSim: ignoring bootstrap with unknown match_state %d" % state) return match_bootstrap_received.emit(state, at_tick, score, end_tick, clock_running, reset_gen, remaining_ticks) @rpc("authority", "call_remote", "reliable", 0) func _score_update(score: Dictionary) -> void: score_update_received.emit(score)