diff --git a/Game/project.godot b/Game/project.godot index e16a976e..8ee38052 100644 --- a/Game/project.godot +++ b/Game/project.godot @@ -44,6 +44,7 @@ GameSettings="*res://scripts/game_settings.gd" VideoSettings="*res://scripts/video_settings.gd" BackgroundFPS="*res://scripts/background_fps.gd" PerfOverlay="*res://scripts/perf_overlay.gd" +NetSim="*res://scripts/net_sim.gd" NetworkManager="*res://scripts/network_manager.gd" MatchNet="*res://scripts/match_net.gd" MatchSim="*res://scripts/match_sim.gd" diff --git a/Game/scripts/match_sim.gd b/Game/scripts/match_sim.gd index 46474143..a98f32ad 100644 --- a/Game/scripts/match_sim.gd +++ b/Game/scripts/match_sim.gd @@ -48,11 +48,13 @@ func request_match_config() -> void: func send_input(bytes: PackedByteArray) -> void: - _recv_input.rpc_id(1, bytes) + # 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: - _snapshot.rpc_id(peer_id, bytes) + NetSim.send(func() -> void: _snapshot.rpc_id(peer_id, bytes), peer_id) func send_score_update(score: Dictionary) -> void: diff --git a/Game/scripts/net_sim.gd b/Game/scripts/net_sim.gd new file mode 100644 index 00000000..fc2a2a28 --- /dev/null +++ b/Game/scripts/net_sim.gd @@ -0,0 +1,116 @@ +extends Node + +# Autoload (project.godot [autoload] NetSim). Debug-only, seeded +# latency/jitter/loss/duplicate decorator around outgoing RPC dispatch — +# task 2.8. A pure passthrough (send() calls dispatch.call() immediately) +# unless CLI flags are given, so every existing test and the real game are +# byte-for-byte unaffected by this autoload merely existing. +# +# CLI (read once, in this process's own OS.get_cmdline_user_args()): +# --net-sim-latency= one-way delay added before each wrapped send +# --net-sim-jitter= extra uniform-random 0..jitter added per send +# --net-sim-loss=<0..1> fraction of sends dropped entirely (never sent) +# --net-sim-dup=<0..1> probability a send is ALSO sent a second time +# --net-sim-seed= RNG seed (default fixed, so a bad run reproduces +# unless a CI/local run deliberately wants a +# different one — same "seeded so failures +# reproduce" bar as §11's testing section sets) +# +# "Asymmetric-capable" per §7 task 2.8 is not a separate feature: each +# process reads only its own CLI args and only delays its own outgoing +# sends, so running the host and client with different flags (e.g. a +# lossy-upload client against a clean host) already produces asymmetric +# behaviour with no extra plumbing. +# +# Call sites build a zero-argument Callable that performs the actual +# rpc_id()/rpc() dispatch, so NetSim never needs to know per-call argument +# shapes. IMPORTANT for callers that embed a timestamp in the call (e.g. +# NetworkManager's _ping/_pong): capture Time.get_ticks_msec() *before* +# calling send(), not inside the wrapped Callable — the delay is meant to +# simulate wire transit *after* the packet is "sent", so a timestamp taken +# inside the delayed closure would silently absorb this process's own +# outbound leg out of any round-trip measurement built on top of it. +# +# Wraps MatchSim.send_input / send_snapshot per the doc's task 2.8 scope, +# plus NetworkManager's _ping/_pong dispatch — the latter is a deliberate +# addition beyond the literal task text: it's the only RTT measurement that +# already exists and is already tested (tests/clock_smoke.gd, task 1.8), so +# routing it through NetSim is what makes "`--net-sim-latency 80` measurably +# raises observed RTT" (this task's own stated acceptance criterion) +# checkable today, without waiting on Phase 3's per-peer snapshot echo. + +const DEFAULT_SEED := 20260820 + +var latency_ms := 0.0 +var jitter_ms := 0.0 +var loss_fraction := 0.0 +var dup_fraction := 0.0 + +var _rng := RandomNumberGenerator.new() # owned instance — never the global RNG, task 0.7's rule + + +func _ready() -> void: + var seed_value := DEFAULT_SEED + for arg: String in OS.get_cmdline_user_args(): + if arg.begins_with("--net-sim-latency="): + latency_ms = maxf(0.0, arg.get_slice("=", 1).to_float()) + elif arg.begins_with("--net-sim-jitter="): + jitter_ms = maxf(0.0, arg.get_slice("=", 1).to_float()) + elif arg.begins_with("--net-sim-loss="): + loss_fraction = clampf(arg.get_slice("=", 1).to_float(), 0.0, 1.0) + elif arg.begins_with("--net-sim-dup="): + dup_fraction = clampf(arg.get_slice("=", 1).to_float(), 0.0, 1.0) + elif arg.begins_with("--net-sim-seed="): + seed_value = arg.get_slice("=", 1).to_int() + _rng.seed = seed_value + + +func is_active() -> bool: + return latency_ms > 0.0 or jitter_ms > 0.0 or loss_fraction > 0.0 or dup_fraction > 0.0 + + +# target_peer_id: the specific remote peer this dispatch is addressed to +# (rpc_id's target), or -1 for a broadcast / not a targeted send. Only used +# to re-validate a delayed send right before it actually fires — see _fire. +func send(dispatch: Callable, target_peer_id: int = -1) -> void: + if not is_active(): + dispatch.call() + return + if _rng.randf() < loss_fraction: + return + _schedule(dispatch, target_peer_id, (latency_ms + _rng.randf() * jitter_ms) / 1000.0) + if _rng.randf() < dup_fraction: + _schedule(dispatch, target_peer_id, (latency_ms + _rng.randf() * jitter_ms) / 1000.0) + + +func _schedule(dispatch: Callable, target_peer_id: int, delay_sec: float) -> void: + if delay_sec <= 0.0: + dispatch.call() + return + get_tree().create_timer(delay_sec, false).timeout.connect(func() -> void: _fire(dispatch, target_peer_id)) + + +# Re-validates the target right before a DELAYED send actually fires. +# NetSim's whole point is to hold a packet in flight past the moment it was +# queued, and in that window the target peer (or this process's own +# connection) can legitimately be gone — a disconnect mid-match, or this +# process's own shutdown() already having reset multiplayer_peer to a fresh +# OfflineMultiplayerPeer. Firing anyway reproduced two real bugs while +# building this task: "Attempt to call RPC with unknown peer ID" (stale +# remote target — networked_match.gd's own get_peers() filter on +# _broadcast_snapshot only checked validity at *schedule* time, and the +# target had disconnected by the time the delayed send actually fired) and +# "'_recv_input' on yourself is not allowed by selected mode" (this +# process's own peer was already torn down, so peer id 1 now refers to +# itself instead of the server). The synchronous (delay_sec <= 0 / NetSim +# inactive) path is deliberately NOT re-validated here — nothing has had +# time to change since the caller's own validation, and matching the +# pre-NetSim behaviour exactly there is what keeps NetSim a true no-op when +# no CLI flags are given. +func _fire(dispatch: Callable, target_peer_id: int) -> void: + var peer := multiplayer.multiplayer_peer + if peer == null or peer is OfflineMultiplayerPeer: + return + if target_peer_id != -1 and target_peer_id not in multiplayer.get_peers(): + return + dispatch.call() diff --git a/Game/scripts/network_manager.gd b/Game/scripts/network_manager.gd index 074f8e01..f4b74ddd 100644 --- a/Game/scripts/network_manager.gd +++ b/Game/scripts/network_manager.gd @@ -93,7 +93,10 @@ func _process(delta: float) -> void: _ping_accum_sec += delta if _ping_accum_sec >= PING_INTERVAL_SEC: _ping_accum_sec = 0.0 - _ping.rpc_id(1, Time.get_ticks_msec()) + # Capture the timestamp now, before NetSim (task 2.8) can add any + # simulated delay — see net_sim.gd's header comment for why. + var send_ms := Time.get_ticks_msec() + NetSim.send(func() -> void: _ping.rpc_id(1, send_ms), 1) # Estimate of what the server's Time.get_ticks_msec() reads right now. @@ -165,7 +168,13 @@ func shutdown() -> void: func _ping(client_send_ms: int) -> void: if not multiplayer.is_server(): return - _pong.rpc_id(multiplayer.get_remote_sender_id(), client_send_ms, Time.get_ticks_msec()) + # Same rule as the client's send above: read the server's clock now, at + # true receipt time, before NetSim can delay the reply — otherwise the + # server's own outbound leg would be silently absorbed out of both the + # RTT sample and the offset estimate instead of adding to them. + var server_now := Time.get_ticks_msec() + var sender_id := multiplayer.get_remote_sender_id() + NetSim.send(func() -> void: _pong.rpc_id(sender_id, client_send_ms, server_now), sender_id) @rpc("authority", "call_remote", "reliable") diff --git a/Game/tests/net_sim_smoke.gd b/Game/tests/net_sim_smoke.gd new file mode 100644 index 00000000..247edff4 --- /dev/null +++ b/Game/tests/net_sim_smoke.gd @@ -0,0 +1,111 @@ +extends Node + +# Manual two-process smoke test for NetSim (task 2.8 acceptance: +# "--net-sim-latency 80 measurably raises observed RTT"). Deliberately not +# part of the pure-function suite — needs two real processes and real wall +# time to observe a delayed pong. +# +# NetSim reads its own --net-sim-* flags directly from OS.get_cmdline_user_args() +# (see net_sim.gd) — this driver only needs --role= and passes any +# --net-sim-* flags straight through untouched. The host is where the +# _pong reply gets delayed, so --net-sim-latency=/--net-sim-loss= belong on +# the HOST invocation; the client just observes NetworkManager.rtt_ms. +# +# Usage: +# godot --headless --path Game res://tests/net_sim_smoke.tscn -- --role=host --net-sim-latency=80 +# godot --headless --path Game res://tests/net_sim_smoke.tscn -- --role=client --min-rtt=70 +# +# For the loss scenario: +# godot --headless --path Game res://tests/net_sim_smoke.tscn -- --role=host --net-sim-loss=1.0 +# godot --headless --path Game res://tests/net_sim_smoke.tscn -- --role=client-loss + +const DEFAULT_PORT := 7810 +const TIMEOUT_SECONDS := 12.0 +const HOST_LIFETIME_SECONDS := 8.0 +# Loose ceiling, not a tight bound: real localhost jitter plus one full +# PING_INTERVAL_SEC of scheduling slack is possible before the first sample +# lands, so this only needs to catch a badly broken (e.g. no-op) NetSim. +const MAX_RTT_SLACK_MS := 400.0 + +var _role := "" +var _port := DEFAULT_PORT +var _min_rtt_ms := 0.0 +var _finished := false + + +func _ready() -> void: + for arg: String in OS.get_cmdline_user_args(): + if arg.begins_with("--role="): + _role = arg.substr("--role=".length()) + elif arg.begins_with("--port="): + _port = int(arg.substr("--port=".length())) + elif arg.begins_with("--min-rtt="): + _min_rtt_ms = arg.substr("--min-rtt=".length()).to_float() + + if _role == "host": + var err := NetworkManager.host(_port) + if err != OK: + _finish(false, "host() failed: %s" % error_string(err)) + return + print("SMOKE: hosting on port %d (net-sim latency=%.1fms jitter=%.1fms loss=%.2f)" % [ + _port, NetSim.latency_ms, NetSim.jitter_ms, NetSim.loss_fraction + ]) + get_tree().create_timer(HOST_LIFETIME_SECONDS).timeout.connect(func() -> void: + _finish(true, "host ran for %.1fs" % HOST_LIFETIME_SECONDS)) + elif _role == "client": + NetworkManager.clock_updated.connect(_on_clock_updated) + var err := NetworkManager.join("127.0.0.1", _port) + if err != OK: + _finish(false, "join() failed: %s" % error_string(err)) + return + print("SMOKE: joining 127.0.0.1:%d, expecting rtt >= %.1fms ..." % [_port, _min_rtt_ms]) + elif _role == "client-loss": + NetworkManager.clock_updated.connect(_on_unexpected_clock_updated) + var err := NetworkManager.join("127.0.0.1", _port) + if err != OK: + _finish(false, "join() failed: %s" % error_string(err)) + return + print("SMOKE: joining 127.0.0.1:%d, expecting NO rtt sample (100%% loss) ..." % _port) + get_tree().create_timer(HOST_LIFETIME_SECONDS - 1.0).timeout.connect(func() -> void: + _finish(NetworkManager.rtt_ms < 0.0, "rtt_ms=%.1f after %.1fs (expected -1, no pong ever arrived)" % [ + NetworkManager.rtt_ms, HOST_LIFETIME_SECONDS - 1.0 + ])) + else: + _finish(false, "missing or unrecognised --role= (expected host|client|client-loss)") + return + + get_tree().create_timer(TIMEOUT_SECONDS).timeout.connect(_on_timeout) + + +func _process(_delta: float) -> void: + NetworkManager.poll() + + +func _physics_process(_delta: float) -> void: + NetworkManager.poll() + + +func _on_clock_updated(rtt_ms: float, _offset_ms: float) -> void: + var ceiling := _min_rtt_ms * 4.0 + MAX_RTT_SLACK_MS + var ok := rtt_ms >= _min_rtt_ms and rtt_ms <= ceiling + print("SMOKE INFO: observed rtt_ms=%.2f (want >= %.1f, <= %.1f)" % [rtt_ms, _min_rtt_ms, ceiling]) + _finish(ok, "client observed rtt_ms=%.2f against min=%.1f" % [rtt_ms, _min_rtt_ms]) + + +func _on_unexpected_clock_updated(rtt_ms: float, _offset_ms: float) -> void: + _finish(false, "client received a pong (rtt_ms=%.2f) despite --net-sim-loss=1.0 on the host" % rtt_ms) + + +func _on_timeout() -> void: + if not _finished: + _finish(false, "timed out waiting for a clock sample") + + +func _finish(success: bool, message: String) -> void: + if _finished: + return + _finished = true + print("SMOKE %s: %s" % ["PASS" if success else "FAIL", message]) + await get_tree().create_timer(0.3).timeout + NetworkManager.shutdown() + get_tree().quit(0 if success else 1) diff --git a/Game/tests/net_sim_smoke.tscn b/Game/tests/net_sim_smoke.tscn new file mode 100644 index 00000000..760bab41 --- /dev/null +++ b/Game/tests/net_sim_smoke.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/net_sim_smoke.gd" id="1_nss"] + +[node name="NetSimSmoke" type="Node"] +script = ExtResource("1_nss") diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 4eed4200..35d8f9b9 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: 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. +**Status: Phase 0 done, Phase 1 done, Phase 2 done — milestone gate passing.** 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: 26–31 m over a 2 s held-thrust drive, purely from interpolated snapshots) with camera and HUD attached — and this still holds under `--net-sim-latency 80 --net-sim-jitter 20` (task 2.8's `net_sim.gd`), which is Phase 2's own stated gate, not just LAN. No own-ship/ball prediction yet (Phase 4) — everything the client renders, including its own ship, comes from the interpolation buffer. See §7 for per-task status and evidence. --- @@ -847,7 +847,7 @@ No own-ship prediction yet: the client renders everything, including its own shi | 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 | +| 2.8 `[D:1.1]` `[P]` | **DONE.** New `NetSim` autoload (`scripts/net_sim.gd`): seeded (`--net-sim-seed=`, fixed default so a bad run reproduces), CLI-driven (`--net-sim-latency=`/`--net-sim-jitter=`/`--net-sim-loss=`/`--net-sim-dup=`), a pure passthrough (`send()` calls the dispatch immediately) unless at least one flag is non-zero — confirmed byte-for-byte inert against every Phase 1/2 regression test with no flags set. Wraps `MatchSim.send_input`/`send_snapshot` per this row's original scope, **plus `NetworkManager`'s `_ping`/`_pong` dispatch** — a deliberate scope addition, since that's the only RTT measurement that already exists and is already tested (task 1.8), so it's what makes this task's own acceptance criterion checkable today without waiting on Phase 3's per-peer snapshot echo. "Asymmetric-capable" needs no special-case code: each process reads only its own CLI args and delays only its own outgoing sends, so hosting and joining with different flags is already asymmetric. **One correctness subtlety, caught before it shipped**: callers that embed a timestamp in a wrapped RPC (`_ping`/`_pong`) must capture `Time.get_ticks_msec()` *before* calling `NetSim.send()`, not inside the wrapped `Callable` — capturing it inside would silently absorb that process's own added delay out of the round-trip measurement instead of adding to it, since the timestamp would then reflect "after my delay" rather than "when I actually tried to send". **A second real bug, found by actually running Phase 2's own milestone gate** (a full `networked_match_smoke` run under `--net-sim-latency=80 --net-sim-jitter=20`, not just the isolated ping/pong test above): a delayed send can outlive the window its target was valid in — the host hit "Attempt to call RPC with unknown peer ID" (the client had disconnected during the ~80-100ms hold, after `_broadcast_snapshot`'s existing `get_peers()` filter had already passed at *schedule* time) and the client hit "'_recv_input' on yourself is not allowed by selected mode" (its own `shutdown()` had already reset `multiplayer_peer` to a fresh `OfflineMultiplayerPeer` before a still-pending delayed send fired, so peer id 1 now meant itself). Fixed by having `send()` accept an optional `target_peer_id` and re-validating it — plus that this process still has a real (non-Offline) peer at all — at *fire* time inside a new `_fire()`, not just at schedule time; the synchronous/inactive path is deliberately left unvalidated so NetSim stays a true no-op when idle | Verified with a real two-process test (`tests/net_sim_smoke.gd`/`.tscn`): baseline (no flags) observed `rtt_ms=7.00` on loopback; `--net-sim-latency=80` on the host alone raised the client's observed `rtt_ms` to `83.00` (want ≥70, confirmed measurably higher than baseline); `--net-sim-loss=1.0` on the host produced **zero** pong samples over 7s (`rtt_ms` stayed `-1`, confirmed the drop path actually drops rather than relabels). **Phase 2's own milestone gate re-run and passing**: `networked_match_smoke` under `--net-sim-latency=80 --net-sim-jitter=20` on both peers — client still observed 26.08m of clean server-authoritative movement via interpolation, `thrust_z=1.00` confirmed mid-drive, zero RPC errors (the fire-time-revalidation fix above). Re-ran the full Phase 1 + Phase 2 regression suite (`test_runner`, `net_smoke`, `match_net_smoke`, `clock_smoke`, `lobby_smoke`, `server_boot`, `networked_match_smoke`) with NetSim present but inactive — all still pass with unchanged behaviour (clock offset converged to the same value, `networked_match_smoke` still showed clean server-authoritative movement) | > **`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. @@ -1009,6 +1009,7 @@ No own-ship prediction yet: the client renders everything, including its own shi 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. +34. **Anything that deliberately delays an RPC dispatch (task 2.8's `net_sim.gd`) must re-validate its target at *fire* time, not just at the moment it was scheduled.** Found by actually running Phase 2's own gate (`networked_match_smoke` under `--net-sim-latency=80 --net-sim-jitter=20`), not the isolated ping/pong test alone: `_broadcast_snapshot`'s existing `get_peers()` filter (gotcha from task 2.2's own fix) only proves the target was valid *when the send was queued* — a target that legitimately disconnects during the ~80–100ms hold produces "Attempt to call RPC with unknown peer ID" anyway, and if the delay outlives this *process's own* `shutdown()`, `multiplayer_peer` has already been reset to a fresh `OfflineMultiplayerPeer` (§9 gotcha re: never resetting to raw `null`), so a stale `rpc_id(1, …)` now means "call yourself" and Godot rejects it. Any deliberate-delay layer needs to re-check both "do I still have a real peer at all" and "is this specific target still in `get_peers()`" inside the delayed callback itself, immediately before dispatching — not reuse whatever validation the caller did before the delay was added. ---