Files
Josh Creek b43ad207c1 docs(multiplayer): split spec into MULTIPLAYER_SPEC.md, trim task doc to outstanding work
multiplayer-next.md was a 1662-line mix of standing architecture spec
and task-completion tracking, most of which was dense per-task DONE
evidence for finished Phases 0-6. Split it:

- MULTIPLAYER_SPEC.md (new): the locked architecture decisions, wire
  format, server-side input handling, prediction/reconciliation,
  latency/frame-rate budget, and match lifecycle state machine -
  standing design reference, not task-tracked.
- multiplayer-next.md (trimmed 1662 -> ~370 lines): only outstanding
  work remains - §0 status, §7 Phase 7/8 task tables condensed to
  "what's left" per task, §8-11 reference material (refactoring notes,
  gotchas, testing, flagged items). Phases 0-6 collapsed to a pointer
  at git history instead of ~500 lines of DONE evidence.

Also:
- Repointed every `multiplayer-next.md §N` code comment (N 1-6) across
  Game/scripts, Game/tools and Game/tests to MULTIPLAYER_SPEC.md, since
  those sections moved. Task-number references (`task N.N`, §7-11)
  correctly still point at multiplayer-next.md.
- Updated CLAUDE.md's doc index and docs/TECH_STACK.md's spec-section
  citations to match.
- TODO.md: added a "what's left to actually finish multiplayer
  (human-actionable)" checklist pulled from multiplayer-next.md §0 and
  docs/MATCHMAKING.md - things that need a person (hardware, a design
  decision, a Steam App ID, hands on a controller), not more agent code.
2026-09-04 22:43:13 +01:00

305 lines
13 KiB
GDScript

extends Node
# Autoload (project.godot [autoload] NetworkManager). Owns transport-neutral
# 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_SPEC.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-next.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
const TRANSPORT_ENET := "enet"
const TRANSPORT_STEAM := "steam"
const EnetTransportScript = preload("res://scripts/enet_transport.gd")
const SteamTransportScript = preload("res://scripts/steam_transport.gd")
# 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: MultiplayerPeer # keep a strong ref alongside multiplayer.multiplayer_peer
var active_transport := ""
var _invalidated_peer_ids: Dictionary = {}
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
# Jitter (task 3.7's debug overlay): RFC3550-style EWMA of the deviation
# between consecutive RAW (not min-filtered) RTT samples — rtt_ms itself is
# a min-RTT, deliberately insensitive to jitter by design (§4.7), so a
# separate, unfiltered running estimate is needed to actually see it.
const JITTER_EWMA_ALPHA := 1.0 / 16.0 # matches RFC3550's own smoothing factor
var jitter_ms := 0.0
var _last_raw_rtt_ms := -1.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
# Capture the timestamp now, before NetSim (task 2.8) can add any
# simulated delay — see net_sim.gd's header comment for why.
var send_ms := Time.get_ticks_msec()
NetSim.send(func() -> void: _ping.rpc_id(1, send_ms), 1)
# 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()
# A peer can be removed from the transport while Godot is still draining the
# same poll batch. During that interval get_peers() may still contain it, but
# an RPC send already fails because ENet has torn down its channels.
func invalidate_peer(peer_id: int) -> void:
_invalidated_peer_ids[peer_id] = true
func can_send_to_peer(peer_id: int) -> bool:
if _invalidated_peer_ids.has(peer_id):
return false
if _peer == null or _peer is OfflineMultiplayerPeer:
return false
# A listening server's peer status is transport/version-specific; the
# authoritative server is valid as soon as it owns a peer and the target
# appears in get_peers(). Clients, however, must not dispatch while their
# connection is still handshaking.
if not is_server and _peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
return false
return peer_id in multiplayer.get_peers()
func available_transports() -> PackedStringArray:
var transports := PackedStringArray([TRANSPORT_ENET])
if SteamTransportScript.new().is_available():
transports.append(TRANSPORT_STEAM)
return transports
func host(port: int = DEFAULT_PORT, max_clients: int = MAX_CLIENTS, transport: String = TRANSPORT_ENET) -> Error:
shutdown()
var implementation := _make_transport(transport)
if implementation == null:
return ERR_INVALID_PARAMETER
var result: Dictionary = implementation.create_server(port, max_clients)
var err := int(result.error)
if err != OK:
push_error("NetworkManager.host(%s): create_server failed (%s): %s" % [transport, error_string(err), String(result.get("reason", ""))])
return err
_peer = result.peer as MultiplayerPeer
multiplayer.multiplayer_peer = _peer
multiplayer.server_relay = false
active_transport = transport
is_server = true
is_client = false
return OK
func join(address: String, port: int = DEFAULT_PORT, transport: String = TRANSPORT_ENET) -> Error:
shutdown()
var implementation := _make_transport(transport)
if implementation == null:
return ERR_INVALID_PARAMETER
var result: Dictionary = implementation.create_client(address, port)
var err := int(result.error)
if err != OK:
push_error("NetworkManager.join(%s): create_client failed (%s): %s" % [transport, error_string(err), String(result.get("reason", ""))])
return err
_peer = result.peer as MultiplayerPeer
multiplayer.multiplayer_peer = _peer
multiplayer.server_relay = false
active_transport = transport
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
_invalidated_peer_ids.clear()
active_transport = ""
is_server = false
is_client = false
rtt_ms = -1.0
clock_offset_ms = 0.0
_clock_samples.clear()
_ping_accum_sec = 0.0
jitter_ms = 0.0
_last_raw_rtt_ms = -1.0
func _make_transport(transport: String) -> NetTransport:
match transport:
TRANSPORT_ENET:
return EnetTransportScript.new()
TRANSPORT_STEAM:
return SteamTransportScript.new()
_:
push_error("NetworkManager: unknown transport '%s'" % transport)
return null
@rpc("any_peer", "call_remote", "reliable")
func _ping(client_send_ms: int) -> void:
if not multiplayer.is_server():
return
# Same rule as the client's send above: read the server's clock now, at
# true receipt time, before NetSim can delay the reply — otherwise the
# server's own outbound leg would be silently absorbed out of both the
# RTT sample and the offset estimate instead of adding to them.
var server_now := Time.get_ticks_msec()
var sender_id := multiplayer.get_remote_sender_id()
# A single poll() call can process several queued RPCs from the same
# peer in one batch — an earlier one in that same batch (e.g. task 3.4's
# abuse-triggered match_sim.gd disconnect_peer() call, or the peer
# disconnecting for any other reason mid-batch) can leave this ping's
# sender no longer a valid peer by the time its own turn in the batch
# comes up. Empirically confirmed reachable with disconnect_peer()'s
# default arguments (a graceful, non-forced disconnect — match_sim.gd's
# own disconnect call tried force=true as an alternative and reverted
# it, since that left Godot's own peer-list bookkeeping inconsistent
# and produced far MORE of this exact class of error, not fewer:
# hundreds vs. one, verified). NetSim's inactive/passthrough path (the
# common case — no CLI flags) dispatches immediately with no
# validation of its own, so check here rather than relying on it.
if sender_id not in multiplayer.get_peers():
return
NetSim.send(func() -> void: _pong.rpc_id(sender_id, client_send_ms, server_now), sender_id)
@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)
if _last_raw_rtt_ms >= 0.0:
var deviation := absf(sample_rtt - _last_raw_rtt_ms)
jitter_ms += (deviation - jitter_ms) * JITTER_EWMA_ALPHA
_last_raw_rtt_ms = sample_rtt
_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()