feat(multiplayer): Phase 5 task 5.1 - match lifecycle state machine

Adds the §6.1 state machine, its broadcast, and the client side that
follows it. Physics, freezing and input are deliberately NOT gated on
state yet - 5.3 and 5.4 own freeze/unfreeze at kickoff and goal, and
doing it here would change the conditions every Phase 4 prediction gate
was measured under.

scripts/match_state.gd holds the enum and transition table as pure data
with no scene or RPC dependency, so the table is checked exhaustively
rather than by example: every state reachable, every state has an exit,
no self-transitions, abort-to-LOBBY from anywhere per §6.4, illegal
shortcuts rejected, unknown values refused rather than coerced. The enum
values are the wire format - match_state has been a u8 in the snapshot
header since §2.4 - so a test pins them; only append, never renumber.

The server validates every transition and push_errors an illegal one
rather than following it. Clients deliberately do NOT enforce the table:
authoritative state must be accepted, and a late joiner legitimately
jumps straight to PLAYING.

Two channels carry the state. state_change (reliable, channel 0) is
prompt and carries an absolute at_tick, never a duration. The snapshot's
match_state byte is the catch-up path for a client not yet sent a
transition - a late joiner, or the window between scene load and the
first RPC.

The byte needs a tick guard, and this was found the hard way. Snapshots
are unreliable_ordered on channel 2 and ordering holds only within a
channel, so a state_change for tick N routinely arrives before an
in-flight snapshot from tick N-2. Without the guard the client applies
the new state then gets dragged back by the older byte, oscillating on
every transition - observed directly as LOADING -> WARMUP -> LOBBY ->
PLAYING -> LOBBY while running a deliberately-broken-byte control. Only
a byte at least as new as match_state_since_tick is accepted.

WARMUP_TICKS/GOAL_PAUSE_TICKS are honest placeholders so 5.1 drives real
transitions to verify against; 5.3 and 5.4 replace them. The server also
leaves LOADING immediately rather than waiting for scene_ready, which
does not exist yet.

New smoke flag --exercise-match-state, passed to both roles: the host
forces a goal to drive a GOAL_PAUSE cycle, the client records the
sequence and asserts every consecutive pair is legal, that ticks are
monotonic, and that the wire byte agrees with its own state. Observed
LOADING -> WARMUP -> PLAYING -> GOAL_PAUSE -> WARMUP with tick deltas
matching the configured durations exactly.

Verified against a control: hardcoding the snapshot byte back to 0 fails
both the byte assertion and the transition-legality assertion. The byte
is asserted separately from the RPC precisely because everything else in
the check is RPC-driven and would pass with a dead byte - the same gap
that hid the Phase 4 label bug (gotcha 47).

Regression: 81 unit tests; 60s free-flight LAN (p99 0.148m, 0 hard
snaps, marker 0/3364); transition gate 0.00%; ball contact; two-bot CI.
This commit is contained in:
Josh Creek
2026-08-21 09:31:22 +01:00
parent 75f485667b
commit 9f28c02488
9 changed files with 485 additions and 7 deletions
+26
View File
@@ -23,6 +23,7 @@ signal match_config_received(arena_path: String, peer_ids: PackedInt32Array, tea
signal input_received(peer_id: int, decoded: Dictionary) # decoded: see NetCodec.unpack_input
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
# 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-
@@ -164,6 +165,18 @@ func send_score_update(score: Dictionary) -> void:
_score_update.rpc(score)
# §6.1 task 5.1. Reliable channel 0, and it carries the ABSOLUTE tick the
# transition happened on rather than a duration — §6.2's closing note: on a
# lossy link ENet's RTO can stretch a lifecycle burst to ~600ms, and a
# duration would then be applied from whenever it happened to arrive.
# The same state also rides every snapshot's match_state byte, so a client
# that misses this entirely still converges (see NetworkedMatch's own
# _on_snapshot_received) — this RPC exists to make the transition PROMPT and
# to carry `at_tick`, not to be the sole channel.
func send_state_change(state: int, at_tick: int) -> void:
_state_change.rpc(state, at_tick)
@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)
@@ -250,6 +263,19 @@ func _snapshot(bytes: PackedByteArray) -> void:
snapshot_received.emit(decoded)
@rpc("authority", "call_remote", "reliable", 0)
func _state_change(state: int, at_tick: int) -> void:
# "authority" already means a forging client is rejected by Godot itself
# (verified for _match_config/_score_update/_snapshot during Phase 2), but
# an authoritative server sending a state this build doesn't know about is
# a real forward-compatibility case — drop it rather than driving the
# client into an undefined state.
if not MatchState.is_valid(state):
push_warning("MatchSim: ignoring unknown match_state %d from server" % state)
return
state_change_received.emit(state, at_tick)
@rpc("authority", "call_remote", "reliable", 0)
func _score_update(score: Dictionary) -> void:
score_update_received.emit(score)