feat(multiplayer): Phase 1 transport, connection, and lobby

Lands tasks 1.0-1.8 of multiplayer-todo.md: the pure-function test runner,
net_codec (wire format quantizers/pack-unpack), NetworkManager (ENet
transport, manual polling, min-RTT clock sync), MatchNet (handshake,
protocol/tick-rate gating, roster with team+ready state), lobby.tscn (team
columns, switch team, ready toggle), server_boot.tscn (headless dedicated
server with structured logging and an overrun watchdog), and main_menu.gd's
Host/Join-by-IP UI (connecting overlay, cancel, bounded failure path).

Followed by an adversarial review (Opus subagent) that found and fixed two
real bugs - an unvalidated player_name broadcast that let one client's
oversized name head-of-line-block the reliable channel for everyone, and a
server-side roster leak across a host/re-host cycle - plus three gaps in
the test suite itself where a claim of "verified" wasn't actually backed
by what the test checked. All five two-process smoke tests plus the
pure-function suite are green with the strengthened assertions in place.
This commit is contained in:
Josh Creek
2026-08-20 08:18:59 +01:00
parent e83bb4fa0c
commit 4533da34e0
30 changed files with 2509 additions and 12 deletions
+116
View File
@@ -0,0 +1,116 @@
extends Control
# Lobby (task 1.5): roster list split by team, team swap, ready toggle,
# leave. Reads/writes MatchNet.roster — this scene owns no state of its
# own, it's a view over the autoload. Reached via main_menu.gd's Host/Join
# flow (task 1.7) calling change_scene_to_file("res://scenes/lobby.tscn")
# after NetworkManager.host()/join() succeeds — this scene must always be
# loaded that way (as the real current_scene), not instantiated as a child
# of something else: change_scene_to_file() operates on
# get_tree().current_scene, and _on_disconnected_from_server()/_leave()
# below call it themselves, which hangs if this scene isn't actually the
# tree's current_scene when that happens (see multiplayer-todo.md §9
# gotcha 27 — found the hard way while building tests/lobby_smoke.gd).
@onready var _status_label: Label = %StatusLabel
@onready var _team0_list: VBoxContainer = %Team0List
@onready var _team1_list: VBoxContainer = %Team1List
@onready var _controls_row: HBoxContainer = %ControlsRow
@onready var _switch_team_button: Button = %SwitchTeamButton
@onready var _ready_button: CheckButton = %ReadyButton
@onready var _leave_button: Button = %LeaveButton
func _ready() -> void:
MatchNet.welcomed.connect(_on_welcomed)
MatchNet.player_joined.connect(_on_roster_changed)
MatchNet.player_left.connect(_on_roster_changed)
MatchNet.player_state_changed.connect(_on_roster_changed)
MatchNet.rejected.connect(_on_rejected)
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
# The server process is never a roster member (§1.1 decision 2) — it
# gets a read-only view, no team/ready controls to operate on itself.
_controls_row.visible = NetworkManager.is_client
_refresh()
func _process(_delta: float) -> void:
NetworkManager.poll()
func _physics_process(_delta: float) -> void:
NetworkManager.poll()
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
_leave()
func _on_roster_changed(_a = null, _b = null, _c = null) -> void:
_refresh()
func _on_welcomed() -> void:
_refresh()
func _on_rejected(reason: String) -> void:
_status_label.text = "Connection rejected: %s" % reason
func _on_disconnected_from_server() -> void:
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
func _on_switch_team_pressed() -> void:
var my_id := multiplayer.get_unique_id()
var info: MatchNet.PlayerInfo = MatchNet.roster.get(my_id)
if info == null:
return
MatchNet.request_set_team((info.team + 1) % MatchNet.TEAM_COUNT)
func _on_ready_toggled(pressed: bool) -> void:
MatchNet.request_set_ready(pressed)
func _on_leave_pressed() -> void:
_leave()
func _leave() -> void:
NetworkManager.shutdown()
get_tree().change_scene_to_file(ScenePaths.MAIN_MENU)
func _refresh() -> void:
if NetworkManager.is_server:
_status_label.text = "Hosting — %d player(s) connected" % MatchNet.roster.size()
elif NetworkManager.is_client:
_status_label.text = "Connected" if not MatchNet.roster.is_empty() else "Connecting..."
else:
_status_label.text = "Not connected"
for child in _team0_list.get_children():
child.queue_free()
for child in _team1_list.get_children():
child.queue_free()
var my_id := multiplayer.get_unique_id()
var infos: Array = MatchNet.roster.values()
infos.sort_custom(func(a: MatchNet.PlayerInfo, b: MatchNet.PlayerInfo) -> bool: return a.peer_id < b.peer_id)
for info: MatchNet.PlayerInfo in infos:
var row := Label.new()
var marker := " (you)" if info.peer_id == my_id else ""
var ready_mark := "" if info.ready else ""
row.text = "%s %s%s" % [ready_mark, info.player_name, marker]
var target_list := _team0_list if info.team == 0 else _team1_list
target_list.add_child(row)
if NetworkManager.is_client:
var my_info: MatchNet.PlayerInfo = MatchNet.roster.get(my_id)
if my_info != null:
_ready_button.set_pressed_no_signal(my_info.ready)
+110
View File
@@ -31,6 +31,10 @@ const DIFFICULTIES := [
@onready var dev_bot_dropdown: OptionButton = %DevBotDropdown
@onready var bot_a_dropdown: OptionButton = %BotADropdown
@onready var bot_b_dropdown: OptionButton = %BotBDropdown
@onready var join_address_edit: LineEdit = %JoinAddressEdit
@onready var multiplayer_error_label: Label = %MultiplayerErrorLabel
@onready var connecting_overlay: Control = %ConnectingOverlay
@onready var connecting_status_label: Label = %ConnectingStatusLabel
func _ready() -> void:
@@ -46,9 +50,27 @@ func _ready() -> void:
_populate_dropdown(dev_bot_dropdown, bots, GameSettings.dev_bot_override_path, true)
_populate_dropdown(bot_a_dropdown, bots, GameSettings.spectate_bot_a_path)
_populate_dropdown(bot_b_dropdown, bots, GameSettings.spectate_bot_b_path)
NetworkManager.connected_to_server.connect(_on_connected_to_server)
NetworkManager.connection_failed.connect(_on_connection_failed)
$CenterContainer/VBoxContainer/FreePlayButton.grab_focus()
# main_menu.gd's first async flow (task 1.7): Host is synchronous
# (NetworkManager.host() either succeeds immediately or fails immediately),
# but Join is not — it can take anywhere from a clean local-network round
# trip to ENet's own ~5s connect timeout to resolve, so unlike every other
# handler in this file (GameSettings.x = y; change_scene_to_file(...)) it
# needs a loading state (ConnectingOverlay), a cancel path, and a failure
# path that returns the player to a sane, retryable menu state rather than
# just hanging with no feedback.
func _process(_delta: float) -> void:
NetworkManager.poll()
func _physics_process(_delta: float) -> void:
NetworkManager.poll()
func _populate_difficulty_dropdown() -> void:
difficulty_dropdown.clear()
for tier in DIFFICULTIES:
@@ -158,3 +180,91 @@ func _on_spectate_pressed() -> void:
GameSettings.spectate_bot_a_path = _selected_path(bot_a_dropdown)
GameSettings.spectate_bot_b_path = _selected_path(bot_b_dropdown)
_leave_to_gameplay("res://scenes/spectate.tscn")
func _on_host_pressed() -> void:
_clear_multiplayer_error()
var err := NetworkManager.host()
if err != OK:
_show_multiplayer_error("Could not host: %s" % error_string(err))
return
_leave_to_lobby()
func _on_join_pressed() -> void:
_start_join()
func _on_join_address_submitted(_new_text: String) -> void:
_start_join()
# ENet's own give-up-and-fire-connection_failed schedule is not bounded to
# anything a menu should make a player wait for — verified empirically
# (tests/main_menu_test_hooks.gd's join_refused case) against a genuinely
# refused loopback connection: connection_failed never fired within 14s.
# This timer is what actually guarantees "connection-refused reaches a sane
# UI state" rather than leaving the overlay up indefinitely.
const CONNECT_TIMEOUT_SECONDS := 6.0
var _connect_timeout_token := 0 # bumped on every new attempt/cancel/resolution so a stale timer callback is a no-op
func _start_join() -> void:
_clear_multiplayer_error()
var address := join_address_edit.text.strip_edges()
if address.is_empty():
_show_multiplayer_error("Enter an IP address to join")
return
var err := NetworkManager.join(address)
if err != OK:
_show_multiplayer_error("Could not join: %s" % error_string(err))
return
connecting_status_label.text = "Connecting to %s..." % address
connecting_overlay.visible = true
_connect_timeout_token += 1
var my_token := _connect_timeout_token
get_tree().create_timer(CONNECT_TIMEOUT_SECONDS).timeout.connect(func(): _on_connect_timeout(my_token))
func _on_connect_timeout(token: int) -> void:
if token != _connect_timeout_token or not connecting_overlay.visible:
return # a newer attempt (or Cancel, or a real success/failure) already resolved this
NetworkManager.shutdown()
connecting_overlay.visible = false
_show_multiplayer_error("Connection timed out — check the address and that a server is hosting on that port")
func _on_connecting_cancel_pressed() -> void:
_connect_timeout_token += 1
NetworkManager.shutdown()
connecting_overlay.visible = false
func _on_connected_to_server() -> void:
if not connecting_overlay.visible:
return # e.g. a stray/late signal after Cancel already shut the peer down
_connect_timeout_token += 1
connecting_overlay.visible = false
_leave_to_lobby()
func _on_connection_failed() -> void:
if not connecting_overlay.visible:
return
_connect_timeout_token += 1
connecting_overlay.visible = false
_show_multiplayer_error("Connection failed — check the address and that a server is hosting on that port")
func _leave_to_lobby() -> void:
get_tree().change_scene_to_file("res://scenes/lobby.tscn")
func _show_multiplayer_error(message: String) -> void:
multiplayer_error_label.text = message
multiplayer_error_label.visible = true
func _clear_multiplayer_error() -> void:
multiplayer_error_label.visible = false
+255
View File
@@ -0,0 +1,255 @@
extends Node
# Autoload (project.godot [autoload] MatchNet). Handshake + roster layer on
# top of NetworkManager's raw transport (§2.5, §1.3 of multiplayer-todo.md).
# hello/welcome, strict protocol_version and physics_ticks_per_second
# gating, player_joined/player_left, and — since lobby.tscn (task 1.5) needs
# somewhere durable to keep it across the lobby→match scene transition —
# each player's team and ready state. Slot assignment (fixed spawn index
# within a team) is NOT here; that's match spawn's job in Phase 2, derived
# from this roster's team field at spawn time, not stored redundantly here.
const NetCodec = preload("res://scripts/net_codec.gd")
const SimConstants = preload("res://scripts/sim_constants.gd")
signal player_joined(peer_id: int, player_name: String)
signal player_left(peer_id: int)
signal player_state_changed(peer_id: int, team: int, ready: bool)
signal rejected(reason: String) # client-side only: the server refused our hello
signal welcomed() # client-side only: our hello was accepted
const TEAM_COUNT := 2
# player_name is the one client-supplied value in _hello that gets broadcast
# verbatim to every other peer (protocol_version/tick_hz are checked, never
# relayed). MAX_INPUT_LENGTH is a reject threshold, checked before touching
# the string at all — a legitimate client only ever sends local_player_name,
# which the UI already keeps short, so anything past this is a bug or an
# attacker, not a real name to truncate politely. Adversarial review found
# an unbounded name relayed to every peer head-of-line-blocks the reliable
# control channel hard enough that a concurrently-joining client's own
# _welcome never arrived — this is what closes that.
const MAX_INPUT_LENGTH := 256
const MAX_PLAYER_NAME_LENGTH := 24
class PlayerInfo:
var peer_id: int
var player_name: String
var team: int = 0
var ready: bool = false
func _init(p_peer_id: int, p_player_name: String, p_team: int = 0, p_ready: bool = false) -> void:
peer_id = p_peer_id
player_name = p_player_name
team = p_team
ready = p_ready
var roster: Dictionary = {} # peer_id (int) -> PlayerInfo. Never contains peer 1 (the server; §1.1 decision 2 — dedicated servers are never a player).
var local_player_name := "Player"
# Test hook (tests/match_net_smoke.gd): set false before connecting to
# suppress the automatic real hello, so a test can send a deliberately
# mismatched one instead to exercise the rejection path.
var _auto_hello := true
func _ready() -> void:
NetworkManager.client_disconnected.connect(_on_peer_disconnected)
NetworkManager.connected_to_server.connect(_on_connected_to_server)
NetworkManager.disconnected_from_server.connect(_on_disconnected_from_server)
NetworkManager.shutting_down.connect(_on_shutting_down)
func _on_connected_to_server() -> void:
roster.clear()
if _auto_hello:
_hello.rpc_id(1, NetCodec.PROTOCOL_VERSION, SimConstants.TICK_HZ, local_player_name)
func _on_disconnected_from_server() -> void:
roster.clear()
# Covers the case _on_disconnected_from_server doesn't: a HOST calling
# NetworkManager.shutdown() itself (Leave, or hosting again after already
# hosting) never fires disconnected_from_server — that signal only fires
# from an incoming multiplayer.server_disconnected event, which a server
# never receives about itself. Without this, roster (and every peer's team/
# ready state in it) would persist forever across a host/re-host cycle in
# the same process.
func _on_shutting_down() -> void:
roster.clear()
# Server only: a raw ENet disconnect (crash, timeout) that never sent a
# proper hello just needs its (possibly absent) roster entry cleaned up.
# The normal leave path also goes through here after the server erases it,
# guarded by roster.erase()'s own has-check below.
func _on_peer_disconnected(peer_id: int) -> void:
if not multiplayer.is_server():
return
_remove_player(peer_id)
func _remove_player(peer_id: int) -> void:
if not roster.has(peer_id):
return
roster.erase(peer_id)
player_left.emit(peer_id)
_player_left.rpc(peer_id)
# Balances a new joiner onto whichever team currently has fewer players
# (ties go to team 0). Server only.
func _pick_balanced_team() -> int:
var counts := []
counts.resize(TEAM_COUNT)
counts.fill(0)
for info: PlayerInfo in roster.values():
counts[info.team] += 1
var best_team := 0
for team in range(TEAM_COUNT):
if counts[team] < counts[best_team]:
best_team = team
return best_team
@rpc("any_peer", "call_remote", "reliable")
func _hello(protocol_version: int, tick_hz: int, player_name: String) -> void:
if not multiplayer.is_server():
return
var peer_id := multiplayer.get_remote_sender_id()
if roster.has(peer_id):
return # duplicate hello from an already-accepted peer; ignore
if protocol_version != NetCodec.PROTOCOL_VERSION:
await _reject(peer_id, "protocol version mismatch: server=%d client=%d" % [NetCodec.PROTOCOL_VERSION, protocol_version])
return
if tick_hz != SimConstants.TICK_HZ:
await _reject(peer_id, "physics tick rate mismatch: server=%d client=%d" % [SimConstants.TICK_HZ, tick_hz])
return
if player_name.length() > MAX_INPUT_LENGTH:
await _reject(peer_id, "player name too long")
return
var clean_name := _sanitize_player_name(player_name)
# Tell the new peer about everyone already here before anyone is told
# about them, so no client ever observes an unknown peer_id in a
# player_joined it didn't get a prior player_joined for.
for existing_id: int in roster.keys():
var existing: PlayerInfo = roster[existing_id]
_player_joined.rpc_id(peer_id, existing_id, existing.player_name, existing.team, existing.ready)
var team := _pick_balanced_team()
roster[peer_id] = PlayerInfo.new(peer_id, clean_name, team, false)
player_joined.emit(peer_id, clean_name) # local: the broadcast below is call_remote, never loops back to the server itself
_welcome.rpc_id(peer_id)
_player_joined.rpc(peer_id, clean_name, team, false) # broadcast, includes the new peer itself
# Strips control/formatting characters (so a name can't corrupt a log line
# or blow out UI layout with e.g. embedded newlines) and clamps to display
# length. Input is already bounded to MAX_INPUT_LENGTH by the caller before
# this runs, so this never iterates an attacker-sized string. static: pure
# function of its argument, doesn't touch roster/multiplayer — also lets
# tests/cases/test_match_net.gd call it with no Node instantiation.
static func _sanitize_player_name(raw: String) -> String:
var clean := ""
for c in raw:
var code := c.unicode_at(0)
if code >= 0x20 and code != 0x7F:
clean += c
clean = clean.strip_edges()
if clean.length() > MAX_PLAYER_NAME_LENGTH:
clean = clean.substr(0, MAX_PLAYER_NAME_LENGTH)
if clean.is_empty():
clean = "Player"
return clean
func _reject(peer_id: int, reason: String) -> void:
_rejected.rpc_id(peer_id, reason)
# §9 gotcha 26: a reliable RPC just queued still needs a beat of polling
# to actually reach the wire before we pull the connection out from
# under it.
await get_tree().create_timer(0.3).timeout
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
# Client-callable requests. Both are fire-and-forget: the authoritative
# change comes back through _state_changed once the server applies it, same
# as everyone else's — a client never mutates its own roster entry directly.
func request_set_team(team: int) -> void:
_set_team.rpc_id(1, team)
func request_set_ready(ready: bool) -> void:
_set_ready.rpc_id(1, ready)
@rpc("any_peer", "call_remote", "reliable")
func _set_team(team: int) -> void:
if not multiplayer.is_server():
return
var peer_id := multiplayer.get_remote_sender_id()
if not roster.has(peer_id) or team < 0 or team >= TEAM_COUNT:
return
var info: PlayerInfo = roster[peer_id]
if info.team == team:
return
info.team = team
info.ready = false # switching teams un-readies — the roster you were ready against just changed
player_state_changed.emit(peer_id, info.team, info.ready)
_state_changed.rpc(peer_id, info.team, info.ready)
@rpc("any_peer", "call_remote", "reliable")
func _set_ready(ready: bool) -> void:
if not multiplayer.is_server():
return
var peer_id := multiplayer.get_remote_sender_id()
if not roster.has(peer_id):
return
var info: PlayerInfo = roster[peer_id]
if info.ready == ready:
return
info.ready = ready
player_state_changed.emit(peer_id, info.team, info.ready)
_state_changed.rpc(peer_id, info.team, info.ready)
@rpc("authority", "call_remote", "reliable")
func _state_changed(peer_id: int, team: int, ready: bool) -> void:
if not roster.has(peer_id):
return
var info: PlayerInfo = roster[peer_id]
info.team = team
info.ready = ready
player_state_changed.emit(peer_id, team, ready)
@rpc("authority", "call_remote", "reliable")
func _welcome() -> void:
welcomed.emit()
@rpc("authority", "call_remote", "reliable")
func _rejected(reason: String) -> void:
rejected.emit(reason)
@rpc("authority", "call_remote", "reliable")
func _player_joined(peer_id: int, player_name: String, team: int, ready: bool) -> void:
roster[peer_id] = PlayerInfo.new(peer_id, player_name, team, ready)
player_joined.emit(peer_id, player_name)
@rpc("authority", "call_remote", "reliable")
func _player_left(peer_id: int) -> void:
if not roster.has(peer_id):
return
roster.erase(peer_id)
player_left.emit(peer_id)
+23
View File
@@ -0,0 +1,23 @@
extends RefCounted
# Plain data holder for one body's snapshot state (§2.4 of multiplayer-todo.md).
# Deliberately not Ship/Ball themselves, and deliberately not a scene-tree
# node — NetCodec's pack/unpack must stay callable from pure-function tests
# with no live scene. Phase 2's snapshot writer fills one of these per body
# per tick from the real RigidBody3D state; Phase 2's interpolator does the
# reverse.
#
# avel_range must match what the sender quantised with (SHIP_AVEL_RANGE vs
# BALL_AVEL_RANGE in net_codec.gd) — it is not carried on the wire, because
# slot order already tells both peers which body is which (§1.3: "entities
# are addressed by integer slot, never by path").
var position := Vector3.ZERO
var rotation := Quaternion.IDENTITY
var linear_velocity := Vector3.ZERO
var angular_velocity := Vector3.ZERO
var frozen := false
var turbo := false
var thrust_z := 0.0 # -1..1; re-quantised to a 3-bit bin on the wire
var stalled := false
var avel_range := 4.0 # NetCodec.SHIP_AVEL_RANGE; set to BALL_AVEL_RANGE for the ball
+295
View File
@@ -0,0 +1,295 @@
class_name NetCodec
# Wire-format constants, quantisers, and pack/unpack for the two hot-path
# packets (§2 of multiplayer-todo.md). Pure functions only — no networking,
# no autoload state — so they're testable head-on by tests/test_runner.tscn
# without a live connection.
#
# Referenced from elsewhere via preload(), not the bare class_name, per the
# same global-script-class-cache caveat documented in tests/test_case.gd and
# sim_constants.gd.
const SimConstants = preload("res://scripts/sim_constants.gd")
const ShipAction = preload("res://scripts/ship_action.gd")
const NetBodyState = preload("res://scripts/net_body_state.gd")
# --- Protocol ---
const PROTOCOL_VERSION := 1
const TICK_HZ: int = SimConstants.TICK_HZ
# --- Channels (logical intent; NetworkManager may need to offset these on
# top of ENet's own reserved channels — verify empirically, see §2.1) ---
const CHANNEL_CONTROL := 0
const CHANNEL_INPUT := 1
const CHANNEL_SNAPSHOT := 2
# --- Packet type/version byte: high nibble = type, low nibble = protocol version ---
enum PacketType { INPUT = 0, SNAPSHOT = 1 }
# --- Input packet (§2.3) ---
const MAX_REDUNDANCY := 4
# type_version u8 + seq u32 + count u8 + ack_snapshot_tick u32 + client_send_ms u16
const INPUT_HEADER_SIZE := 12
const INPUT_ENTRY_SIZE := 7 # thrust i8x3 + rotation i8x3 + flags u8
const INPUT_FLAG_TURBO := 1 << 0
# --- Snapshot packet (§2.4) ---
# last_input_seq u32 + input_buffer_depth i8 + echo_client_send_ms u16
const SNAPSHOT_CLIENT_HEADER_SIZE := 7
# type_version u8 + server_tick u32 + match_state u8 + reset_gen u8 + body_count u8
const SNAPSHOT_BODY_HEADER_SIZE := 8
const SNAPSHOT_BODY_SIZE := 22
const BODY_FLAG_FROZEN := 1 << 0
const BODY_FLAG_TURBO := 1 << 1
const BODY_FLAG_THRUST_Z_SHIFT := 2
const BODY_FLAG_THRUST_Z_MASK := 0x1C # bits 2-4
const BODY_FLAG_STALLED := 1 << 5
const BODY_FLAG_QUAT_W_SIGN := 1 << 6
# --- Quantisation ranges (§2.4 — derived from arena/gameplay constants, not
# restated prose; see multiplayer-todo.md for the ArenaBoundary/Ship/Ball
# constants these are sized against) ---
const POS_RANGE := 64.0 # metres, ±
const VEL_RANGE := 64.0 # m/s, ±
const QUAT_COMPONENT_RANGE := 1.0
const SHIP_AVEL_RANGE := 4.0 # rad/s, ±
const BALL_AVEL_RANGE := 32.0 # rad/s, ±
const I16_MAX := 32767
const I8_MAX := 127
const THRUST_Z_BIN_MAX := 7 # 3 bits
# ============================================================
# Quantisers — pure, reusable, independently testable.
# ============================================================
static func quantize_i16(value: float, range_max: float) -> int:
var scaled := clampf(value / range_max, -1.0, 1.0) * I16_MAX
return clampi(roundi(scaled), -I16_MAX, I16_MAX)
static func dequantize_i16(raw: int, range_max: float) -> float:
return (float(raw) / I16_MAX) * range_max
static func quantize_i8(value: float, range_max: float) -> int:
var scaled := clampf(value / range_max, -1.0, 1.0) * I8_MAX
return clampi(roundi(scaled), -I8_MAX, I8_MAX)
static func dequantize_i8(raw: int, range_max: float) -> float:
return (float(raw) / I8_MAX) * range_max
static func quantize_thrust_z_bin(thrust_z: float) -> int:
var t := clampf((thrust_z + 1.0) * 0.5, 0.0, 1.0)
return clampi(roundi(t * THRUST_Z_BIN_MAX), 0, THRUST_Z_BIN_MAX)
static func dequantize_thrust_z_bin(bin_value: int) -> float:
return (float(bin_value) / THRUST_Z_BIN_MAX) * 2.0 - 1.0
static func type_version_byte(type: PacketType) -> int:
return ((int(type) & 0x0F) << 4) | (PROTOCOL_VERSION & 0x0F)
static func packet_type_of(type_version: int) -> int:
return (type_version >> 4) & 0x0F
static func protocol_version_of(type_version: int) -> int:
return type_version & 0x0F
# ============================================================
# Input packet — client -> server, channel 1 (§2.3)
# ============================================================
# actions: newest-first, 1..MAX_REDUNDANCY ShipAction instances.
static func pack_input(seq: int, ack_snapshot_tick: int, client_send_ms: int, actions: Array) -> PackedByteArray:
var count: int = clampi(actions.size(), 1, MAX_REDUNDANCY)
var buf := StreamPeerBuffer.new()
buf.put_u8(type_version_byte(PacketType.INPUT))
buf.put_u32(seq)
buf.put_u8(count)
buf.put_u32(ack_snapshot_tick)
buf.put_u16(client_send_ms & 0xFFFF)
for i in count:
var action: ShipAction = actions[i]
buf.put_8(quantize_i8(action.thrust.x, 1.0))
buf.put_8(quantize_i8(action.thrust.y, 1.0))
buf.put_8(quantize_i8(action.thrust.z, 1.0))
buf.put_8(quantize_i8(action.rotation.x, 1.0))
buf.put_8(quantize_i8(action.rotation.y, 1.0))
buf.put_8(quantize_i8(action.rotation.z, 1.0))
var flags := 0
if action.turbo:
flags |= INPUT_FLAG_TURBO
buf.put_u8(flags)
return buf.data_array
# Returns a Dictionary: type_version, seq, count, ack_snapshot_tick,
# client_send_ms, actions (Array[ShipAction], newest first).
static func unpack_input(bytes: PackedByteArray) -> Dictionary:
var buf := StreamPeerBuffer.new()
buf.data_array = bytes
var type_version := buf.get_u8()
var seq := buf.get_u32()
var count := buf.get_u8()
var ack_snapshot_tick := buf.get_u32()
var client_send_ms := buf.get_u16()
var actions: Array[ShipAction] = []
for i in count:
var a := ShipAction.new()
a.thrust = Vector3(
dequantize_i8(buf.get_8(), 1.0),
dequantize_i8(buf.get_8(), 1.0),
dequantize_i8(buf.get_8(), 1.0)
)
a.rotation = Vector3(
dequantize_i8(buf.get_8(), 1.0),
dequantize_i8(buf.get_8(), 1.0),
dequantize_i8(buf.get_8(), 1.0)
)
var flags := buf.get_u8()
a.turbo = (flags & INPUT_FLAG_TURBO) != 0
actions.append(a)
return {
"type_version": type_version,
"seq": seq,
"count": count,
"ack_snapshot_tick": ack_snapshot_tick,
"client_send_ms": client_send_ms,
"actions": actions,
}
# ============================================================
# Snapshot packet — server -> client, channel 2 (§2.4)
# ============================================================
# Shared across every peer this tick — build once, reuse (§2.4's stated
# intent). Returns type_version + server_tick + match_state + reset_gen +
# body_count + body_count * SNAPSHOT_BODY_SIZE bytes.
static func pack_snapshot_body_segment(server_tick: int, match_state: int, reset_gen: int, bodies: Array) -> PackedByteArray:
var buf := StreamPeerBuffer.new()
buf.put_u8(type_version_byte(PacketType.SNAPSHOT))
buf.put_u32(server_tick)
buf.put_u8(match_state & 0xFF)
buf.put_u8(reset_gen & 0xFF)
buf.put_u8(bodies.size())
for body in bodies:
var b: NetBodyState = body
buf.put_16(quantize_i16(b.position.x, POS_RANGE))
buf.put_16(quantize_i16(b.position.y, POS_RANGE))
buf.put_16(quantize_i16(b.position.z, POS_RANGE))
buf.put_16(quantize_i16(b.rotation.x, QUAT_COMPONENT_RANGE))
buf.put_16(quantize_i16(b.rotation.y, QUAT_COMPONENT_RANGE))
buf.put_16(quantize_i16(b.rotation.z, QUAT_COMPONENT_RANGE))
buf.put_16(quantize_i16(b.linear_velocity.x, VEL_RANGE))
buf.put_16(quantize_i16(b.linear_velocity.y, VEL_RANGE))
buf.put_16(quantize_i16(b.linear_velocity.z, VEL_RANGE))
buf.put_8(quantize_i8(b.angular_velocity.x, b.avel_range))
buf.put_8(quantize_i8(b.angular_velocity.y, b.avel_range))
buf.put_8(quantize_i8(b.angular_velocity.z, b.avel_range))
var flags := 0
if b.frozen:
flags |= BODY_FLAG_FROZEN
if b.turbo:
flags |= BODY_FLAG_TURBO
flags |= (quantize_thrust_z_bin(b.thrust_z) << BODY_FLAG_THRUST_Z_SHIFT) & BODY_FLAG_THRUST_Z_MASK
if b.stalled:
flags |= BODY_FLAG_STALLED
if b.rotation.w < 0.0:
flags |= BODY_FLAG_QUAT_W_SIGN
buf.put_u8(flags)
return buf.data_array
static func pack_snapshot_client_header(last_input_seq: int, input_buffer_depth: int, echo_client_send_ms: int) -> PackedByteArray:
var buf := StreamPeerBuffer.new()
buf.put_u32(last_input_seq)
buf.put_8(clampi(input_buffer_depth, -128, 127))
buf.put_u16(echo_client_send_ms & 0xFFFF)
return buf.data_array
# Convenience: one full per-client packet = per-client header + shared body segment.
static func pack_snapshot(last_input_seq: int, input_buffer_depth: int, echo_client_send_ms: int, body_segment: PackedByteArray) -> PackedByteArray:
var header := pack_snapshot_client_header(last_input_seq, input_buffer_depth, echo_client_send_ms)
var out := PackedByteArray()
out.append_array(header)
out.append_array(body_segment)
return out
# Returns a Dictionary: last_input_seq, input_buffer_depth, echo_client_send_ms,
# type_version, server_tick, match_state, reset_gen, bodies (Array[NetBodyState]).
static func unpack_snapshot(bytes: PackedByteArray) -> Dictionary:
var buf := StreamPeerBuffer.new()
buf.data_array = bytes
var last_input_seq := buf.get_u32()
var input_buffer_depth := buf.get_8()
var echo_client_send_ms := buf.get_u16()
var type_version := buf.get_u8()
var server_tick := buf.get_u32()
var match_state := buf.get_u8()
var reset_gen := buf.get_u8()
var body_count := buf.get_u8()
var bodies: Array[NetBodyState] = []
for i in body_count:
var b := NetBodyState.new()
b.position = Vector3(
dequantize_i16(buf.get_16(), POS_RANGE),
dequantize_i16(buf.get_16(), POS_RANGE),
dequantize_i16(buf.get_16(), POS_RANGE)
)
var qx := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
var qy := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
var qz := dequantize_i16(buf.get_16(), QUAT_COMPONENT_RANGE)
b.linear_velocity = Vector3(
dequantize_i16(buf.get_16(), VEL_RANGE),
dequantize_i16(buf.get_16(), VEL_RANGE),
dequantize_i16(buf.get_16(), VEL_RANGE)
)
# avel_range is unknown to the codec at this point (it isn't on the
# wire — see net_body_state.gd) — decode at SHIP_AVEL_RANGE and let
# the caller, which knows this slot's body kind, rescale if it's the
# ball's slot. Storing the raw i8 would avoid this, but every other
# field in this struct is already physical units; consistency wins.
b.angular_velocity = Vector3(
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE),
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE),
dequantize_i8(buf.get_8(), SHIP_AVEL_RANGE)
)
var flags := buf.get_u8()
b.frozen = (flags & BODY_FLAG_FROZEN) != 0
b.turbo = (flags & BODY_FLAG_TURBO) != 0
var bin_value := (flags & BODY_FLAG_THRUST_Z_MASK) >> BODY_FLAG_THRUST_Z_SHIFT
b.thrust_z = dequantize_thrust_z_bin(bin_value)
b.stalled = (flags & BODY_FLAG_STALLED) != 0
var w_sq := 1.0 - qx * qx - qy * qy - qz * qz
var w := sqrt(maxf(w_sq, 0.0))
if (flags & BODY_FLAG_QUAT_W_SIGN) != 0:
w = -w
b.rotation = Quaternion(qx, qy, qz, w)
bodies.append(b)
return {
"last_input_seq": last_input_seq,
"input_buffer_depth": input_buffer_depth,
"echo_client_send_ms": echo_client_send_ms,
"type_version": type_version,
"server_tick": server_tick,
"match_state": match_state,
"reset_gen": reset_gen,
"bodies": bodies,
}
# Rescales an already-decoded body's angular_velocity from the SHIP_AVEL_RANGE
# assumption unpack_snapshot() decoded it with to the range it was actually
# quantised at (BALL_AVEL_RANGE for the ball). Call once per non-ship body
# immediately after unpack_snapshot(), using slot order to know which.
static func rescale_avel(body: NetBodyState, actual_range: float) -> void:
if is_equal_approx(actual_range, SHIP_AVEL_RANGE):
body.avel_range = actual_range
return
body.angular_velocity = (body.angular_velocity / SHIP_AVEL_RANGE) * actual_range
body.avel_range = actual_range
+43
View File
@@ -0,0 +1,43 @@
extends CanvasLayer
# Autoload: toggleable network debug overlay (F4 by default — see
# toggle_net_overlay in project.godot's [input]). Read-only against
# NetworkManager's clock state (task 1.8). Mirrors perf_overlay.gd's pattern
# — headless-guarded, hidden by default, no gameplay-state writes.
var _label: Label
func _ready() -> void:
if DisplayServer.get_name() == "headless":
set_process(false)
return
layer = 100
_label = Label.new()
_label.add_theme_font_size_override("font_size", 14)
_label.add_theme_color_override("font_color", Color(0.5, 0.8, 1.0))
_label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.85))
_label.add_theme_constant_override("shadow_offset_x", 1)
_label.add_theme_constant_override("shadow_offset_y", 1)
_label.position = Vector2(12, 90)
_label.visible = false
add_child(_label)
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("toggle_net_overlay") and _label:
_label.visible = not _label.visible
func _process(_delta: float) -> void:
if not _label or not _label.visible:
return
if NetworkManager.is_server:
_label.text = "NET: server, %d peer(s)" % (MatchNet.roster.size())
elif NetworkManager.is_client:
if NetworkManager.rtt_ms < 0.0:
_label.text = "NET: client, connecting (no clock sample yet)"
else:
_label.text = "NET: client RTT %.1fms clock offset %.1fms" % [NetworkManager.rtt_ms, NetworkManager.clock_offset_ms]
else:
_label.text = "NET: offline"
+210
View File
@@ -0,0 +1,210 @@
extends Node
# Autoload (project.godot [autoload] NetworkManager). Owns the ENet
# transport: hosting, joining, shutdown, and connection-state signals. Lives
# at a fixed autoload path so RPC NodePaths never depend on which scene is
# loaded (§1.3 of multiplayer-todo.md's derived decisions).
#
# server_relay = false is set the moment a peer exists: the default `true`
# lets any client rpc() any other client *through the server*, which this
# project's server-authoritative model must never allow — §2.1 calls this
# out as the single highest-value one-line security change in the document.
#
# IMPORTANT, learned the hard way (tests/net_smoke.gd): don't call
# shutdown()/close the peer the instant connected_to_server or peer_connected
# fires. ENet's connect handshake isn't fully settled on the *other* side the
# moment your own side's signal fires — the final ACK still needs a couple
# more poll() cycles to actually reach the wire. Closing immediately drops
# it and leaves the other side's handshake permanently incomplete (it will
# never see peer_connected/connected_to_server at all). Callers that shut
# down right after a fresh connection should let a frame or two pass first.
#
# Manual polling (task 1.3): SceneTree's automatic multiplayer poll runs on
# the *idle* frame, so an rpc() issued from _physics_process waits up to a
# full frame before it's actually pushed onto the wire — and the return leg
# pays the same tax again. set_multiplayer_poll_enabled(false) below turns
# that off; every caller that sends or expects to receive on a tight cadence
# must now call NetworkManager.poll() itself. The intended placement per
# multiplayer-todo.md §7 task 1.3 (client: end of _physics_process after
# sending input, plus top of both _process and _physics_process for receive;
# server: tick start to drain, tick end to flush) has no real per-tick caller
# yet — that lands with the input/snapshot pipeline (tasks 1.4+, Phase 2-3).
# Until then, anything driving a connection (tests/net_smoke.gd included)
# must poll() every frame itself or nothing will ever be sent or received.
signal client_connected(peer_id: int)
signal client_disconnected(peer_id: int)
signal connected_to_server()
signal connection_failed()
signal disconnected_from_server()
signal clock_updated(rtt_ms: float, offset_ms: float)
# Fires at the top of every shutdown() call, whether this process was
# hosting, joined, or already offline, and regardless of *why* (deliberate
# Leave/Cancel, or an incoming disconnect from the other side). Adversarial
# review found MatchNet.roster had no path that cleared it when a HOST
# stopped hosting — connected_to_server/disconnected_from_server only cover
# the client side — so a host -> lobby -> leave -> host-again cycle left a
# permanent phantom player. Listeners that need per-role cleanup should
# still use the more specific signals above; this one exists so "something
# is about to reset the connection, drop anything you were keeping" has
# exactly one place to hook regardless of role.
signal shutting_down()
const DEFAULT_PORT := 7777
const MAX_CLIENTS := 32
# Clock (task 1.8, §4.7): client pings the server once a second on the
# reliable control channel; clock_offset_ms is the min-RTT sample in a
# rolling window, because the lowest-RTT sample has the least queueing
# error. get_server_time_estimate_ms() is the thing every later phase
# (interpolation delay, tick_offset seeding) actually wants — everything
# else here exists to produce it.
const PING_INTERVAL_SEC := 1.0
const CLOCK_WINDOW_SEC := 5.0
var is_server := false
var is_client := false
var _peer: ENetMultiplayerPeer # keep a strong ref alongside multiplayer.multiplayer_peer
var rtt_ms := -1.0 # min-RTT sample currently in the window; -1 = no sample yet
var clock_offset_ms := 0.0 # add to a local Time.get_ticks_msec() reading to estimate the server's clock
var _clock_samples: Array[Dictionary] = []
var _ping_accum_sec := 0.0
func _ready() -> void:
get_tree().set_multiplayer_poll_enabled(false)
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
multiplayer.connected_to_server.connect(_on_connected_to_server)
multiplayer.connection_failed.connect(_on_connection_failed)
multiplayer.server_disconnected.connect(_on_server_disconnected)
func _process(delta: float) -> void:
# is_client turns true the instant join() is called, before the ENet
# handshake actually completes (or fails) — a slow or refused connect
# attempt would otherwise leave this trying to rpc_id() on a peer
# that's still CONNECTING (or already failed), which Godot logs as
# "Trying to call an RPC via a multiplayer peer which is not
# connected." every single frame. Require the real transport state.
if not is_client or _peer == null or _peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
return
_ping_accum_sec += delta
if _ping_accum_sec >= PING_INTERVAL_SEC:
_ping_accum_sec = 0.0
_ping.rpc_id(1, Time.get_ticks_msec())
# Estimate of what the server's Time.get_ticks_msec() reads right now.
# Meaningless before the first pong lands (clock_offset_ms is 0.0 until then
# — callers needing round-trip-confirmed freshness should check rtt_ms >= 0).
func get_server_time_estimate_ms() -> float:
return float(Time.get_ticks_msec()) + clock_offset_ms
# The single entry point every per-tick caller uses instead of relying on
# SceneTree's (now disabled) automatic poll. Safe to call with no peer set —
# polling the default OfflineMultiplayerPeer is a no-op.
func poll() -> void:
multiplayer.poll()
func host(port: int = DEFAULT_PORT, max_clients: int = MAX_CLIENTS) -> Error:
shutdown()
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(port, max_clients)
if err != OK:
push_error("NetworkManager.host: create_server failed (%s)" % error_string(err))
return err
_peer = peer
multiplayer.multiplayer_peer = peer
multiplayer.server_relay = false
is_server = true
is_client = false
return OK
func join(address: String, port: int = DEFAULT_PORT) -> Error:
shutdown()
var peer := ENetMultiplayerPeer.new()
var err := peer.create_client(address, port)
if err != OK:
push_error("NetworkManager.join: create_client failed (%s)" % error_string(err))
return err
_peer = peer
multiplayer.multiplayer_peer = peer
multiplayer.server_relay = false
is_server = false
is_client = true
return OK
func shutdown() -> void:
shutting_down.emit()
# MultiplayerAPI's default multiplayer_peer is an OfflineMultiplayerPeer
# sentinel, never null — closing that sentinel is a no-op, but assigning
# multiplayer_peer = null (rather than a fresh OfflineMultiplayerPeer)
# leaves the API in a state distinct from its own default, which is a
# known source of confusing follow-on bugs (godotengine/godot#81540).
# Always reset to a real OfflineMultiplayerPeer, never raw null.
var peer := multiplayer.multiplayer_peer
if peer != null and not (peer is OfflineMultiplayerPeer):
peer.close()
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
_peer = null
is_server = false
is_client = false
rtt_ms = -1.0
clock_offset_ms = 0.0
_clock_samples.clear()
_ping_accum_sec = 0.0
@rpc("any_peer", "call_remote", "reliable")
func _ping(client_send_ms: int) -> void:
if not multiplayer.is_server():
return
_pong.rpc_id(multiplayer.get_remote_sender_id(), client_send_ms, Time.get_ticks_msec())
@rpc("authority", "call_remote", "reliable")
func _pong(client_send_ms: int, server_now_ms: int) -> void:
var now_ms := Time.get_ticks_msec()
var sample_rtt := float(now_ms - client_send_ms)
var sample_offset := float(server_now_ms) + sample_rtt / 2.0 - float(now_ms)
_clock_samples.append({"t": now_ms, "rtt": sample_rtt, "offset": sample_offset})
var cutoff := now_ms - int(CLOCK_WINDOW_SEC * 1000.0)
_clock_samples = _clock_samples.filter(func(s: Dictionary) -> bool: return s["t"] >= cutoff)
var best: Dictionary = _clock_samples[0]
for sample: Dictionary in _clock_samples:
if sample["rtt"] < best["rtt"]:
best = sample
rtt_ms = best["rtt"]
clock_offset_ms = best["offset"]
clock_updated.emit(rtt_ms, clock_offset_ms)
func _on_peer_connected(peer_id: int) -> void:
client_connected.emit(peer_id)
func _on_peer_disconnected(peer_id: int) -> void:
client_disconnected.emit(peer_id)
func _on_connected_to_server() -> void:
connected_to_server.emit()
func _on_connection_failed() -> void:
is_client = false
connection_failed.emit()
func _on_server_disconnected() -> void:
is_server = false
is_client = false
disconnected_from_server.emit()
+98
View File
@@ -0,0 +1,98 @@
extends Node
# Headless dedicated server entry point (task 1.6). Parses CLI args, hosts
# via NetworkManager, logs structured lines, and watches for physics-tick
# overrun (§9 gotcha 9: Engine.max_physics_steps_per_frame defaults to 8;
# a tick overrunning 16.7ms backs up the accumulator and the next frame
# runs multiple ticks, spiking CPU further — worth logging, not just
# silently absorbing).
#
# Run: godot --headless --path Game res://scenes/server_boot.tscn -- --port=7777
#
# Deliberately does not spawn a match yet — that's Phase 2's networked_match
# scene. This is just the process shell: listen, log, idle cheaply.
const LOG_LEVELS := {"debug": 0, "info": 1, "warn": 2, "error": 3}
var _boot_ms := 0
var _last_physics_frame := 0
var _log_level := 1 # info
var _watchdog_armed := false # skip the first _process(): engine startup scheduling can batch several physics frames before the first idle frame runs, which isn't a real overrun
func _ready() -> void:
_boot_ms = Time.get_ticks_msec()
Engine.max_fps = 60 # a server never renders; this just caps the idle-frame poll rate so it doesn't spin
var port := NetworkManager.DEFAULT_PORT
var max_clients := NetworkManager.MAX_CLIENTS
for arg in OS.get_cmdline_user_args():
if arg.begins_with("--port="):
port = int(arg.substr("--port=".length()))
elif arg.begins_with("--max-clients="):
max_clients = int(arg.substr("--max-clients=".length()))
elif arg.begins_with("--log-level="):
var level_name := arg.substr("--log-level=".length())
if LOG_LEVELS.has(level_name):
_log_level = LOG_LEVELS[level_name]
else:
_log("error", "bad_log_level", {"given": level_name, "valid": LOG_LEVELS.keys()})
get_tree().quit(1)
return
NetworkManager.client_connected.connect(_on_client_connected)
NetworkManager.client_disconnected.connect(_on_client_disconnected)
MatchNet.player_joined.connect(_on_player_joined)
MatchNet.player_left.connect(_on_player_left)
var err := NetworkManager.host(port, max_clients)
if err != OK:
_log("error", "server_boot_failed", {"port": port, "error": error_string(err)})
get_tree().quit(1)
return
_log("info", "server_started", {"port": port, "max_clients": max_clients})
_last_physics_frame = Engine.get_physics_frames()
func _process(_delta: float) -> void:
NetworkManager.poll()
var current := Engine.get_physics_frames()
var steps := current - _last_physics_frame
_last_physics_frame = current
# §9 gotcha 6: with physics_jitter_fix = 0.0, frames legitimately
# alternate between 0 and 2 ticks even on an idle, healthy server —
# that's expected quantisation, not backlog. A real overrun is the
# accumulator failing to drain back down, i.e. 3+ ticks in one frame.
if steps > 2 and _watchdog_armed:
_log("warn", "physics_overrun", {"steps": steps})
_watchdog_armed = true
func _physics_process(_delta: float) -> void:
NetworkManager.poll()
func _on_client_connected(peer_id: int) -> void:
_log("debug", "peer_connected", {"peer_id": peer_id})
func _on_client_disconnected(peer_id: int) -> void:
_log("debug", "peer_disconnected", {"peer_id": peer_id})
func _on_player_joined(peer_id: int, player_name: String) -> void:
_log("info", "player_joined", {"peer_id": peer_id, "name": player_name})
func _on_player_left(peer_id: int) -> void:
_log("info", "player_left", {"peer_id": peer_id})
func _log(level: String, event: String, fields: Dictionary) -> void:
if LOG_LEVELS.get(level, 1) < _log_level:
return
var parts := PackedStringArray()
for key in fields:
parts.append("%s=%s" % [key, str(fields[key])])
var elapsed_sec := (Time.get_ticks_msec() - _boot_ms) / 1000.0
print("[%.3f] %s %s %s" % [elapsed_sec, level.to_upper(), event, " ".join(parts)])