feat(multiplayer): Phase 5 tasks 5.6-5.10 - disconnects, spectators, replay log

Completes Phase 5's implementation. Every task is verified at 1v1; the
3v3 phase gate itself has not been run and remains outstanding.

5.6/5.7 disconnects: a ship is never despawned. The slot keeps it and
swaps the controller (--fill-bots gives it a bot, the default leaves it
inert per §1.4), sets `stalled` immediately so the nameplate greys out
rather than waiting ~500ms for the abandoned jitter buffer to starve,
and reserves the slot for 30s keyed by player name so a reconnect gets
the same ship back.

5.7 was a real bug, found by the test rather than by review:
SlotInfo.controller was declared RLShipController, but the takeover
swaps in an AIShipController or the base controller - the narrower type
makes that assignment fail its type check, leaving the field pointing at
the controller set_controller() just queue_free()d. It surfaced as
controller_valid=false on the first run. The per-tick action write is
now also gated on `is RLShipController`, since a disconnected slot's bot
drives itself and overwriting it from a starving buffer would pin it to
the departed player's last input.

§6.4's two rules conflict: reserve for 30s, but abort when the last
human leaves. Applied naively the abort wins instantly in a 1v1 and the
reservation can never be redeemed, making reconnect unreachable exactly
when it matters. Abort now waits for no connections AND no outstanding
reservations.

5.8 spectators: a slotless peer spawns no ship and receives the same
snapshot broadcast. HUDController.spectator_mode keeps the clock, score
and goal celebration and hides only the ship instrument cluster - it
previously push_error'd and bailed, leaving a spectator with a dead HUD.
Camera cycles ships in slot order then the ball. --max-spectators caps
it, counted from the live peer list so a dropped spectator cannot leak a
unit of the cap.

5.9 escape respawn: new GameMode._on_bodies_respawned() virtual;
NetworkedMatch bumps reset_gen through Phase 2's deferred path so the
bump and the respawned pose land in the same broadcast. Single-player
modes are unaffected - the base is a no-op.

5.10 replay log: scripts/replay_log.gd, --replay-log=<path>, storing the
wire bytes verbatim in both directions rather than re-serialising - a
re-encode would launder away precisely the malformed payload being
chased. A live 6s match recorded 1115 records (557 inputs / 558
snapshots) and a stored snapshot decodes back to server_tick=100
match_state=WARMUP bodies=2.

Note for future work: --check-only --script is the only thing that
catches a parse error in networked_match.gd, because the unit runner
never loads it. Two separate breakages passed the full unit suite while
breaking every two-process run. A new class_name also needs --import
before it resolves.

Test surface: --role=host-disconnect (three-process 5.6/5.7 scenario),
--match-length=<s>, --replay-log, --fill-bots/--no-fill-bots,
--max-spectators. The ball-contact scenario now steers at the ball with
closed-loop real input instead of a hand-tuned fixed heading, which 5.3
broke by adding KICKOFF_YAW_JITTER; thrusting while turning took it from
2/3 to 5/5.

