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)
+88
View File
@@ -0,0 +1,88 @@
class_name MatchState
# Match lifecycle states (multiplayer-todo.md §6.1, task 5.1).
#
# Pure data + a transition table, deliberately with no scene, RPC or
# NetworkedMatch dependency — same reason net_codec.gd and
# input_jitter_buffer.gd are standalone: the table can then be exhaustively
# unit-tested without a live match.
#
# The integer values ARE the wire format. `match_state` has been a u8 in the
# snapshot header since §2.4 (net_codec.gd's pack_snapshot_body_segment), so
# these numbers are protocol, not an implementation detail: never renumber an
# existing state, only append. LOBBY is 0 so a zeroed/placeholder snapshot
# body decodes to a state that is obviously "not in a match" rather than to
# something mid-play.
enum State {
LOBBY = 0,
LOADING = 1,
WARMUP = 2,
PLAYING = 3,
GOAL_PAUSE = 4,
FULL_TIME = 5,
OVERTIME_WARMUP = 6,
OVERTIME = 7,
RESULTS = 8,
}
# Legal successors, straight from §6.1's diagram. Enforced rather than
# documented: an illegal transition is a server logic bug, and the failure it
# otherwise produces (clients following the server into a state its own code
# never expected to broadcast) is exactly the kind that shows up as an
# unreproducible field report three phases later.
#
# LOBBY is reachable from ANY state and is handled separately in
# can_transition() rather than being listed nine times — §6.4's "if the last
# human leaves, abort to LOBBY" can fire at any point, including mid-goal.
const _SUCCESSORS := {
State.LOBBY: [State.LOADING],
State.LOADING: [State.WARMUP],
State.WARMUP: [State.PLAYING],
# A goal, or the clock running out. FULL_TIME is entered on the clock even
# if a goal is in flight — §6.2 step 9's clock is authoritative.
State.PLAYING: [State.GOAL_PAUSE, State.FULL_TIME],
# Back to a kickoff, or straight to results when the goal that caused the
# pause also ended the match (golden goal in overtime, or a goal on the
# final tick).
State.GOAL_PAUSE: [State.WARMUP, State.OVERTIME_WARMUP, State.RESULTS],
State.FULL_TIME: [State.OVERTIME_WARMUP, State.RESULTS],
State.OVERTIME_WARMUP: [State.OVERTIME],
State.OVERTIME: [State.GOAL_PAUSE, State.RESULTS],
State.RESULTS: [State.LOBBY],
}
# States in which the simulation is live and inputs drive ships. Everything
# else freezes bodies (§6.2 steps 6 and 8). Kept as a set here rather than as
# an `if state == PLAYING or state == OVERTIME` scattered through
# NetworkedMatch, so adding a future live state can't miss a site.
const _LIVE := [State.PLAYING, State.OVERTIME]
static func is_valid(state: int) -> bool:
return state in State.values()
static func is_live(state: int) -> bool:
return state in _LIVE
# True when the match is over and the clock should not advance. Distinct from
# `not is_live()`: a WARMUP is not live but the match is very much ongoing.
static func is_terminal(state: int) -> bool:
return state == State.RESULTS or state == State.LOBBY
static func can_transition(from_state: int, to_state: int) -> bool:
if not is_valid(from_state) or not is_valid(to_state):
return false
if to_state == State.LOBBY:
return from_state != State.LOBBY # §6.4 abort, from anywhere
return to_state in _SUCCESSORS.get(from_state, [])
static func to_name(state: int) -> String:
for key in State.keys():
if State[key] == state:
return key
return "UNKNOWN(%d)" % state
+1
View File
@@ -0,0 +1 @@
uid://b1etnxbdelq1p
+132 -1
View File
@@ -20,6 +20,10 @@ extends GameMode
# but-dead signal shows a permanently frozen timer rather than correctly
# hiding it the way free_play.gd's total absence of the signal does.
signal score_changed(score: Dictionary)
# §6.1 task 5.1. Emitted on BOTH peers — server-side when it drives a
# 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)
const NetCodec = preload("res://scripts/net_codec.gd")
const NetBodyState = preload("res://scripts/net_body_state.gd")
@@ -213,6 +217,27 @@ var _reset_gen := 0 # server only: bumped on every kickoff
var _pending_reset_gen_bump := false
var _pending_reset_gen_bump_tick := -1
# §6.1 task 5.1. Authoritative on the server; on a client this mirrors what
# the server last told us, via state_change (prompt, carries at_tick) or the
# snapshot's match_state byte (the catch-up path — see _apply_match_state).
var match_state := MatchState.State.LOADING
var match_state_since_tick := 0
# Client only: the match_state byte of the most recently decoded snapshot.
# Distinct from `match_state` on purpose — it is what the WIRE said, so a test
# can prove the byte is genuinely populated rather than passing on the
# reliable state_change RPC alone.
var _last_snapshot_match_state := -1
# Server only: the tick the current state's own timer expires on, or -1 when
# the state has no timer (PLAYING ends on a goal or the clock, not a deadline).
var _state_deadline_tick := -1
# Placeholder durations. Task 5.3 replaces the WARMUP one with the real
# broadcast kickoff (reset transforms + a countdown derived from server_tick),
# 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
func _ready() -> void:
add_to_group("game")
@@ -238,6 +263,7 @@ func _ready() -> void:
MatchSim.match_config_received.connect(_on_match_config_received)
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)
_request_match_config_until_received()
@@ -301,6 +327,14 @@ func _start_server() -> void:
MatchSim.send_match_config(arena_path, peer_ids, teams, spawn_indices)
MatchSim.input_received.connect(_on_input_received)
# §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'
# own scene_ready (with a 10s timeout) instead of leaving immediately —
# there is no scene_ready message yet, and inventing half of one here
# would be worse than the honest placeholder.
_apply_match_state(MatchState.State.LOADING, Engine.get_physics_frames())
_set_match_state(MatchState.State.WARMUP)
func _on_input_received(peer_id: int, decoded: Dictionary) -> void:
for slot in _slots:
@@ -386,6 +420,72 @@ func _on_input_received(peer_id: int, decoded: Dictionary) -> void:
_unknown_sender_input_count += 1
# --- §6.1 match state machine (task 5.1) -----------------------------------
#
# Deliberately does NOT gate physics, freezing or input this task. Tasks 5.3
# and 5.4 own freeze/unfreeze at kickoff and goal, and doing it here would
# both duplicate that work and silently change the conditions every Phase 4
# prediction gate was measured under. 5.1's job is the machine, the broadcast
# and the client following it.
func _set_match_state(new_state: int) -> void:
if not multiplayer.is_server():
push_error("NetworkedMatch: only the server may drive match state")
return
if new_state == match_state:
return
if not MatchState.can_transition(match_state, new_state):
# Loud, not silent: this is a server logic error, and the symptom it
# produces otherwise (clients faithfully following into a state the
# server's own code never meant to reach) is near-impossible to
# diagnose from a field report.
push_error("NetworkedMatch: illegal match state transition %s -> %s" % [
MatchState.to_name(match_state), MatchState.to_name(new_state)
])
return
var at_tick := Engine.get_physics_frames()
_apply_match_state(new_state, at_tick)
MatchSim.send_state_change(new_state, at_tick)
# The one place either peer's state actually changes, so the signal and the
# bookkeeping cannot drift apart between the server and client paths.
func _apply_match_state(new_state: int, at_tick: int) -> void:
if new_state == match_state:
return
match_state = new_state
match_state_since_tick = at_tick
_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.GOAL_PAUSE:
_state_deadline_tick = at_tick + GOAL_PAUSE_TICKS
match_state_changed.emit(new_state, at_tick)
# 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:
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.
_set_match_state(MatchState.State.WARMUP)
func _on_state_change_received(state: int, at_tick: int) -> void:
# Client path. MatchSim already rejected an unknown state value, and the
# server is the only peer allowed to send this (rpc "authority").
_apply_match_state(state, at_tick)
func _on_goal_registered(conceding_team: int) -> void:
_record_goal(1 - conceding_team)
MatchSim.send_score_update(score.duplicate())
@@ -396,6 +496,11 @@ func _on_goal_scored(_conceding_team: int) -> void:
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)
func _broadcast_snapshot() -> void:
@@ -412,7 +517,7 @@ func _broadcast_snapshot() -> void:
bodies.append(_ship_to_net_body_state(slot.ship, slot.jitter_buffer.stalled) 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, 0, _reset_gen, bodies)
var segment := NetCodec.pack_snapshot_body_segment(server_tick, match_state, _reset_gen, bodies)
# Building the shared body segment once and reusing it per peer (rather
# than re-encoding per client) is the whole reason §2.4 splits the wire
# format into a per-client header + a shared body segment in the first
@@ -647,6 +752,27 @@ func _on_snapshot_received(decoded: Dictionary) -> void:
_expected_next_snapshot_tick = server_tick + 1
_last_received_snapshot_tick = server_tick
_last_snapshot_wall_ms = Time.get_ticks_msec()
# match_state catch-up (§6.1). state_change is reliable, so this is not a
# loss-recovery path — it covers the cases reliability cannot: a client
# that joined mid-match and has not been sent a transition yet, and the
# window between scene load and the first state_change arriving. Snapshots
# carry no at_tick for the transition, so attribute it to this snapshot's
# own server_tick, which is the tightest bound available and is never
# later than the true transition tick.
var snapshot_state: int = decoded["match_state"]
_last_snapshot_match_state = snapshot_state
# The tick guard is load-bearing, not defensive padding. state_change is
# reliable on channel 0 while snapshots are unreliable_ordered on channel
# 2, and ordering is only guaranteed WITHIN a channel — so a state_change
# for tick N routinely arrives before a snapshot that was sent at tick
# N-2 and is still in flight. Without this the client would apply the new
# state, then be dragged straight back by the older snapshot's byte, and
# oscillate on every single transition. Observed exactly that while
# testing a deliberately-broken byte: LOADING -> WARMUP -> LOBBY ->
# PLAYING -> LOBBY -> ... Only accept a byte at least as new as whatever
# told us the current state.
if snapshot_state != match_state and MatchState.is_valid(snapshot_state) and server_tick >= match_state_since_tick:
_apply_match_state(snapshot_state, server_tick)
# Per-client header (§2.4): unlike the shared body segment, this is
# genuinely this recipient's own — input_buffer_depth is THIS client's
# own slot's server-side InputJitterBuffer.depth() at send time, which
@@ -931,6 +1057,8 @@ func get_net_debug_stats() -> Dictionary:
if _last_local_prediction_comparison.get("authoritative_state", null) != null:
server_stalled = (_last_local_prediction_comparison["authoritative_state"] as NetBodyState).stalled
return {
"match_state": match_state,
"snapshot_match_state": _last_snapshot_match_state,
"input_buffer_depth": _last_known_input_buffer_depth,
"input_lead": _input_lead_controller.lead,
"input_target_depth": _current_input_target_depth(),
@@ -1001,6 +1129,9 @@ func _physics_process(_delta: float) -> void:
if _owns_world_simulation():
_respawn_escaped_bodies()
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.
_update_match_state()
# _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