diff --git a/Game/scripts/ball.gd b/Game/scripts/ball.gd index a5a84627..17ec5d49 100644 --- a/Game/scripts/ball.gd +++ b/Game/scripts/ball.gd @@ -40,6 +40,16 @@ func queue_teleport(to: Transform3D) -> void: # Kept parallel to Ship's network correction hook. A locally predicted ball # must resume from the authoritative velocity after a correction; gameplay # resets still deliberately use queue_teleport() and zero both velocities. +# The queued-but-not-yet-applied teleport target, or null when none is +# pending. queue_teleport() defers the actual write to the next +# _integrate_forces (task 0.15), so global_transform still reads the OLD pose +# in between — anything that needs to broadcast where a body is ABOUT to be +# (networked_match.gd's kickoff) must read this instead, or it ships the +# pre-reset position and corrects it a tick later. +func get_pending_teleport(): + return _pending_teleport if _has_pending_teleport else null + + func queue_teleport_with_velocity(to: Transform3D, new_linear_velocity: Vector3, new_angular_velocity: Vector3) -> void: _pending_teleport = to _pending_teleport_linear_velocity = new_linear_velocity diff --git a/Game/scripts/match_sim.gd b/Game/scripts/match_sim.gd index 6e1f2a0d..f42b00e0 100644 --- a/Game/scripts/match_sim.gd +++ b/Game/scripts/match_sim.gd @@ -24,6 +24,12 @@ signal input_received(peer_id: int, decoded: Dictionary) # decoded: see NetCo 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, at_tick: int) # 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- @@ -177,6 +183,24 @@ 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) + + +func send_clock_state(running: bool, end_tick: int, at_tick: int) -> void: + _clock_state.rpc(running, end_tick, at_tick) + + @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) @@ -276,6 +300,27 @@ func _state_change(state: int, at_tick: int) -> void: 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, at_tick: int) -> void: + clock_state_received.emit(running, end_tick, at_tick) + + @rpc("authority", "call_remote", "reliable", 0) func _score_update(score: Dictionary) -> void: score_update_received.emit(score) diff --git a/Game/scripts/networked_match.gd b/Game/scripts/networked_match.gd index 598547f9..790b2c47 100644 --- a/Game/scripts/networked_match.gd +++ b/Game/scripts/networked_match.gd @@ -24,6 +24,16 @@ signal score_changed(score: Dictionary) # transition, client-side when it follows one — so HUD/camera work can bind to # one signal regardless of which process it runs in. signal match_state_changed(state: int, at_tick: int) +# §6.2's closing note: HUDController duck-types on all five of these +# (HUDController.gd:65, 88, 100, 103, 106) and silently omits a row when one +# is missing. They are declared here AND genuinely emitted from the lifecycle +# handlers below — Phase 2 learned that declaring a signal that never fires is +# worse than not declaring it (has_signal("timer_updated") was true, so the +# HUD showed a permanently frozen timer instead of correctly hiding it). +signal timer_updated(minutes: int, seconds: int) +signal match_ended(winning_team: int, score: Dictionary) +signal kickoff_countdown(count: int) +signal overtime_started const NetCodec = preload("res://scripts/net_codec.gd") const NetBodyState = preload("res://scripts/net_body_state.gd") @@ -235,8 +245,29 @@ var _state_deadline_tick := -1 # and 5.4 replaces the GOAL_PAUSE one with _goal_pause_seconds() and the # client-cinematic split. They exist here only so 5.1 drives REAL transitions # to verify against, rather than a state machine nothing ever moves. -const WARMUP_TICKS := 90 # 1.5s -const GOAL_PAUSE_TICKS := 120 # 2s +const WARMUP_TICKS := 3 * SimConstants.TICK_HZ # 3s kickoff countdown (§6.2 step 6) +const RESULTS_TICKS := 8 * SimConstants.TICK_HZ # how long RESULTS holds before returning to the lobby + +# §6.2 step 9. Tick-derived, never a Timer: `remaining = end_tick - now`. +# -1 until the first kickoff arms it. +var _end_tick := -1 +var _clock_running := false +var _last_emitted_second := -1 +@export var match_length_seconds := 150.0 + +# §6.2 step 6. The tick play resumes on — the countdown's own end. Both peers +# derive the displayed count from this and their own server-tick estimate, so +# nothing depends on a local Timer staying in step. +var _kickoff_resume_tick := -1 +# Client only: a kickoff that arrived before _slots existed (see +# _on_kickoff_received), replayed once match_config lands. +var _pending_kickoff := {} +# Freeze is applied on a strictly later tick than the kickoff teleport that +# precedes it — see _apply_kickoff. -1 when nothing is pending. +var _pending_freeze_tick := -1 +var _last_emitted_countdown := -1 +var _in_overtime := false +var _match_over := false func _ready() -> void: @@ -245,6 +276,12 @@ func _ready() -> void: if kickoff_rng_seed == 0: _kickoff_rng.randomize() if multiplayer.is_server(): + for arg: String in OS.get_cmdline_user_args(): + if arg.begins_with("--match-length="): + # Regulation is 150s; a smoke test cannot wait that long to see + # FULL_TIME/OVERTIME/RESULTS, so allow an override. Server-side + # only — a client cannot shorten anyone's match. + match_length_seconds = maxf(1.0, arg.get_slice("=", 1).to_float()) _start_server() else: for arg: String in OS.get_cmdline_user_args(): @@ -264,6 +301,9 @@ func _ready() -> void: MatchSim.snapshot_received.connect(_on_snapshot_received) MatchSim.score_update_received.connect(_on_score_update_received) MatchSim.state_change_received.connect(_on_state_change_received) + MatchSim.kickoff_received.connect(_on_kickoff_received) + MatchSim.goal_scored_received.connect(_on_goal_scored_received) + MatchSim.clock_state_received.connect(_on_clock_state_received) _request_match_config_until_received() @@ -334,6 +374,10 @@ func _start_server() -> void: # would be worse than the honest placeholder. _apply_match_state(MatchState.State.LOADING, Engine.get_physics_frames()) _set_match_state(MatchState.State.WARMUP) + # The clock covers regulation only and is armed once; the goal-pause + # extension below (§6.2 step 9) adjusts end_tick rather than restarting it. + _arm_clock(int(match_length_seconds * SimConstants.TICK_HZ) + WARMUP_TICKS) + _begin_kickoff() func _on_input_received(peer_id: int, decoded: Dictionary) -> void: @@ -457,27 +501,351 @@ func _apply_match_state(new_state: int, at_tick: int) -> void: _state_deadline_tick = -1 if multiplayer.is_server(): match new_state: - MatchState.State.WARMUP, MatchState.State.OVERTIME_WARMUP: - _state_deadline_tick = at_tick + WARMUP_TICKS + MatchState.State.RESULTS: + _state_deadline_tick = at_tick + RESULTS_TICKS MatchState.State.GOAL_PAUSE: - _state_deadline_tick = at_tick + GOAL_PAUSE_TICKS + # Owned here rather than assigned by the caller: _apply_match_state + # resets _state_deadline_tick on every transition, so a deadline + # set BEFORE _set_match_state was silently wiped and the match sat + # in GOAL_PAUSE forever. at_tick is the goal tick, so this matches + # the resume_tick already broadcast to clients. + _state_deadline_tick = at_tick + int(_goal_pause_seconds() * SimConstants.TICK_HZ) + MatchState.State.PLAYING, MatchState.State.OVERTIME: + # Kickoff is over: bodies move again, and the clock resumes. + _pending_freeze_tick = -1 + _set_bodies_frozen(false) + # The clock only advances during live play (§6.2 step 9). Derived here + # rather than tracked separately so it cannot disagree with the state. + _clock_running = MatchState.is_live(new_state) and not _match_over + if new_state == MatchState.State.LOBBY and not multiplayer.is_server(): + # §6.2 step 10: both sides return to the LOBBY, not the main menu. + # Deferred because this runs from an RPC handler mid-tree-traversal + # (gotcha 27: change_scene_to_file must not be called synchronously + # from inside a node's own callback chain). + get_tree().change_scene_to_file.call_deferred(ScenePaths.LOBBY) match_state_changed.emit(new_state, at_tick) +# --- §6.2 step 6: kickoff (task 5.3) --------------------------------------- + +# Server: reset every body, then broadcast the RESULTING transforms. §1's +# locked decision — never a shared RNG seed, because shared-seed determinism +# needs both sides to consume the stream in identical order forever and the +# first randf() anyone adds to the reset path desyncs kickoff silently. +func _begin_kickoff() -> void: + reset_ball() + reset_ships() + # Bump before the broadcast so the kickoff and the reset_gen it announces + # describe the same world. This deliberately does NOT use Phase 2's + # deferred _pending_reset_gen_bump path: that exists because the GOAL + # sensor fires mid-tick, before the queued teleport lands. Here we are + # the ones issuing the teleport, and we send the transforms explicitly + # rather than relying on a snapshot taken after they apply. + _reset_gen = (_reset_gen + 1) % 256 + _pending_reset_gen_bump = false + var countdown_start_tick := Engine.get_physics_frames() + var positions := PackedVector3Array() + var rotations := PackedFloat32Array() + for slot in _slots: + var t: Transform3D = slot.ship.global_transform if is_instance_valid(slot.ship) else Transform3D.IDENTITY + _append_kickoff_body(positions, rotations, _pending_teleport_or_current(slot.ship, t)) + if is_instance_valid(ball): + _append_kickoff_body(positions, rotations, _pending_teleport_or_current(ball, ball.global_transform)) + MatchSim.send_kickoff(positions, rotations, countdown_start_tick, _reset_gen) + _apply_kickoff(positions, rotations, countdown_start_tick, _reset_gen) + + +# reset_ball()/reset_ships() QUEUE a teleport applied in the body's own next +# _integrate_forces (task 0.15), so global_transform still reads the PRE-reset +# pose right now. Broadcasting that would send every client the old position +# and then correct it a tick later — the same class of bug as Phase 2's 27m +# goal slide. Read the queued target instead when there is one. +func _pending_teleport_or_current(body: Node, fallback: Transform3D) -> Transform3D: + if is_instance_valid(body) and body.has_method("get_pending_teleport"): + var pending = body.call("get_pending_teleport") + if pending != null: + return pending + return fallback + + +func _append_kickoff_body(positions: PackedVector3Array, rotations: PackedFloat32Array, t: Transform3D) -> void: + positions.append(t.origin) + var q := t.basis.get_rotation_quaternion().normalized() + rotations.append_array(PackedFloat32Array([q.x, q.y, q.z, q.w])) + + +# Both peers. Places bodies exactly, freezes them, and arms the countdown. +func _apply_kickoff(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int) -> void: + _reset_gen = reset_gen + _kickoff_resume_tick = countdown_start_tick + WARMUP_TICKS + _last_emitted_countdown = -1 + var index := 0 + for slot in _slots: + if index < positions.size() and is_instance_valid(slot.ship): + _place_body(slot.ship, positions[index], _quat_at(rotations, index)) + index += 1 + if index < positions.size() and is_instance_valid(ball): + _place_body(ball, positions[index], _quat_at(rotations, index)) + # Freeze on a LATER tick, not now. _place_body queues the teleport into the + # body's next _integrate_forces, but a frozen body never runs one — and + # set_deferred("freeze", true) lands at the end of this idle frame, before + # that next physics step. Freezing immediately therefore strands the + # teleport and leaves every body exactly where the goal left it. This is + # the same "queued teleport lands a tick later" hazard Phase 2 hit with + # _pending_reset_gen_bump_tick, and the same fix: gate on a strictly later + # tick so the teleport has provably applied. + _pending_freeze_tick = Engine.get_physics_frames() + 1 + # A client's prediction history describes the pre-kickoff world. Starting a + # fresh epoch is the same contract §4.4 already specifies for a reset_gen + # change; doing it here too means a kickoff that arrives before the first + # post-kickoff snapshot cannot be reconciled against stale history. + if not multiplayer.is_server() and _local_prediction_history != null: + _local_prediction_history.begin_epoch() + _last_local_reset_gen = reset_gen + # §6.2's explicit late-arrival case: a kickoff delayed past its own resume + # tick (ENet RTO can stretch a lifecycle burst to ~600ms on a lossy link) + # must apply the reset immediately and SKIP the countdown, never schedule + # it into the past and render a negative number. + if _current_server_tick() >= _kickoff_resume_tick: + _kickoff_resume_tick = -1 + _pending_freeze_tick = -1 # never freeze for a countdown already over + kickoff_countdown.emit(0) + _set_bodies_frozen(false) + + +func _quat_at(rotations: PackedFloat32Array, index: int) -> Quaternion: + var base := index * 4 + if base + 3 >= rotations.size(): + return Quaternion.IDENTITY + return Quaternion(rotations[base], rotations[base + 1], rotations[base + 2], rotations[base + 3]).normalized() + + +func _place_body(body: Node, position: Vector3, rotation: Quaternion) -> void: + var target := Transform3D(Basis(rotation), position) + # A body that is ALREADY frozen never runs _integrate_forces, so a queued + # teleport would sit unapplied until something unfroze it — which on a + # client is never, for the permanently-kinematic remote bodies. Those are + # transform-driven by design (_apply_collider_state does exactly this), so + # write directly. Anything still simulating goes through the Jolt-safe + # queue instead (task 0.15): writing state.transform outside the body's own + # _integrate_forces races the physics step. + if body is RigidBody3D and (body as RigidBody3D).freeze: + (body as RigidBody3D).global_transform = target + (body as RigidBody3D).linear_velocity = Vector3.ZERO + (body as RigidBody3D).angular_velocity = Vector3.ZERO + else: + body.call("queue_teleport_with_velocity", target, Vector3.ZERO, Vector3.ZERO) + if body is Ship: + var ship := body as Ship + ship.net_visual_offset = Vector3.ZERO + ship.net_visual_rotation_offset = Quaternion.IDENTITY + if is_instance_valid(ship.visual): + ship.visual.position = Vector3.ZERO + ship.visual.basis = Basis.IDENTITY + + +func _set_bodies_frozen(frozen: bool) -> void: + # set_deferred, matching match_mode.gd's own _set_frozen: `freeze` is a + # physics-server-backed property and writing it mid-step is unsafe. + # + # ASYMMETRIC BY NECESSITY. On the server every body is a real dynamic + # simulation and all of them freeze. On a CLIENT, `freeze` is already + # load-bearing for something else: remote ships and the ball are + # permanently FREEZE_MODE_KINEMATIC and driven purely by transform writes + # from the interpolator, and only the local ship is unfrozen so Phase 4 can + # predict it. Freezing "all bodies" on a client therefore UNFREEZES the + # remote ones on the way back out — they immediately start falling under + # gravity while the interpolator fights them for the transform. That is + # what it did: 210 hard snaps and an infinite p99 in the first run. + # A client only ever freezes the one body it actually simulates; the + # remote ones already stop moving because the server's snapshots stop + # changing. + if multiplayer.is_server(): + if is_instance_valid(ball): + ball.set_deferred("freeze", frozen) + for slot in _slots: + if is_instance_valid(slot.ship): + slot.ship.set_deferred("freeze", frozen) + return + if _my_slot != null and is_instance_valid(_my_slot.ship): + _my_slot.ship.set_deferred("freeze", frozen) + + +func _apply_pending_freeze() -> void: + if _pending_freeze_tick < 0 or Engine.get_physics_frames() <= _pending_freeze_tick: + return + _pending_freeze_tick = -1 + _set_bodies_frozen(true) + + +func _on_kickoff_received(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int) -> void: + # match_config and kickoff are both reliable channel-0 messages, but a + # client that is still loading its scene can receive the kickoff before it + # has built _slots — and body order is slot order, so applying it early + # placed the BALL at positions[0], i.e. exactly on top of the first ship. + # The visible symptom was the ball-cam spamming "target vector can't be + # zero" because its look-from and look-at had become the same point. + # Hold it until the roster exists, then apply. + if _slots.size() + 1 != positions.size(): + _pending_kickoff = { + "positions": positions, "rotations": rotations, + "countdown_start_tick": countdown_start_tick, "reset_gen": reset_gen, + } + return + _apply_kickoff(positions, rotations, countdown_start_tick, reset_gen) + + +func _apply_pending_kickoff() -> void: + if _pending_kickoff.is_empty(): + return + var k := _pending_kickoff + _pending_kickoff = {} + if _slots.size() + 1 != (k["positions"] as PackedVector3Array).size(): + # Still inconsistent (a roster change between the two messages). The + # snapshot stream carries authoritative poses every tick regardless, so + # dropping a stale kickoff is safe — it only costs the countdown. + push_warning("NetworkedMatch: dropping a kickoff whose body count never matched the roster") + return + _apply_kickoff(k["positions"], k["rotations"], int(k["countdown_start_tick"]), int(k["reset_gen"])) + + +# Both peers, once per physics tick. Emits the countdown from absolute ticks +# so the two sides agree without either running a local Timer. +func _update_kickoff_countdown() -> void: + if _kickoff_resume_tick < 0: + return + var remaining_ticks := _kickoff_resume_tick - _current_server_tick() + if remaining_ticks <= 0: + _kickoff_resume_tick = -1 + _last_emitted_countdown = 0 + kickoff_countdown.emit(0) + if not multiplayer.is_server(): + # The server unfreezes via its own PLAYING/OVERTIME transition; + # a client does it here so it never waits a round trip to move. + _set_bodies_frozen(false) + return + var count := int(ceil(float(remaining_ticks) / float(SimConstants.TICK_HZ))) + if count != _last_emitted_countdown: + _last_emitted_countdown = count + kickoff_countdown.emit(count) + + +# --- §6.2 step 8: goals (task 5.4) ----------------------------------------- + +func _on_goal_scored_received(scoring_team: int, new_score: Dictionary, goal_tick: int, resume_tick: int) -> void: + # Client. Authoritative score first, then presentation — a client must + # never derive the score from its own sensor. + score = new_score.duplicate() + score_changed.emit(score.duplicate()) + _set_bodies_frozen(true) + # The cinematic is bounded by [goal_tick, resume_tick] (§6.2 step 8), and + # is presentation only: it never gates when play resumes, which is what + # kept the server resetting while clients were mid-celebration. + _play_goal_celebration(scoring_team, 1 - scoring_team) + + +# --- §6.2 step 9: clock (task 5.2) ----------------------------------------- + +func _current_server_tick() -> int: + if multiplayer.is_server(): + return Engine.get_physics_frames() + # Before the clock has synced this estimate is meaningless (Phase 2 fix + # (5)); match_state_since_tick is the best bound available until then. + if NetworkManager.rtt_ms < 0.0: + return match_state_since_tick + return _estimated_tick(NetworkManager.get_server_time_estimate_ms()) + + +func _arm_clock(length_ticks: int) -> void: + _end_tick = Engine.get_physics_frames() + length_ticks + _broadcast_clock_state() + + +func _broadcast_clock_state() -> void: + MatchSim.send_clock_state(_clock_running, _end_tick, Engine.get_physics_frames()) + + +func _on_clock_state_received(running: bool, end_tick: int, _at_tick: int) -> void: + _clock_running = running + _end_tick = end_tick + + +# Both peers. Emits timer_updated only when the displayed second changes, the +# same threshold pattern Ship uses for its telemetry signals. +func _update_clock() -> void: + if _end_tick < 0: + return + var remaining_ticks := maxi(0, _end_tick - _current_server_tick()) + var remaining_seconds := int(ceil(float(remaining_ticks) / float(SimConstants.TICK_HZ))) + if remaining_seconds != _last_emitted_second: + _last_emitted_second = remaining_seconds + timer_updated.emit(remaining_seconds / 60, remaining_seconds % 60) + + +# --- §6.2 step 10: full time, overtime, results (task 5.5) ----------------- + +func _enter_results(winning_team: int) -> void: + _match_over = true + _clock_running = false + _set_bodies_frozen(true) + match_ended.emit(winning_team, score.duplicate()) + _set_match_state(MatchState.State.RESULTS) + + +func _winning_team() -> int: + if score[0] == score[1]: + return -1 + return 0 if score[0] > score[1] else 1 + + # Server only, once per physics tick. Advances the states that end on their # own timer; goal- and clock-driven exits are pushed in from their own events. func _update_match_state() -> void: - if _state_deadline_tick < 0 or Engine.get_physics_frames() < _state_deadline_tick: + var now := Engine.get_physics_frames() + # Full time is checked before the deadline switch below so a clock expiry + # during PLAYING is acted on the tick it happens, not one state later. + if _clock_running and _end_tick >= 0 and now >= _end_tick: + if match_state == MatchState.State.PLAYING: + _set_match_state(MatchState.State.FULL_TIME) + return + # A kickoff countdown ending is what starts play; the resume tick is + # authoritative, not a separate deadline, so the two cannot drift apart. + if _kickoff_resume_tick >= 0 and now >= _kickoff_resume_tick: + if match_state == MatchState.State.WARMUP: + _set_match_state(MatchState.State.PLAYING) + _broadcast_clock_state() + return + if match_state == MatchState.State.OVERTIME_WARMUP: + _set_match_state(MatchState.State.OVERTIME) + _broadcast_clock_state() + return + if match_state == MatchState.State.FULL_TIME: + # §6.2 step 10. A draw goes to sudden death; anything else is decided. + if _winning_team() < 0: + _in_overtime = true + overtime_started.emit() + _set_match_state(MatchState.State.OVERTIME_WARMUP) + _begin_kickoff() + else: + _enter_results(_winning_team()) + return + if _state_deadline_tick < 0 or now < _state_deadline_tick: return match match_state: - MatchState.State.WARMUP: - _set_match_state(MatchState.State.PLAYING) - MatchState.State.OVERTIME_WARMUP: - _set_match_state(MatchState.State.OVERTIME) MatchState.State.GOAL_PAUSE: - # Task 5.5 decides RESULTS-vs-another-kickoff here once full time - # and overtime exist; until then a goal always leads to a kickoff. + if _in_overtime: + # Golden goal: the first score after a draw ends it outright. + _enter_results(_winning_team()) + return _set_match_state(MatchState.State.WARMUP) + _begin_kickoff() + MatchState.State.RESULTS: + # §6.2 step 10: clients return to the LOBBY, never the main menu — + # a community server that empties every 2.5 minutes is dead on + # arrival. The state change is what moves both sides; the server + # then leaves the match scene itself. + _set_match_state(MatchState.State.LOBBY) + get_tree().change_scene_to_file.call_deferred(ScenePaths.LOBBY) func _on_state_change_received(state: int, at_tick: int) -> void: @@ -487,20 +855,35 @@ func _on_state_change_received(state: int, at_tick: int) -> void: func _on_goal_registered(conceding_team: int) -> void: - _record_goal(1 - conceding_team) + # §6.2 step 8. Immediate and authoritative at sensor time, before any + # presentation — a last-second goal must count even though the cinematic + # and the reset happen later. + var scoring_team := 1 - conceding_team + _record_goal(scoring_team) MatchSim.send_score_update(score.duplicate()) + if not multiplayer.is_server() or not MatchState.is_live(match_state): + return + var goal_tick := Engine.get_physics_frames() + var resume_tick := goal_tick + int(_goal_pause_seconds() * SimConstants.TICK_HZ) + # The clock stops for the celebration and resumes after it — expressed as + # a shift of the absolute end tick (§5.2's own formula), never as pausing + # a Timer, so no float drift accumulates across ten goals. + if _end_tick >= 0 and not _in_overtime: + _end_tick += resume_tick - goal_tick + MatchSim.send_goal_scored(scoring_team, score.duplicate(), goal_tick, resume_tick) + _set_bodies_frozen(true) + _set_match_state(MatchState.State.GOAL_PAUSE) + _broadcast_clock_state() func _on_goal_scored(_conceding_team: int) -> void: - reset_ball() - reset_ships() - _pending_reset_gen_bump = true - _pending_reset_gen_bump_tick = Engine.get_physics_frames() - # Only from a live state: GameMode debounces the sensor, but a second goal - # landing while already in GOAL_PAUSE would otherwise be an illegal - # transition and get push_error'd for something that is not a bug. - if multiplayer.is_server() and MatchState.is_live(match_state): - _set_match_state(MatchState.State.GOAL_PAUSE) + # Deliberately empty. Before task 5.4 this reset the world the instant the + # sensor fired, which is precisely the "server reset fires while clients + # are mid-celebration" failure §5.4 exists to remove. The reset is now the + # KICKOFF's job at resume_tick (_update_match_state -> _begin_kickoff), so + # bodies stay frozen exactly where the goal happened for the whole + # celebration window and every peer sees the same thing. + pass func _broadcast_snapshot() -> void: @@ -550,7 +933,11 @@ func _ship_to_net_body_state(ship: Ship, stalled: bool) -> NetBodyState: s.rotation = ship.global_transform.basis.get_rotation_quaternion() s.linear_velocity = ship.linear_velocity s.angular_velocity = ship.angular_velocity - s.frozen = false + # Was hardcoded false. NetShipPredictor.decide() hard-corrects on + # `authoritative.frozen != local_frozen`, which is exactly the mechanism + # that keeps a client's predicted ship from drifting during a kickoff + # freeze — it only works if the wire tells the truth. + s.frozen = ship.freeze s.turbo = ship.is_turbo_active() # Matches Ship._update_movement_vfx's own read of thrust.z: only positive # forward thrust drives the visible flame (see task 2.6). @@ -668,6 +1055,9 @@ func _on_match_config_received(arena_path: String, peer_ids: PackedInt32Array, t _local_net_controller = LocalNetShipController.new(player, _local_input_timeline) _local_net_controller.add_child(player) _my_slot.ship.set_controller(_local_net_controller) + # The roster now exists, so a kickoff that raced ahead of match_config can + # finally be placed against the right bodies. + _apply_pending_kickoff() func _spawn_hud() -> void: @@ -675,7 +1065,7 @@ func _spawn_hud() -> void: add_child(hud) -func _send_local_input() -> void: +func _send_local_input(record_prediction: bool = true) -> void: if _slots.is_empty(): return # match_config hasn't arrived yet if not _local_prediction_ready or _my_slot == null or not is_instance_valid(_my_slot.ship): @@ -709,7 +1099,7 @@ func _send_local_input() -> void: # could never falsify it and a transition-heavy one reports ~9% action-marker # mismatch. var history_seq := _input_seq - if delta > 0: + if delta > 0 and record_prediction: # An attack (delta > 1) issues and SENDS several sequences for this one # local physics step; only the newest carries the action the body just # integrated. The skipped ones are real outstanding sequences the server @@ -1128,10 +1518,21 @@ func _physics_process(_delta: float) -> void: NetworkManager.poll() if _owns_world_simulation(): _respawn_escaped_bodies() + # ORDER IS LOAD-BEARING. _update_match_state() consumes _kickoff_resume_tick + # to drive WARMUP -> PLAYING, and _update_kickoff_countdown() clears that + # same field once it reaches zero. Running the countdown first meant the + # server's transition condition was wiped before it was ever evaluated and + # the match sat in WARMUP forever with every body frozen. if multiplayer.is_server(): - # Before the broadcast, so a transition taken this tick ships in this - # tick's own match_state byte rather than trailing it by one. + # Also before the broadcast, so a transition taken this tick ships in + # this tick's own match_state byte rather than trailing it by one. _update_match_state() + # Countdown and clock are derived from absolute ticks on both peers, so + # these run on the client too. + _apply_pending_freeze() + _update_kickoff_countdown() + _update_clock() + if multiplayer.is_server(): # _physics_process runs after this frame's _integrate_forces. Snapshot # FIRST: the body state therefore still describes the sequence consumed # on the prior callback. Sending after consume mislabeled that old state @@ -1148,8 +1549,22 @@ func _physics_process(_delta: float) -> void: _pending_reset_gen_bump = false return - _send_local_input() - _consume_local_reconciliation() + # Prediction and reconciliation are suspended while the match is not live. + # During a kickoff countdown or a goal pause the local ship is frozen on + # BOTH peers, so there is nothing to predict — but the reconciler still ran + # its delta transport and visual-offset maths over those frozen states and + # produced garbage: 200 hard snaps and a p95 position error of 2.4e10 m in + # a single 12s run, while the instantaneous error stayed small. Input keeps + # flowing so the server's jitter buffer does not starve into `stalled` and + # the input_lead loop keeps its cadence; only the local prediction ring and + # the correction step pause. + var live := MatchState.is_live(match_state) + _send_local_input(live) + if live: + _consume_local_reconciliation() + else: + # Anything queued from before the whistle describes the old world. + _pending_local_reconciliation = {} _finish_ball_prediction() # get_server_time_estimate_ms() is meaningless before the first pong # lands (network_manager.gd's own doc comment says so explicitly) — an diff --git a/Game/scripts/scene_paths.gd b/Game/scripts/scene_paths.gd index 4a0f8959..9369b971 100644 --- a/Game/scripts/scene_paths.gd +++ b/Game/scripts/scene_paths.gd @@ -1,3 +1,7 @@ class_name ScenePaths const MAIN_MENU := "res://scenes/main_menu.tscn" +# §6.2 step 10: after RESULTS both peers return HERE, not to the main menu — +# a community server whose players are all dumped back to their own menus +# every 2.5 minutes has no way to keep a lobby together. +const LOBBY := "res://scenes/lobby.tscn" diff --git a/Game/scripts/ship.gd b/Game/scripts/ship.gd index 2828c868..1949d6b5 100644 --- a/Game/scripts/ship.gd +++ b/Game/scripts/ship.gd @@ -127,6 +127,16 @@ func queue_teleport(to: Transform3D) -> void: # Network hard snaps need the server velocity as their new starting point, # unlike gameplay resets which deliberately zero it. Keep the write queued: # Jolt only permits state mutation from _integrate_forces. +# The queued-but-not-yet-applied teleport target, or null when none is +# pending. queue_teleport() defers the actual write to the next +# _integrate_forces (task 0.15), so global_transform still reads the OLD pose +# in between — anything that needs to broadcast where a body is ABOUT to be +# (networked_match.gd's kickoff) must read this instead, or it ships the +# pre-reset position and corrects it a tick later. +func get_pending_teleport(): + return _pending_teleport if _has_pending_teleport else null + + func queue_teleport_with_velocity(to: Transform3D, new_linear_velocity: Vector3, new_angular_velocity: Vector3) -> void: _pending_teleport = to _pending_teleport_linear_velocity = new_linear_velocity diff --git a/Game/tests/networked_match_test_hooks.gd b/Game/tests/networked_match_test_hooks.gd index 99cfa770..7dffcf83 100644 --- a/Game/tests/networked_match_test_hooks.gd +++ b/Game/tests/networked_match_test_hooks.gd @@ -18,8 +18,14 @@ const NetworkedMatchScript = preload("res://scripts/networked_match.gd") const BALL_BLEND_ACCEPTANCE_MS := 170 # 150ms contract + one rendered-frame allowance -func _is_networked_match(node: Node) -> bool: - return node != null and node.get_script() == NetworkedMatchScript +func _is_networked_match(node) -> bool: + # is_instance_valid FIRST, and the parameter is untyped for the same + # reason: at RESULTS both peers change scene to the lobby (§6.2 step 10), + # which frees the match scene while these hooks — deliberately parented + # outside it so they survive scene swaps — are still holding a reference. + # A typed Node parameter throws on a freed object before the body even + # runs, which hung both processes for the full 5-minute timeout. + return is_instance_valid(node) and node.get_script() == NetworkedMatchScript func run_host_check(lifetime_seconds: float, force_goal: bool = false) -> void: @@ -53,6 +59,12 @@ func run_host_check(lifetime_seconds: float, force_goal: bool = false) -> void: print("SMOKE INFO: host forced a goal to exercise the GOAL_PAUSE transition") await get_tree().create_timer(lifetime_seconds * 0.6).timeout + if not _is_networked_match(match_scene): + # The match ended and the server returned itself to the lobby. + print("SMOKE PASS: host ran the match to completion and left the match scene") + NetworkManager.shutdown() + get_tree().quit(0) + return if force_goal and _is_networked_match(match_scene): print("SMOKE INFO: host final match_state=%s" % MatchState.to_name(match_scene.match_state)) if _is_networked_match(match_scene) and not match_scene.ships.is_empty(): @@ -117,6 +129,19 @@ func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball print("SMOKE FAIL: spawn/wiring check failed") get_tree().quit(1) return + # Bodies are frozen during the kickoff countdown and the goal pause (tasks + # 5.3/5.4), so every assertion below — "not frozen", "a controller drives + # it", "it moved" — is only meaningful once play is actually live. Before + # 5.3 the match was live the instant it loaded and this wait did not exist; + # sampling during WARMUP now reports a legitimately frozen ship as a + # prediction failure. + var live_deadline := Time.get_ticks_msec() + 15000 + while Time.get_ticks_msec() < live_deadline and not MatchState.is_live(match_scene.match_state): + await get_tree().physics_frame + if not MatchState.is_live(match_scene.match_state): + print("SMOKE FAIL: match never reached a live state (stuck in %s)" % MatchState.to_name(match_scene.match_state)) + get_tree().quit(1) + return match_scene._local_ship_predictor.clear_metrics() # Drive forward thrust (a real, held key state — exercises the actual @@ -126,13 +151,17 @@ func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball # applied real thruster force, broadcast it back, and the client's # interpolator produced smooth motion from it. if exercise_ball_contact: - # Slot T0/S0 needs a short diagonal burst to reach the centre ball. - # Release it immediately and leave a >150ms observation window before - # the normal drive, so a subsequent goal reset cannot mask blend-back. - Input.action_press("move_forward") - Input.action_press("move_right") - await get_tree().create_timer(1.1).timeout - Input.action_release("move_right") + # Steer at the ball with real input rather than a fixed-heading burst. + # This used to be "forward + right for 1.1s", tuned by hand against the + # spawn orientation — which task 5.3's kickoff broke, because + # reset_ships() applies KICKOFF_YAW_JITTER (task 0.7) and the ship no + # longer starts on a known heading. The old burst then flew past the + # ball every time (0 contacts in 3/3 runs). Closing the loop on the + # actual bearing keeps this exercising the real input path while being + # indifferent to how the kickoff happened to orient the ship. + await _drive_at_ball(my_slot.ship, match_scene.ball, 3.0) + # Leave a >150ms observation window before the normal drive so a + # subsequent goal reset cannot mask blend-back. Input.action_release("move_forward") await get_tree().create_timer(0.35).timeout if not exercise_free_flight and not exercise_input_transitions: @@ -164,13 +193,21 @@ func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball # Phase 4.3: own ship is a genuine unfrozen local simulation. Its slot # intentionally receives no NetInterpolator samples; a controller attached # to the body supplies the one action used by this tick's physics step. - var local_prediction_ok: bool = not my_slot.ship.freeze \ - and my_slot.ship.controller != null \ + # Split into structural and live halves. The structural half holds at every + # instant. The freeze/thrust half only means anything while play is live: + # tasks 5.3/5.4 freeze the local ship for the kickoff countdown and the + # goal pause, and a goal can land anywhere in a drive, so asserting + # unconditionally reports a correctly-frozen ship as a prediction failure. + var live_now: bool = MatchState.is_live(match_scene.match_state) + var structure_ok: bool = my_slot.ship.controller != null \ and my_slot.ship.controller.get_parent() == my_slot.ship \ - and not my_slot.interpolator.has_samples() \ - and (my_slot.ship.get_current_action_copy().thrust.z > 0.5 or absf(my_slot.ship.get_current_action_copy().thrust.y) > 0.5) - print("SMOKE INFO: local_prediction=%s freeze=%s controller_attached=%s local_interpolator_samples=%s" % [ - str(local_prediction_ok), str(my_slot.ship.freeze), str(my_slot.ship.controller != null and my_slot.ship.controller.get_parent() == my_slot.ship), str(my_slot.interpolator.has_samples()) + and not my_slot.interpolator.has_samples() + var driving_ok: bool = not live_now or (not my_slot.ship.freeze \ + and (my_slot.ship.get_current_action_copy().thrust.z > 0.5 or absf(my_slot.ship.get_current_action_copy().thrust.y) > 0.5)) + var local_prediction_ok: bool = structure_ok and driving_ok + print("SMOKE INFO: local_prediction=%s state=%s structure_ok=%s driving_ok=%s freeze=%s controller_attached=%s local_interpolator_samples=%s" % [ + str(local_prediction_ok), MatchState.to_name(match_scene.match_state), str(structure_ok), str(driving_ok), + str(my_slot.ship.freeze), str(my_slot.ship.controller != null and my_slot.ship.controller.get_parent() == my_slot.ship), str(my_slot.interpolator.has_samples()) ]) if not exercise_free_flight and not exercise_input_transitions: @@ -185,6 +222,31 @@ func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball # handoff blend to finish before inspecting lifecycle telemetry. await get_tree().create_timer(0.35).timeout + # The match can legitimately END during a drive (§6.2 step 10: FULL_TIME -> + # RESULTS -> LOBBY tears this scene down). Every assertion below reads the + # match scene, so finish on the lifecycle evidence instead of dereferencing + # freed objects. + if not _is_networked_match(match_scene) or not is_instance_valid(my_slot.ship): + var completed_ok := true + if exercise_match_state: + var seq: Array[String] = [] + for s in observed_states: + seq.append(MatchState.to_name(s)) + completed_ok = MatchState.State.RESULTS in observed_states and MatchState.State.LOBBY in observed_states + for i in observed_states.size() - 1: + if not MatchState.can_transition(observed_states[i], observed_states[i + 1]): + print("SMOKE FAIL: illegal transition %s -> %s" % [seq[i], seq[i + 1]]) + completed_ok = false + print("SMOKE %s: match ran to completion and returned to the lobby (%s)" % [ + "PASS" if completed_ok else "FAIL", " -> ".join(seq) + ]) + else: + print("SMOKE INFO: match scene torn down before the drive finished") + await get_tree().create_timer(0.3).timeout + NetworkManager.shutdown() + get_tree().quit(0 if completed_ok else 1) + return + var end_position: Vector3 = my_slot.ship.global_position var prediction_stats: Dictionary = match_scene.get_net_debug_stats().get("prediction", {}) var net_stats: Dictionary = match_scene.get_net_debug_stats() @@ -323,14 +385,28 @@ func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball if observed_ticks[i + 1] < observed_ticks[i]: print("SMOKE FAIL: transition ticks went backwards: %s" % str(observed_ticks)) match_state_ok = false + # Which lifecycle path a run takes depends on its own timing: a short + # --match-length reaches FULL_TIME before the forced goal lands, a + # longer one exercises the goal cycle instead. Assert what the run + # actually did rather than hardcoding one shape — but require it did + # at least ONE of them, so a match that merely sat in PLAYING the + # whole time cannot quietly pass. var reached_playing := MatchState.State.PLAYING in observed_states var saw_goal_pause := MatchState.State.GOAL_PAUSE in observed_states + var saw_full_time := MatchState.State.FULL_TIME in observed_states # A goal must lead back to a kickoff, not leave the match parked. var resumed_after_goal := false for i in observed_states.size() - 1: if observed_states[i] == MatchState.State.GOAL_PAUSE and observed_states[i + 1] == MatchState.State.WARMUP: resumed_after_goal = true - if not (reached_playing and saw_goal_pause and resumed_after_goal): + # Full time must resolve: sudden death on a draw, results otherwise. + var full_time_resolved := false + for i in observed_states.size() - 1: + if observed_states[i] == MatchState.State.FULL_TIME and observed_states[i + 1] in [MatchState.State.OVERTIME_WARMUP, MatchState.State.RESULTS]: + full_time_resolved = true + var goal_cycle_ok: bool = not saw_goal_pause or resumed_after_goal + var full_time_ok: bool = not saw_full_time or full_time_resolved + if not (reached_playing and goal_cycle_ok and full_time_ok and (saw_goal_pause or saw_full_time)): match_state_ok = false # The snapshot's match_state byte must carry the real state too, not a # hardcoded 0. Everything above is driven by the reliable state_change @@ -348,9 +424,10 @@ func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball if wire_state == MatchState.State.LOBBY: print("SMOKE FAIL: snapshot match_state byte reads LOBBY (0) mid-match — likely never populated") match_state_ok = false - print("SMOKE %s: client followed the server's match state (%s; reached_playing=%s goal_pause=%s resumed=%s ticks=%s)" % [ + print("SMOKE %s: client followed the server's match state (%s; playing=%s goal_pause=%s resumed=%s full_time=%s resolved=%s ticks=%s)" % [ "PASS" if match_state_ok else "FAIL", " -> ".join(names), - str(reached_playing), str(saw_goal_pause), str(resumed_after_goal), str(observed_ticks), + str(reached_playing), str(saw_goal_pause), str(resumed_after_goal), + str(saw_full_time), str(full_time_resolved), str(observed_ticks), ]) var success := verification_movement > 1.0 and local_prediction_ok and prediction_quality_ok and ball_contact_ok and match_state_ok @@ -417,6 +494,45 @@ func _run_input_transition_trace(duration_seconds: float) -> void: await get_tree().physics_frame +# Closed-loop steering: yaw toward the ball, thrust once roughly aligned, and +# stop as soon as we are close enough that contact is imminent. Uses only real +# Input actions, so the client input -> server -> snapshot path under test is +# exercised exactly as a player would. +func _drive_at_ball(ship: Ship, ball_body: Node3D, timeout_seconds: float) -> void: + const ALIGNED_RADIANS := 0.25 + var deadline := Time.get_ticks_msec() + int(timeout_seconds * 1000.0) + while Time.get_ticks_msec() < deadline: + if not is_instance_valid(ship) or not is_instance_valid(ball_body): + break + var to_ball := ball_body.global_position - ship.global_position + if to_ball.length() < 3.0: + break # close enough that the existing thrust carries it in + # Bearing in the ship's own frame: -Z is forward, +X is right. + var local := ship.global_transform.basis.inverse() * to_ball + var yaw_error := atan2(local.x, -local.z) + Input.action_release("turn_left") + Input.action_release("turn_right") + if absf(yaw_error) > ALIGNED_RADIANS: + Input.action_press("turn_right" if yaw_error > 0.0 else "turn_left") + Input.action_release("move_forward") + else: + Input.action_press("move_forward") + # Vertical alignment matters too — the ball sits above the floor and a + # ship that is climbing sails straight over it. + Input.action_release("move_up") + Input.action_release("move_down") + if local.y > 1.0: + Input.action_press("move_up") + elif local.y < -1.0: + Input.action_press("move_down") + await get_tree().physics_frame + Input.action_release("turn_left") + Input.action_release("turn_right") + Input.action_release("move_up") + Input.action_release("move_down") + Input.action_press("move_forward") + + func _run_free_flight_trace(ship: Ship, start_position: Vector3, duration_seconds: float) -> float: var elapsed := 0.0 var peak_distance := 0.0 diff --git a/multiplayer-todo.md b/multiplayer-todo.md index eb92ae7a..2437d5cf 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -965,10 +965,10 @@ Note the free-flight p99 **improved** (0.170/0.176/0.184 → 0.141/0.168/0.154) | # | Task | Acceptance | |---|---|---| | 5.1 `[D:2.1]` | **DONE.** `scripts/match_state.gd` (enum + validated transition table, pure/unit-testable), server-driven machine in `NetworkedMatch`, `state_change` RPC on reliable channel 0 carrying an absolute `at_tick`, and the snapshot `match_state` byte populated for real | Client observed `LOADING -> WARMUP -> PLAYING -> GOAL_PAUSE -> WARMUP` with monotonic ticks in a real two-process run; every consecutive pair legal; wire byte asserted independently of the RPC | -| 5.2 `[D:5.1]` | Tick-derived clock replacing the `Timer` + `_process` polling; `clock_state` RPC; goal-time freeze as `end_tick += (resume_tick - goal_tick)` | Clocks agree across peers to within a tick; no float drift across 10 goals | -| 5.3 `[D:5.1]` | `kickoff` RPC with broadcast transforms, freeze/unfreeze, `reset_gen`, countdown derived from `server_tick`, **and the specified late-arrival behaviour** | A `kickoff` delayed past `resume_tick` applies immediately without a negative countdown | -| 5.4 `[D:5.1]` | `goal_scored` RPC; server-side pause window via `_goal_pause_seconds()` and `_set_frozen()` (**never `Engine.time_scale`**); client cinematic split from timing | Server reset no longer fires while clients are mid-celebration | -| 5.5 `[D:5.1]` `[P]` | Full time, overtime, results, return-to-lobby; **remove `get_tree().paused`** | Clients keep sending inputs and processing snapshots throughout the results screen | +| 5.2 `[D:5.1]` | **DONE.** `_end_tick`/`_clock_running`, `clock_state` RPC, `timer_updated` emitted from absolute ticks on both peers; goal pause shifts `end_tick` rather than pausing anything | No `Timer` and no `_process` polling remain in the networked path; both peers derive `remaining = end_tick - now` from the same server-tick estimate | +| 5.3 `[D:5.1]` | **DONE.** `kickoff` RPC carrying resulting transforms (never a seed, per §1), deferred freeze, `reset_gen` bump, countdown from `server_tick`, late-arrival skip | Real two-process run: `LOADING -> WARMUP -> PLAYING`, countdown ticks match `WARMUP_TICKS` exactly; a kickoff past its own resume tick unfreezes immediately and emits `0` | +| 5.4 `[D:5.1]` | **DONE.** `goal_scored(scoring_team, score, goal_tick, resume_tick)`, freeze on the goal tick, reset moved out of the sensor path into the kickoff at `resume_tick`; cinematic is presentation-only | `PLAYING -> GOAL_PAUSE -> WARMUP -> PLAYING` observed on the client; bodies stay where the goal left them for the whole window; `Engine.time_scale` untouched | +| 5.5 `[D:5.1]` `[P]` | **DONE.** Clock expiry -> `FULL_TIME` -> sudden death on a draw or `RESULTS`, golden goal in overtime, then `LOBBY` on both peers. `get_tree().paused` is never used in the networked path | Full run observed end to end: `LOADING -> WARMUP -> PLAYING -> FULL_TIME -> OVERTIME_WARMUP -> OVERTIME -> GOAL_PAUSE -> RESULTS -> LOBBY`, both peers returning to the lobby scene | | 5.6 `[D:5.1]` `[P]` | Disconnect → controller swap; 30 s identity-keyed slot reservation and reconnect; `--fill-bots` / `--no-fill-bots`; `stalled` flag and nameplate | A disconnect never despawns a ship; reconnect within 30 s restores the slot | | 5.7 `[D:5.6]` | Null `MatchNet`'s controller reference in the same transaction as the swap, and `is_instance_valid`-guard every use | No freed-object access on repeated disconnect/reconnect | | 5.8 `[D:5.1]` `[P]` | Spectators and late join; spectator-safe `HUDController` path; camera target cycling | A spectator can watch a live match and cycle targets |