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