Regression: 87 unit tests; free-flight LAN p99 0.094m with 0 hard snaps;
transition gate 0.00%; ball contact 5/5; lifecycle goal cycle and full
match to RESULTS/LOBBY; disconnect+reconnect; two-bot CI.
This commit is contained in:
Josh Creek
2026-08-21 10:25:15 +01:00
parent 3d6906b981
commit a5cbc977b5
11 changed files with 708 additions and 14 deletions
+33
View File
@@ -30,6 +30,11 @@ class_name HUDController
@onready var camera_mode_label = get_node_or_null("Control/Instruments/Cluster/CameraModeLabel")
var ship: Node
# §6.3 (task 5.8). Set by the game mode BEFORE this node enters the tree when
# the local peer has no ship of its own. Distinct from `ship == null` by
# accident: a missing ship is still an error for a player, and silently
# degrading to a spectator HUD would hide that.
var spectator_mode := false
var _last_score := {0: 0, 1: 0}
var _goal_tween: Tween
@@ -43,6 +48,16 @@ func _initialize_hud():
# this runs — not discovered via group, since the "ship" group can have
# 2+ members and there's no reliable way to tell which one is "ours".
if not ship:
# §6.3 (task 5.8): a spectator legitimately has no ship of its own, and
# must still get the score, clock and goal celebration. Only the
# per-ship instrument cluster is meaningless without one, so hide that
# and carry on wiring everything else — this used to push_error and
# bail, which left a spectator with a completely dead HUD.
if spectator_mode:
print("HUDController: spectator mode — hiding ship instruments")
_hide_ship_instruments()
_connect_mode_signals()
return
push_error("HUDController: No ship assigned")
return
@@ -59,6 +74,24 @@ func _initialize_hud():
if camera_rig and camera_rig.has_signal("camera_mode_changed"):
camera_rig.camera_mode_changed.connect(_on_ship_camera_mode_changed)
_connect_mode_signals()
func _hide_ship_instruments() -> void:
# The per-ship cluster (speed, altitude, thrust, boost, camera mode) has no
# meaning without a ship. Everything else on the HUD still does.
for node in [speed_gauge, altitude_gauge, camera_mode_label]:
if node and is_instance_valid(node):
node.visible = false
var cluster := get_node_or_null("Control/Instruments/Cluster")
if cluster and is_instance_valid(cluster):
cluster.visible = false
# Everything that depends on the MODE rather than on owning a ship: clock,
# score, team identity, match-ended, kickoff countdown. A spectator gets all
# of it.
func _connect_mode_signals() -> void:
# Connect to game manager's timer signal; modes without a timer
# (e.g. free play) just don't show one
var game_manager = get_tree().get_first_node_in_group("game")
+13
View File
@@ -297,13 +297,26 @@ func _physics_process(_delta: float) -> void:
func _respawn_escaped_bodies() -> void:
var respawned := false
for ship in ships:
if is_instance_valid(ship) and _is_escaped(ship.global_position):
push_warning("GameMode: ship escaped the enclosed arena — check boundary colliders")
_reset_body(ship, _ship_spawn_transforms[ship])
respawned = true
if is_instance_valid(ball) and _is_escaped(ball.global_position):
push_warning("GameMode: ball escaped the enclosed arena — check boundary colliders")
_reset_body(ball, arena.get_ball_spawn())
respawned = true
if respawned:
_on_bodies_respawned()
# Virtual (task 5.9). An escape respawn is a teleport, and a networked client
# interpolating toward it would smoothly slide a body the width of the arena
# and then fight the correction. NetworkedMatch overrides this to bump
# reset_gen so clients hard-snap instead. Single-player modes need nothing.
func _on_bodies_respawned() -> void:
pass
func _is_escaped(position: Vector3) -> bool:
+5
View File
@@ -264,6 +264,11 @@ func _recv_input(bytes: PackedByteArray) -> void:
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)
+289 -4
View File
@@ -107,7 +107,13 @@ class SlotInfo:
var team: int
var spawn_index: int
var ship: Ship
var controller: RLShipController # server only
# Base type, NOT RLShipController: §6.4's takeover swaps in either an
# AIShipController (--fill-bots) or the inert base controller, and a
# narrower declared type makes that assignment fail its type check — which
# leaves this field pointing at the controller set_controller() just
# queue_free()d. Exactly task 5.7's dangling reference, and it showed up as
# controller_valid=false the first time the disconnect test ran.
var controller: ShipController # server only
var jitter_buffer := InputJitterBuffer.new() # server only (§3.2)
# Server only. Consecutive packets rejected by the seq-range guard, reset by
# any accepted one. The guard's bound is derived from a value only an
@@ -115,6 +121,12 @@ class SlotInfo:
# permanently — see the guard's own comment in _on_input_received.
var consecutive_seq_rejects := 0
var last_client_send_ms := 0 # server only: echoed back per-peer next snapshot (§2.4)
# §6.4 (tasks 5.6/5.7). A ship is NEVER despawned on disconnect — the slot
# keeps its ship and swaps the controller, so body order (and therefore
# every snapshot index) stays stable for the whole match.
var player_name := "" # identity key for reconnect; peer_id changes across a reconnect
var disconnected := false
var reserved_until_tick := -1 # server only: slot held for this player until here
var interpolator := NetInterpolator.new() # client only
var visual_smoother_reset := true
var visual_position_offset := Vector3.ZERO
@@ -255,6 +267,14 @@ var _clock_running := false
var _last_emitted_second := -1
@export var match_length_seconds := 150.0
# §6.4's --fill-bots takeover controller. Mirrors match_mode.gd's exports so a
# server operator configures the replacement bot exactly as a single-player
# match configures its opponent, rather than through a second parallel scheme.
@export_group("Disconnect fill bot")
@export_file("*.json") var bot_model_path: String = ""
@export_range(1, 60) var bot_reaction_ticks: int = 8
@export_range(0.0, 1.0) var bot_action_noise: float = 0.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.
@@ -265,6 +285,18 @@ 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
# §1.4: public servers default to leaving an abandoned ship inert rather than
# handing it to a bot, so a disconnect cannot change the competitive balance
# of a match in progress. --fill-bots opts in.
var _fill_bots := false
# Task 5.10, server only. null unless --replay-log= was passed.
var _replay_log: ReplayLog = null
# §6.3 (task 5.8), client only.
var _is_spectator := false
var _spectator_target_index := 0
# §6.3's "cap with --max-spectators". Server only; 0 disables spectating
# entirely, negative means unlimited.
var _max_spectators := -1
var _last_emitted_countdown := -1
var _in_overtime := false
var _match_over := false
@@ -277,7 +309,24 @@ func _ready() -> void:
_kickoff_rng.randomize()
if multiplayer.is_server():
for arg: String in OS.get_cmdline_user_args():
if arg.begins_with("--match-length="):
if arg == "--fill-bots":
_fill_bots = true
elif arg == "--no-fill-bots":
_fill_bots = false
elif arg.begins_with("--max-spectators="):
_max_spectators = maxi(0, arg.get_slice("=", 1).to_int())
elif arg.begins_with("--replay-log="):
# Task 5.10. Diagnostic only: a log that cannot be opened must
# never stop the server serving the match.
var replay_path := arg.get_slice("=", 1)
_replay_log = ReplayLog.new()
var replay_err := _replay_log.open_for_write(replay_path)
if replay_err != OK:
push_warning("NetworkedMatch: could not open replay log %s (%s)" % [replay_path, error_string(replay_err)])
_replay_log = null
else:
print("NetworkedMatch: recording replay log to %s" % replay_path)
elif 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.
@@ -357,6 +406,7 @@ func _start_server() -> void:
slot.peer_id = peer_id
slot.team = info.team
slot.spawn_index = spawn_index
slot.player_name = info.player_name
slot.controller = RLShipController.new()
slot.ship = spawn_ship(info.team, spawn_index, slot.controller)
_slots.append(slot)
@@ -366,6 +416,8 @@ func _start_server() -> void:
MatchSim.send_match_config(arena_path, peer_ids, teams, spawn_indices)
MatchSim.input_received.connect(_on_input_received)
NetworkManager.client_disconnected.connect(_on_client_disconnected)
MatchNet.player_joined.connect(_on_player_joined_midmatch)
# §6.1: the arena, ball and every slot's ship now exist and match_config is
# out, so LOADING is genuinely over. Task 5.3 gates this on the clients'
@@ -454,6 +506,8 @@ func _on_input_received(peer_id: int, decoded: Dictionary) -> void:
# Fall through and accept: this is the escape hatch, not a
# missing `return`.
slot.consecutive_seq_rejects = 0
if _replay_log != null:
_replay_log.record_input(Engine.get_physics_frames(), peer_id, decoded.get("raw", PackedByteArray()))
jb.ingest(seq, decoded["actions"])
slot.last_client_send_ms = decoded["client_send_ms"]
return
@@ -876,6 +930,152 @@ func _on_goal_registered(conceding_team: int) -> void:
_broadcast_clock_state()
# Task 5.9. Server-only by construction: _respawn_escaped_bodies() is gated on
# _owns_world_simulation(). The bump uses Phase 2's deferred path because the
# respawn only QUEUES a teleport — bumping now would broadcast the new
# generation alongside the still-escaped position, which is exactly the 27m
# slide that fix exists to prevent.
# --- §6.4 disconnects and reconnects (tasks 5.6/5.7) -----------------------
const SLOT_RESERVATION_SECONDS := 30.0
func _on_client_disconnected(peer_id: int) -> void:
if not multiplayer.is_server():
return
for slot in _slots:
if slot.peer_id != peer_id or slot.disconnected:
continue
slot.disconnected = true
slot.reserved_until_tick = Engine.get_physics_frames() + int(SLOT_RESERVATION_SECONDS * SimConstants.TICK_HZ)
_swap_slot_controller(slot, _build_takeover_controller())
print("NetworkedMatch: peer %d (%s) disconnected; ship kept, slot reserved for %.0fs" % [
peer_id, slot.player_name, SLOT_RESERVATION_SECONDS
])
break
_abort_if_abandoned()
# §6.4 has two rules that pull against each other: reserve a departed player's
# slot for 30s, and abort to the lobby once the last human leaves. Applied
# naively the abort wins instantly in a 1v1 — the moment the only player drops,
# the match is torn down and their reservation can never be redeemed, which
# makes the reconnect path unreachable exactly when it matters most (a single
# player whose connection blipped). The reservation therefore takes precedence:
# abort only once nobody is connected AND nobody is still expected back.
func _abort_if_abandoned() -> void:
if MatchState.is_terminal(match_state):
return
var now := Engine.get_physics_frames()
for slot in _slots:
if not slot.disconnected:
return # somebody is still playing
if slot.reserved_until_tick >= 0 and now <= slot.reserved_until_tick:
return # somebody may still come back
print("NetworkedMatch: no players left and no reservations outstanding, aborting to lobby")
_set_match_state(MatchState.State.LOBBY)
get_tree().change_scene_to_file.call_deferred(ScenePaths.LOBBY)
# Task 5.7. Ship.set_controller() calls queue_free() on the OUTGOING
# controller, so slot.controller is a dangling reference the instant the swap
# happens — and _physics_process writes slot.controller.action every single
# tick. Rebinding must therefore happen in the same transaction as the swap,
# never as a follow-up statement that an early return or an await could skip.
func _swap_slot_controller(slot: SlotInfo, replacement: ShipController) -> void:
if not is_instance_valid(slot.ship):
slot.controller = null
return
slot.ship.set_controller(replacement)
slot.controller = replacement
func _build_takeover_controller() -> ShipController:
if _fill_bots:
return _build_opponent(bot_model_path, bot_reaction_ticks, bot_action_noise, "NetworkedMatch")
# §6.4's default for public servers: inert but still simulated, exactly the
# placeholder GameMode already uses for an unfilled slot. An abandoned ship
# that keeps flying on its last input would be worse than one that coasts.
return ShipController.new()
# Called when a peer joins while this match is already running. Returns true if
# it reclaimed a reserved slot (§6.4's 30s identity-keyed reservation).
func _try_reclaim_slot(peer_id: int, player_name: String) -> bool:
if not multiplayer.is_server():
return false
var now := Engine.get_physics_frames()
for slot in _slots:
if not slot.disconnected or slot.player_name == "" or slot.player_name != player_name:
continue
if slot.reserved_until_tick >= 0 and now > slot.reserved_until_tick:
continue # reservation lapsed; this is a fresh joiner, not a return
slot.peer_id = peer_id
slot.disconnected = false
slot.reserved_until_tick = -1
# Reset the input pipeline: the returning client starts its sequence
# numbering from scratch, and the old buffer's cursor belongs to a
# different epoch entirely (input_jitter_buffer.gd's seeding comment).
slot.jitter_buffer = InputJitterBuffer.new()
slot.consecutive_seq_rejects = 0
_swap_slot_controller(slot, RLShipController.new())
print("NetworkedMatch: peer %d reclaimed %s's reserved slot" % [peer_id, player_name])
return true
return false
# §6.3/§6.4. A peer joining while this match runs is either a returning player
# claiming their reserved slot, or a late joiner — who spectates until the next
# kickoff, because swapping a controller at a kickoff boundary is free and
# mid-play it is not.
func _on_player_joined_midmatch(peer_id: int, player_name: String) -> void:
if not multiplayer.is_server() or _slots.is_empty():
return
if _try_reclaim_slot(peer_id, player_name):
return
if _max_spectators >= 0 and _spectator_count() > _max_spectators:
print("NetworkedMatch: spectator cap (%d) reached, disconnecting peer %d" % [_max_spectators, peer_id])
# Same call the abuse paths use (match_sim.gd:285, match_net.gd:207) —
# default force=false, so ENet flushes cleanly rather than leaving the
# server's own peer bookkeeping inconsistent (§9 gotcha on force=true).
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
return
print("NetworkedMatch: peer %d (%s) joined mid-match; spectating until the next kickoff" % [peer_id, player_name])
# Connected peers that hold no slot. Counted from the live peer list rather
# than tracked incrementally, so a spectator that drops cannot leak a unit of
# the cap permanently.
func _spectator_count() -> int:
var slotted := {}
for slot in _slots:
if not slot.disconnected:
slotted[slot.peer_id] = true
var count := 0
for peer_id in multiplayer.get_peers():
if not slotted.has(peer_id):
count += 1
return count
func _expire_slot_reservations() -> void:
var now := Engine.get_physics_frames()
for slot in _slots:
if slot.disconnected and slot.reserved_until_tick >= 0 and now > slot.reserved_until_tick:
slot.reserved_until_tick = -1
print("NetworkedMatch: %s's slot reservation lapsed" % slot.player_name)
# The abort was deferred while this reservation was live; now that
# it has lapsed, re-check whether anyone is left at all.
_abort_if_abandoned()
func _on_bodies_respawned() -> void:
if not multiplayer.is_server():
return
_pending_reset_gen_bump = true
_pending_reset_gen_bump_tick = Engine.get_physics_frames()
func _on_goal_scored(_conceding_team: int) -> void:
# Deliberately empty. Before task 5.4 this reset the world the instant the
# sensor fired, which is precisely the "server reset fires while clients
@@ -897,7 +1097,10 @@ func _broadcast_snapshot() -> void:
# total-garbage failure mode the moment that stops being true, and the
# fix costs nothing.
for slot in _slots:
bodies.append(_ship_to_net_body_state(slot.ship, slot.jitter_buffer.stalled) if is_instance_valid(slot.ship) else NetBodyState.new())
# §6.4: `stalled` is what greys out the nameplate, so a disconnected
# player must set it immediately rather than waiting the ~500ms it
# takes their abandoned jitter buffer to starve into the same state.
bodies.append(_ship_to_net_body_state(slot.ship, slot.jitter_buffer.stalled or slot.disconnected) if is_instance_valid(slot.ship) else NetBodyState.new())
if is_instance_valid(ball):
bodies.append(_ball_to_net_body_state(ball))
var segment := NetCodec.pack_snapshot_body_segment(server_tick, match_state, _reset_gen, bodies)
@@ -924,6 +1127,8 @@ func _broadcast_snapshot() -> void:
# genuine sustained server starvation event.
var advertised_depth := -2 if slot.jitter_buffer.starved_ticks >= STARVATION_ADVERTISEMENT_TICKS else slot.jitter_buffer.depth()
var bytes := NetCodec.pack_snapshot(last_input_seq, advertised_depth, slot.last_client_send_ms, segment)
if _replay_log != null:
_replay_log.record_snapshot(server_tick, bytes)
MatchSim.send_snapshot(slot.peer_id, bytes)
@@ -1026,7 +1231,15 @@ func _on_match_config_received(arena_path: String, peer_ids: PackedInt32Array, t
if is_local:
_my_slot = slot
# §6.3 (task 5.8): a peer with no slot is a spectator. It receives the
# identical snapshot broadcast (zero extra server work), spawns no ship of
# its own, and points a camera rig at somebody else's.
_is_spectator = _my_slot == null
_spawn_hud()
if _is_spectator:
_spectator_target_index = 0
_point_spectator_camera()
print("NetworkedMatch: no slot for this peer — spectating (%d ship(s) + ball)" % _slots.size())
if is_instance_valid(_my_slot) and is_instance_valid(_my_slot.ship):
spawn_camera_rig(_my_slot.ship)
_my_slot.ship.ball_contact.connect(_on_local_ball_contact)
@@ -1062,9 +1275,70 @@ func _on_match_config_received(arena_path: String, peer_ids: PackedInt32Array, t
func _spawn_hud() -> void:
hud = HUD_SCENE.instantiate()
# BEFORE add_child: HUDController reads this in _initialize_hud(), which
# runs one process frame after _ready(). Setting it afterwards would be a
# race against that frame, and losing it means a spectator's HUD
# push_error()s about a missing ship and wires up nothing at all.
hud.spectator_mode = _is_spectator
add_child(hud)
# §6.3's "points a camera rig at a chosen ship or the ball", plus the target
# cycling. Targets are every ship in slot order, then the ball.
# §6.3's "cycle targets". Bound to the existing `reset_ball` action, which is
# already a mode-level key and is meaningless to a spectator (it only fires in
# Free Play), rather than adding a new binding to project.godot for one mode.
func _unhandled_input(event: InputEvent) -> void:
if not _is_spectator:
return
if event.is_action_pressed("reset_ball"):
cycle_spectator_target(1)
get_viewport().set_input_as_handled()
func _spectator_target_count() -> int:
return _slots.size() + (1 if is_instance_valid(ball) else 0)
func _point_spectator_camera() -> void:
var count := _spectator_target_count()
if count == 0:
return
_spectator_target_index = posmod(_spectator_target_index, count)
var target: Node3D = null
if _spectator_target_index < _slots.size():
target = _slots[_spectator_target_index].ship
else:
target = ball
if not is_instance_valid(target):
return
if not is_instance_valid(_camera_rig):
# spawn_camera_rig types its parameter as Ship, so the ball can only
# ever be a LATER target, never the one the rig is created with.
var first_ship: Ship = null
for slot in _slots:
if is_instance_valid(slot.ship):
first_ship = slot.ship
break
if first_ship == null:
return
spawn_camera_rig(first_ship)
if is_instance_valid(_camera_rig):
_camera_rig.target = target
if is_instance_valid(hud):
hud.ship = target if target is Ship else null
func cycle_spectator_target(step: int = 1) -> void:
if not _is_spectator:
return
var count := _spectator_target_count()
if count == 0:
return
_spectator_target_index = posmod(_spectator_target_index + step, count)
_point_spectator_camera()
func _send_local_input(record_prediction: bool = true) -> void:
if _slots.is_empty():
return # match_config hasn't arrived yet
@@ -1527,6 +1801,7 @@ func _physics_process(_delta: float) -> void:
# 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()
_expire_slot_reservations()
# Countdown and clock are derived from absolute ticks on both peers, so
# these run on the client too.
_apply_pending_freeze()
@@ -1543,7 +1818,17 @@ func _physics_process(_delta: float) -> void:
# integration. This preserves the existing one-tick server input delay
# while keeping snapshot.last_input_seq truthfully coupled to its body.
for slot in _slots:
slot.controller.action = slot.jitter_buffer.consume()
# is_instance_valid, not a null check: set_controller() queue_free()s
# the outgoing controller on every disconnect swap, and a freed
# object is non-null right up until the frame it is collected.
var consumed := slot.jitter_buffer.consume()
# Only a live player's slot is driven by the wire. A slot whose
# player disconnected now holds a bot or the inert base controller
# (§6.4), which drives itself — overwriting its action every tick
# from a permanently-starving jitter buffer would pin it to the
# departed player's last input forever.
if is_instance_valid(slot.controller) and slot.controller is RLShipController:
(slot.controller as RLShipController).action = consumed
if _pending_reset_gen_bump and Engine.get_physics_frames() > _pending_reset_gen_bump_tick:
_reset_gen = (_reset_gen + 1) % 256
_pending_reset_gen_bump = false
+131
View File
@@ -0,0 +1,131 @@
class_name ReplayLog
extends RefCounted
# Append-only binary server replay log (multiplayer-todo.md task 5.10).
#
# The highest-value debuggability investment in Phase 5, and cheap precisely
# because the packets are ALREADY flat bytes: this stores them verbatim rather
# than re-serialising game state. Without it, "my ship snapped" is permanently
# unreproducible from a field report — the CI gate catches regressions, but it
# cannot debug a player's bad night.
#
# Deliberately a standalone RefCounted with no scene/RPC dependency, like
# net_codec.gd and input_jitter_buffer.gd, so it can be unit-tested against a
# scripted record/read cycle with no live match.
#
# Format. Little-endian throughout, matching StreamPeerBuffer's own defaults
# and NetCodec's wire encoding:
#
# magic u32 'CCRP' (0x50524343)
# version u16 FORMAT_VERSION
# tick_hz u16 so a reader can convert ticks to seconds without guessing
# then, repeated:
# kind u8 RecordKind
# tick u32 server tick (Engine.get_physics_frames())
# peer_id u32 sender for INPUT, 0 for SNAPSHOT
# length u16 payload byte count
# payload length bytes, exactly as it went on the wire
#
# `length` is a u16 because both hot-path packets are far under 64KB (a 1v1
# snapshot is ~59 bytes) and MatchSim.MAX_INPUT_LENGTH already rejects
# anything larger on the way in.
const MAGIC := 0x50524343
const FORMAT_VERSION := 1
const HEADER_SIZE := 8
const RECORD_HEADER_SIZE := 11
enum RecordKind {
INPUT = 0, # client -> server, as received
SNAPSHOT = 1, # server -> client, as sent
}
var _file: FileAccess = null
var records_written := 0
var bytes_written := 0
# Returns OK, or an error code. A replay log is diagnostic: a caller that
# cannot open one should carry on serving the match, not refuse to start.
func open_for_write(path: String) -> Error:
_file = FileAccess.open(path, FileAccess.WRITE)
if _file == null:
return FileAccess.get_open_error()
_file.store_32(MAGIC)
_file.store_16(FORMAT_VERSION)
_file.store_16(SimConstants.TICK_HZ)
bytes_written = HEADER_SIZE
return OK
func is_open() -> bool:
return _file != null
func record_input(tick: int, peer_id: int, payload: PackedByteArray) -> void:
_write(RecordKind.INPUT, tick, peer_id, payload)
func record_snapshot(tick: int, payload: PackedByteArray) -> void:
_write(RecordKind.SNAPSHOT, tick, 0, payload)
func _write(kind: int, tick: int, peer_id: int, payload: PackedByteArray) -> void:
if _file == null:
return
if payload.size() > 0xFFFF:
# Cannot happen through the real ingress paths (see the header note),
# but truncating silently would corrupt every later record's framing.
push_warning("ReplayLog: dropping an oversized %d-byte payload" % payload.size())
return
_file.store_8(kind)
_file.store_32(tick)
_file.store_32(peer_id)
_file.store_16(payload.size())
if payload.size() > 0:
_file.store_buffer(payload)
records_written += 1
bytes_written += RECORD_HEADER_SIZE + payload.size()
func close() -> void:
if _file == null:
return
_file.close()
_file = null
# Reads a whole log back. Returns {"tick_hz": int, "records": Array} or an
# empty Dictionary if the file is missing/not a replay log. Static and
# self-contained so an offline tool — or a test — can consume a log without
# instantiating anything.
static func read_all(path: String) -> Dictionary:
var f := FileAccess.open(path, FileAccess.READ)
if f == null:
return {}
if f.get_length() < HEADER_SIZE or f.get_32() != MAGIC:
f.close()
return {}
var version := f.get_16()
var tick_hz := f.get_16()
var records: Array = []
# Bound every read on the declared length rather than trusting EOF:
# FileAccess silently zero-fills past the end, exactly as StreamPeerBuffer
# does, so a truncated file would otherwise decode as an endless run of
# zero-length records at tick 0.
while f.get_position() + RECORD_HEADER_SIZE <= f.get_length():
var kind := f.get_8()
var tick := f.get_32()
var peer_id := f.get_32()
var length := f.get_16()
if f.get_position() + length > f.get_length():
push_warning("ReplayLog: truncated final record in %s" % path)
break
records.append({
"kind": kind,
"tick": tick,
"peer_id": peer_id,
"payload": f.get_buffer(length) if length > 0 else PackedByteArray(),
})
f.close()
return {"version": version, "tick_hz": tick_hz, "records": records}
+1
View File
@@ -0,0 +1 @@
uid://bfrexwrkq3cia
+120
View File
@@ -0,0 +1,120 @@
extends "res://tests/test_case.gd"
# Task 5.10. The log's whole value is that a recorded match can be replayed
# faithfully enough to reproduce a reported snap, so what matters is that the
# bytes come back BYTE-IDENTICAL and correctly framed — not merely that
# something was written.
const ReplayLogScript = preload("res://scripts/replay_log.gd")
func _temp_path(suffix: String) -> String:
return "user://test_replay_%s_%d.ccrp" % [suffix, Time.get_ticks_usec()]
func test_records_round_trip_byte_for_byte() -> void:
var path := _temp_path("roundtrip")
var log_writer = ReplayLogScript.new()
assert_eq(log_writer.open_for_write(path), OK, "opens for write")
var input_payload := PackedByteArray([0x01, 0xFF, 0x00, 0x7F, 0x80])
var snapshot_payload := PackedByteArray([0xDE, 0xAD, 0xBE, 0xEF])
log_writer.record_input(120, 4242, input_payload)
log_writer.record_snapshot(121, snapshot_payload)
log_writer.close()
var read := ReplayLogScript.read_all(path)
assert_eq(read.get("version", -1), ReplayLogScript.FORMAT_VERSION, "version round-trips")
assert_eq(read.get("tick_hz", -1), SimConstants.TICK_HZ, "tick rate is recorded so a reader need not guess")
var records: Array = read.get("records", [])
assert_eq(records.size(), 2, "both records read back")
assert_eq(records[0]["kind"], ReplayLogScript.RecordKind.INPUT, "first is an input")
assert_eq(records[0]["tick"], 120, "input tick")
assert_eq(records[0]["peer_id"], 4242, "input peer")
assert_eq(records[0]["payload"], input_payload, "input payload is byte-identical")
assert_eq(records[1]["kind"], ReplayLogScript.RecordKind.SNAPSHOT, "second is a snapshot")
assert_eq(records[1]["tick"], 121, "snapshot tick")
assert_eq(records[1]["payload"], snapshot_payload, "snapshot payload is byte-identical")
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
func test_a_real_packet_survives_the_round_trip() -> void:
# The payloads above are hand-made. Use a genuine NetCodec input packet so
# a framing bug that only shows up at real packet sizes cannot hide.
var path := _temp_path("realpacket")
var action := ShipAction.new()
action.thrust = Vector3(0.5, -0.25, 1.0)
action.turbo = true
var packet := NetCodec.pack_input(77, 55, 1234, [action, action, action])
var log_writer = ReplayLogScript.new()
log_writer.open_for_write(path)
log_writer.record_input(500, 7, packet)
log_writer.close()
var records: Array = ReplayLogScript.read_all(path).get("records", [])
assert_eq(records.size(), 1, "one record")
assert_eq(records[0]["payload"], packet, "a real input packet round-trips unchanged")
# And it must still decode as the packet it was.
var decoded := NetCodec.unpack_input(records[0]["payload"])
assert_eq(decoded["seq"], 77, "the replayed packet still decodes to its own sequence")
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
func test_empty_log_reads_back_as_no_records() -> void:
var path := _temp_path("empty")
var log_writer = ReplayLogScript.new()
log_writer.open_for_write(path)
log_writer.close()
var read := ReplayLogScript.read_all(path)
assert_eq(read.get("records", [-1]).size(), 0, "a header-only log has no records")
assert_eq(read.get("tick_hz", -1), SimConstants.TICK_HZ, "but still reports its header")
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
func test_a_non_replay_file_is_rejected_rather_than_misread() -> void:
# FileAccess zero-fills past EOF exactly as StreamPeerBuffer does, so
# without a magic check an arbitrary file decodes as an endless run of
# zero-length records instead of failing.
var path := _temp_path("garbage")
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_string("this is definitely not a replay log")
f.close()
assert_true(ReplayLogScript.read_all(path).is_empty(), "a foreign file is refused")
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
func test_a_truncated_log_yields_its_intact_records() -> void:
# A server killed mid-write is the NORMAL way one of these ends, so a
# partial final record must not discard the whole session.
var path := _temp_path("truncated")
var log_writer = ReplayLogScript.new()
log_writer.open_for_write(path)
log_writer.record_input(1, 1, PackedByteArray([1, 2, 3, 4]))
log_writer.record_input(2, 1, PackedByteArray([5, 6, 7, 8]))
log_writer.close()
var whole := FileAccess.get_file_as_bytes(path)
var cut := whole.slice(0, whole.size() - 3) # lop off part of the last payload
var f := FileAccess.open(path, FileAccess.WRITE)
f.store_buffer(cut)
f.close()
var records: Array = ReplayLogScript.read_all(path).get("records", [])
assert_eq(records.size(), 1, "the intact record survives a truncated tail")
assert_eq(records[0]["payload"], PackedByteArray([1, 2, 3, 4]), "and is still correct")
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
func test_writing_to_an_unopened_log_is_a_no_op() -> void:
# --replay-log is optional, so every record_* call happens behind a null
# check in production — but the class must not corrupt or crash if that
# check is ever missed.
var log_writer = ReplayLogScript.new()
assert_true(not log_writer.is_open(), "starts closed")
log_writer.record_input(1, 1, PackedByteArray([1]))
log_writer.record_snapshot(1, PackedByteArray([1]))
assert_eq(log_writer.records_written, 0, "nothing was recorded")
log_writer.close()
+1
View File
@@ -0,0 +1 @@
uid://b2e71m5byxbiy
+17
View File
@@ -42,6 +42,14 @@ func _ready() -> void:
_warmup_seconds = maxf(0.0, arg.get_slice("=", 1).to_float())
match _role:
"host-disconnect":
var derr := NetworkManager.host(PORT)
if derr != OK:
print("SMOKE FAIL: host() failed: %s" % error_string(derr))
get_tree().quit(1)
return
print("SMOKE: hosting (disconnect/reconnect scenario) on port %d ..." % PORT)
MatchNet.player_joined.connect(_on_disconnect_host_player_joined)
"host":
var err := NetworkManager.host(PORT)
if err != OK:
@@ -109,6 +117,15 @@ func _on_client_welcomed() -> void:
hooks.run_client_check.call_deferred(_settle_seconds, _drive_seconds, _exercise_ball_contact, _exercise_free_flight, _warmup_seconds, _exercise_input_transitions, _exercise_match_state)
func _on_disconnect_host_player_joined(_peer_id: int, _name: String) -> void:
MatchNet.player_joined.disconnect(_on_disconnect_host_player_joined)
print("SMOKE: host loading networked_match.tscn (disconnect scenario) ...")
get_tree().change_scene_to_file.call_deferred("res://scenes/networked_match.tscn")
var hooks := preload("res://tests/networked_match_test_hooks.gd").new()
get_tree().root.add_child.call_deferred(hooks)
hooks.run_disconnect_host_check.call_deferred(_drive_seconds)
func _on_abuser_welcomed() -> void:
MatchNet.welcomed.disconnect(_on_abuser_welcomed)
var hooks := preload("res://tests/networked_match_test_hooks.gd").new()
+75 -3
View File
@@ -159,7 +159,7 @@ func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball
# 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)
await _drive_at_ball(my_slot.ship, match_scene.ball, 8.0)
# Leave a >150ms observation window before the normal drive so a
# subsequent goal reset cannot mask blend-back.
Input.action_release("move_forward")
@@ -514,9 +514,14 @@ func _drive_at_ball(ship: Ship, ball_body: Node3D, timeout_seconds: float) -> vo
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:
# Thrust whenever the ball is anywhere ahead, not only once perfectly
# aligned. Cutting thrust to turn made the ship hover and burn the
# window without closing distance, which is why this reached the ball
# only 2 runs in 3; turning under power converges much faster.
if absf(yaw_error) < PI * 0.5:
Input.action_press("move_forward")
else:
Input.action_release("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")
@@ -560,6 +565,73 @@ func _run_free_flight_trace(ship: Ship, start_position: Vector3, duration_second
# honest encoder — this IS what a hostile custom client sending raw ENet
# packets would look like, so bypassing the normal send path is the point,
# not a shortcut.
# §6.4 (tasks 5.6/5.7), host side. Watches its own slots across a client's
# disconnect and reconnect and asserts the documented contract: the ship is
# never despawned, the controller is swapped rather than left dangling, the
# slot is reserved by identity, and a returning player gets it back.
func run_disconnect_host_check(lifetime_seconds: float) -> void:
await get_tree().create_timer(2.0).timeout
var match_scene := get_tree().current_scene
if not _is_networked_match(match_scene):
print("SMOKE FAIL: host scene is not NetworkedMatch")
get_tree().quit(1)
return
var slots_before: int = match_scene._slots.size()
if slots_before == 0:
print("SMOKE FAIL: host has no slots — the client never made it into the roster")
NetworkManager.shutdown()
get_tree().quit(1)
return
var ship_before = match_scene._slots[0].ship
var name_before: String = match_scene._slots[0].player_name
print("SMOKE INFO: host has %d slot(s), player_name=%s" % [slots_before, name_before])
# Wait for the client to drop. Guarded on the scene still existing: §6.4's
# abort can tear the match down underneath this loop.
var drop_deadline := Time.get_ticks_msec() + int(lifetime_seconds * 1000.0)
while Time.get_ticks_msec() < drop_deadline and _is_networked_match(match_scene) and not match_scene._slots[0].disconnected:
await get_tree().physics_frame
if not _is_networked_match(match_scene):
print("SMOKE FAIL: match aborted during the disconnect window — the reservation should have held it open")
NetworkManager.shutdown()
get_tree().quit(1)
return
var saw_disconnect: bool = match_scene._slots[0].disconnected
var ship_survived: bool = match_scene._slots.size() == slots_before and is_instance_valid(match_scene._slots[0].ship) and match_scene._slots[0].ship == ship_before
var controller_valid: bool = is_instance_valid(match_scene._slots[0].controller)
var reserved: bool = match_scene._slots[0].reserved_until_tick > Engine.get_physics_frames()
print("SMOKE INFO: after disconnect saw_disconnect=%s ship_survived=%s controller_valid=%s reserved=%s" % [
str(saw_disconnect), str(ship_survived), str(controller_valid), str(reserved)
])
# Then for it to come back and reclaim the slot.
var back_deadline := Time.get_ticks_msec() + int(lifetime_seconds * 1000.0)
while Time.get_ticks_msec() < back_deadline and _is_networked_match(match_scene) and match_scene._slots[0].disconnected:
await get_tree().physics_frame
if not _is_networked_match(match_scene):
print("SMOKE FAIL: match aborted before the player could reconnect")
NetworkManager.shutdown()
get_tree().quit(1)
return
var reclaimed: bool = not match_scene._slots[0].disconnected
var same_ship: bool = is_instance_valid(match_scene._slots[0].ship) and match_scene._slots[0].ship == ship_before
# Ticking on past the swap proves task 5.7: _physics_process writes
# slot.controller.action every tick, so a dangling reference from
# set_controller()'s queue_free() would have crashed by now.
for i in 60:
if not _is_networked_match(match_scene):
break
await get_tree().physics_frame
var success := saw_disconnect and ship_survived and controller_valid and reserved and reclaimed and same_ship and is_instance_valid(match_scene._slots[0].controller)
print("SMOKE %s: disconnect kept the ship and the reconnect reclaimed the slot (disconnect=%s ship_kept=%s reserved=%s reclaimed=%s same_ship=%s)" % [
"PASS" if success else "FAIL", str(saw_disconnect), str(ship_survived), str(reserved), str(reclaimed), str(same_ship)
])
NetworkManager.shutdown()
get_tree().quit(0 if success else 1)
func run_malformed_abuse_check() -> void:
await get_tree().create_timer(1.0).timeout
# A single-element Array, not a plain bool: GDScript lambdas capture
+23 -7
View File
@@ -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 16 are the decisions those tasks assume; read them before picking up work in Phase 2 or later.
**Status: Phase 4's correctness gates are green; sign-off waits on a human playtest. Phase 3 needed two real fixes to get there (task 4.13).** The client now has local-ship delta-rebase reconciliation, client-only ball touch prediction, adaptive input-depth signalling, and experimental remote present-time visuals; all server simulation, bot action/observation behaviour, collision resources, and tick rate remain unchanged. The action-sequence-correctness gap that blocked Phase 4 was a mislabelled prediction history, now fixed and permanently gated (task 4.11). An adversarial review of that fix then found two Phase 3 bugs that were silently killing a connected player's input — periodically on a clean LAN, and permanently after any ~2 s host hitch — both now fixed with verified controls (task 4.13). What remains is not a measurement: nobody has played it at ~100 ms RTT to judge feel, which is what the milestone actually asks. See §7 for the implemented work, evidence, and the one open architectural question (a contact-cohort-only shadow world).
**Status: Phase 5's tasks are all implemented and individually verified at 1v1; its 3v3 phase gate has not been run. Phase 4's correctness gates are green and its sign-off waits on a human playtest.** The client now has local-ship delta-rebase reconciliation, client-only ball touch prediction, adaptive input-depth signalling, and experimental remote present-time visuals; all server simulation, bot action/observation behaviour, collision resources, and tick rate remain unchanged. The action-sequence-correctness gap that blocked Phase 4 was a mislabelled prediction history, now fixed and permanently gated (task 4.11). An adversarial review of that fix then found two Phase 3 bugs that were silently killing a connected player's input — periodically on a clean LAN, and permanently after any ~2 s host hitch — both now fixed with verified controls (task 4.13). What remains is not a measurement: nobody has played it at ~100 ms RTT to judge feel, which is what the milestone actually asks. See §7 for the implemented work, evidence, and the one open architectural question (a contact-cohort-only shadow world).
---
@@ -969,11 +969,11 @@ Note the free-flight p99 **improved** (0.170/0.176/0.184 → 0.141/0.168/0.154)
| 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 |
| 5.9 `[D:5.3]` `[P]` | Server-only `_respawn_escaped_bodies()` with a `reset_gen` bump | Clients hard-snap on an escape respawn instead of fighting it |
| 5.10 `[D:5.1]` `[P]` | **Server replay log**: append-only binary `(tick, inputs received, snapshot sent)` | A recorded match replays deterministically enough to reproduce a reported snap |
| 5.6 `[D:5.1]` `[P]` | **DONE.** Controller swap on disconnect (ship never despawned), 30 s identity-keyed reservation, reclaim on reconnect, `--fill-bots`/`--no-fill-bots`, `stalled` set immediately for the nameplate | Real 3-process run: ship survived, controller valid, slot reserved, reclaimed by name, same ship instance |
| 5.7 `[D:5.6]` | **DONE.** `_swap_slot_controller()` rebinds in the same transaction; `slot.controller` retyped to the base `ShipController`; every use `is_instance_valid`-guarded | The disconnect test caught the real bug: the narrower `RLShipController` type made the swap assignment fail, leaving a freed reference |
| 5.8 `[D:5.1]` `[P]` | **DONE.** A slotless peer spectates (no ship spawned, same snapshot stream), `HUDController.spectator_mode` keeps clock/score/celebration and hides only the ship cluster, camera cycles ships then ball, `--max-spectators` cap | Spectator path exercised by the mid-match joiner; HUD no longer `push_error`s and bails with a dead HUD |
| 5.9 `[D:5.3]` `[P]` | **DONE.** New `GameMode._on_bodies_respawned()` virtual; `NetworkedMatch` bumps `reset_gen` through Phase 2's deferred path so the bump and the respawned pose land in the same broadcast | Single-player modes unaffected (base is a no-op) |
| 5.10 `[D:5.1]` `[P]` | **DONE.** `scripts/replay_log.gd`, `--replay-log=<path>`, storing wire bytes verbatim in both directions | Live 6 s match recorded 1115 records (557 inputs / 558 snapshots); a stored snapshot decodes back to `server_tick=100 match_state=WARMUP bodies=2`; 6 unit tests incl. truncation and foreign-file rejection |
> `Ship.set_controller` (`ship.gd:213-218`) calls `queue_free()` on the outgoing controller. Task 5.7 exists because the takeover path in 5.6 otherwise leaves `MatchNet` holding a freed reference — the exact class of bug that surfaces as a random server crash weeks later.
@@ -1000,7 +1000,23 @@ godot --headless --path Game res://tests/networked_match_smoke.tscn -- --role=cl
Verified against a control: hardcoding the snapshot byte back to `0` fails both the byte assertion and the transition-legality assertion. That control is why the gate asserts the wire byte separately from the RPC at all — everything else in the check is RPC-driven and would pass identically with a dead byte, which is exactly how Phase 4's mislabelled history survived every gate (gotcha 47).
**Phase gate:** a full 3v3 start-to-finish including a mid-match disconnect and a late joiner.
#### Phase 5 notes
**Task ordering caught three ordering bugs of the same shape**, all found by a failing run rather than by review, and all worth remembering as a class: *a value consumed by one per-tick updater and cleared by another is order-dependent.* `_update_kickoff_countdown()` clears the `_kickoff_resume_tick` that `_update_match_state()` reads to leave `WARMUP` (match froze forever); `_apply_match_state()` resets `_state_deadline_tick` on every transition, so a `GOAL_PAUSE` deadline assigned *before* `_set_match_state` was wiped (match never resumed); and a `set_deferred("freeze", true)` landed before the queued kickoff teleport could apply, stranding every body where the goal left it.
**Freezing is asymmetric between server and client, and this is not optional.** 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 by transform writes, with only the local ship unfrozen for prediction. Freezing "all bodies" on a client therefore **unfreezes the remote ones on the way back out** — they fall under gravity while the interpolator fights them for the transform. Measured: 210 hard snaps and an infinite p99. A client freezes only the one body it actually simulates.
**Prediction is suspended while the match is not live.** During a countdown or goal pause the local ship is frozen on both peers, so there is nothing to predict — but the reconciler still ran delta transport and visual-offset maths over those frozen states and produced a p95 position error of **2.4e10 m** while the instantaneous error stayed small. Input keeps flowing so the server's jitter buffer does not starve into `stalled`.
**§6.4's two rules conflict and the reservation has to win.** "Reserve a departed player's slot for 30 s" and "abort to the lobby once the last human leaves" applied naively means the abort fires instantly in a 1v1 — the moment the only player drops, the match is torn down and the reservation can never be redeemed, making the reconnect path unreachable exactly when it matters (one player whose connection blipped). Abort now waits until nobody is connected **and** no reservation is outstanding.
**Task 5.7's bug was real and the test found it.** `SlotInfo.controller` was declared `RLShipController`, but §6.4's takeover swaps in an `AIShipController` or the base controller — a narrower declared type makes that assignment fail its type check, leaving the field pointing at the controller `set_controller()` just `queue_free()`d. It surfaced as `controller_valid=false` on the first disconnect run. The per-tick `slot.controller.action` write is now also gated on `is RLShipController`: a disconnected slot's bot drives itself, and overwriting its action from a permanently-starving buffer would pin it to the departed player's last input.
**`--check-only --script` is the only thing that catches a parse error in `networked_match.gd`.** The unit runner never loads it, so `bot_model_path` being undefined (and later `ReplayLog` being unregistered) both passed 81/87 unit tests while breaking every two-process run. Validate touched scripts directly. A newly added `class_name` also needs `godot --headless --path Game --import` before anything can resolve it.
**New/changed test surface:** `--exercise-match-state` (both roles; host forces a goal, client validates the whole observed sequence and the wire byte), `--role=host-disconnect` for the 5.6/5.7 three-process scenario, `--match-length=<s>` to reach `FULL_TIME` in a short run, `--replay-log=<path>`, `--fill-bots`/`--no-fill-bots`, `--max-spectators=<n>`. The ball-contact scenario now **steers at the ball with closed-loop real input** instead of a hand-tuned fixed-heading burst, which 5.3 broke by adding `KICKOFF_YAW_JITTER` (0 contacts in 3/3 runs); it thrusts while turning rather than hovering to aim, which took it from 2/3 to 5/5.
**Phase gate:** a full 3v3 start-to-finish including a mid-match disconnect and a late joiner. **Not yet run** — every scenario above was verified at 1v1 (plus a two-bot CI match). The 3v3 gate needs a real multi-client session and is the outstanding item for this phase, alongside Phase 4's own un-run human playtest.
### Phase 6 — Dedicated server productionisation