Files
CosmicClash/Game/scripts/server_boot.gd
T
Josh Creek 4533da34e0 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.
2026-08-20 08:18:59 +01:00

99 lines
3.7 KiB
GDScript

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)])