From fc2c0236cb8c2bd3d74659f843d3d0d27eda43a0 Mon Sep 17 00:00:00 2001 From: Shawn Date: Fri, 3 Jul 2026 14:47:00 -0400 Subject: [PATCH] Fix dedicated server: delay netfox tick loop until first client connects, keep full scene tree for identity sync --- .../multiplayer-fps/scripts/player-spawner.gd | 22 ++- scripts/headless_server.gd | 147 +++++++----------- tests/bootstrapper.gd.uid | 1 + tests/economy.gd.uid | 1 + tests/runner.gd.uid | 1 + tests/team.gd.uid | 1 + tests/weapons.gd.uid | 1 + 7 files changed, 85 insertions(+), 89 deletions(-) create mode 100644 tests/bootstrapper.gd.uid create mode 100644 tests/economy.gd.uid create mode 100644 tests/runner.gd.uid create mode 100644 tests/team.gd.uid create mode 100644 tests/weapons.gd.uid diff --git a/examples/multiplayer-fps/scripts/player-spawner.gd b/examples/multiplayer-fps/scripts/player-spawner.gd index d247d6c..0e6d08b 100644 --- a/examples/multiplayer-fps/scripts/player-spawner.gd +++ b/examples/multiplayer-fps/scripts/player-spawner.gd @@ -6,6 +6,17 @@ extends Node ## Set to false for dedicated server operation. @export var spawn_host_avatar: bool = true +## When true, this is a dedicated server. Peer 1 (the server itself) never +## gets a player avatar. Clients also skip spawning peer 1 locally. +var _dedicated_server: bool = false + +## Mark this spawner as running on a dedicated server. +## The host avatar (peer 1) is skipped entirely — no avatar is spawned for the +## server process itself. +func set_dedicated_server() -> void: + _dedicated_server = true + print("[PlayerSpawner] Dedicated server mode enabled") + var t_spawn_points: Array[Marker3D] = [] var ct_spawn_points: Array[Marker3D] = [] var team_manager: TeamManager @@ -47,8 +58,12 @@ func _handle_connected(id: int): _spawn(id) func _handle_host(): + if _dedicated_server: + print("[PlayerSpawner] Dedicated server — skipping host avatar entirely") + # Match starts when the first client connects (handled by headless_server) + return if not spawn_host_avatar: - print("[PlayerSpawner] Dedicated server mode — skipping host avatar") + print("[PlayerSpawner] spawn_host_avatar=false — skipping host avatar") if round_manager: round_manager.start_match() return @@ -58,8 +73,11 @@ func _handle_host(): round_manager.start_match() func _handle_new_peer(id: int): + if _dedicated_server and id == 1: + # Peer 1 is the server itself — no avatar needed + return if id == 1 and not spawn_host_avatar: - print("[PlayerSpawner] Skipping spawn for peer 1 (dedicated server)") + print("[PlayerSpawner] Skipping spawn for peer 1 (spawn_host_avatar=false)") return _spawn(id) if multiplayer.is_server(): diff --git a/scripts/headless_server.gd b/scripts/headless_server.gd index fee8c8c..b476968 100644 --- a/scripts/headless_server.gd +++ b/scripts/headless_server.gd @@ -3,20 +3,17 @@ ## Loads the multiplayer-fps game scene DIRECTLY under /root/ (as a sibling, ## not a child), so RPC node paths match what clients expect. ## -## Usage: -## godot --headless --scene res://scenes/headless_server.tscn -## godot --headless --scene res://scenes/headless_server.tscn -- --port 34197 -## ## Architecture: ## This script runs as a temporary scene root. It loads multiplayer-fps.tscn ## and adds it directly under get_tree().root so node paths (e.g. ## /root/multiplayer-fps/Network/TeamManager) are identical on both ## server and client. ## -## 1. Loads multiplayer-fps.tscn, adds to /root/ -## 2. Removes Environment and UI -## 3. Starts ENet server -## 4. RCON admin console on configurable port +## Critical: NetworkEvents.on_server_start is DISCONNECTED from +## NetworkTime.start(). The netfox tick loop only begins AFTER the first +## real client connects (on_peer_join). This ensures the rollback system +## always has at least one player subject before ticking starts, preventing +## PropertyPool/PerObjectHistory typed array corruption (Godot 4.7). extends Node @@ -26,6 +23,9 @@ const DEFAULT_PORT: int = 34197 ## Reference to the multiplayer-fps game scene (at root level). var _game_scene: Node = null +## Whether a client has connected (deferred tick start). +var _first_client_connected: bool = false + # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- @@ -45,24 +45,22 @@ func _ready() -> void: _game_scene = GameScene.instantiate() - # CRITICAL: Add game scene directly to /root/ as a sibling, NOT as child. - # This ensures RPC node paths are the same on both server and client. - # Must use call_deferred because we're still in _ready(). - # Strip BEFORE adding to tree so queue_free works cleanly. - _strip_for_headless() + # Disconnect NetworkEvents.on_server_start from NetworkTime.start. + # The netfox tick loop must NOT start until the first client connects, + # so the rollback system has at least one player subject. + if NetworkEvents and NetworkEvents.on_server_start.is_connected(NetworkTime.start): + NetworkEvents.on_server_start.disconnect(NetworkTime.start) + print("[HeadlessServer] Disconnected NetworkTime.start from on_server_start") + + # Keep full scene tree — no stripping. Environment/UI nodes are harmless + # in headless mode. Keeping the tree identical to the client ensures + # NetworkIdentityServer produces matching identity references. + _verify_scene() get_tree().root.call_deferred("add_child", _game_scene) # Start RCON admin console _start_rcon() - # Set dedicated server flag on the player spawner to prevent - # spawning a host avatar (peer 1) that would cause rollback errors. - # Must be set before the ENet server starts (before on_server_start fires). - var spawner = _game_scene.get_node_or_null("Network/Player Spawner") - if spawner: - spawner.spawn_host_avatar = false - print("[HeadlessServer] Set spawn_host_avatar=false on PlayerSpawner") - # Wait for game scene to enter the tree await get_tree().process_frame await get_tree().process_frame @@ -71,34 +69,27 @@ func _ready() -> void: get_tree().current_scene = _game_scene print("[HeadlessServer] Game scene added to root, current_scene updated") + # Set dedicated server flag BEFORE starting ENet, so _handle_host() + # runs in the correct mode. + var spawner = _game_scene.get_node_or_null("Network/Player Spawner") + if spawner and spawner.has_method("set_dedicated_server"): + spawner.set_dedicated_server() + print("[HeadlessServer] PlayerSpawner set to dedicated server mode") + + # Connect first-client handler BEFORE starting the server, so we + # don't miss the on_peer_join event for the very first client. + NetworkEvents.on_peer_join.connect(_on_first_peer_join) + # Start the ENet server _start_server(port) func _exit_tree() -> void: _stop_server() -# --------------------------------------------------------------------------- -# Strip scene for headless mode -# --------------------------------------------------------------------------- - -## Remove nodes that are useless in headless mode: rendering, UI, audio. -func _strip_for_headless() -> void: +func _verify_scene() -> void: if _game_scene == null: return - # Remove Environment (sky, light, camera, world environment) - var env = _game_scene.get_node_or_null("Environment") - if env: - env.queue_free() - print("[HeadlessServer] Removed Environment node") - - # Remove UI (network popup, HUD, crosshair, etc.) - var ui = _game_scene.get_node_or_null("UI") - if ui: - ui.queue_free() - print("[HeadlessServer] Removed UI node") - - # Verify critical game logic nodes exist var network = _game_scene.get_node_or_null("Network") if network == null: push_error("[HeadlessServer] Missing Network node in game scene!") @@ -108,7 +99,32 @@ func _strip_for_headless() -> void: if network.get_node_or_null(required) == null: push_warning("[HeadlessServer] Missing Network/%s — some features disabled" % required) - print("[HeadlessServer] Scene stripped for headless mode") + print("[HeadlessServer] Scene tree verified, keeping full tree for identity sync") + +# --------------------------------------------------------------------------- +# First peer handling — deferred tick and match start +# --------------------------------------------------------------------------- + +func _on_first_peer_join(_peer_id: int) -> void: + if _first_client_connected: + return + _first_client_connected = true + + print("[HeadlessServer] First client connected — starting netfox tick loop...") + + # Start netfox tick loop now that we have at least one player + if NetworkTime and NetworkTime.has_method("start"): + var err = await NetworkTime.start() + if err != OK: + push_error("[HeadlessServer] NetworkTime.start() returned error: %d" % err) + + # Start the match via the round manager + var network = _game_scene.get_node_or_null("Network") if _game_scene else null + if network: + var round_mgr = network.get_node_or_null("RoundManager") + if round_mgr and round_mgr.has_method("start_match"): + round_mgr.start_match() + print("[HeadlessServer] RoundManager.start_match() called on first join") # --------------------------------------------------------------------------- # Server startup @@ -128,15 +144,10 @@ func _start_server(port: int) -> void: print("[HeadlessServer] ENet server started on port %d" % port) - # Wait for NetworkEvents to fire on_server_start + # Let NetworkEvents fire on_server_start (without starting NetworkTime) await get_tree().process_frame - - # NetworkEvents handles on_server_start -> player-spawner -> round_manager - if NetworkEvents and NetworkEvents.enabled: - print("[HeadlessServer] NetworkEvents active — waiting for on_server_start") - else: - print("[HeadlessServer] NetworkEvents not available — manually bootstrapping") - _manual_startup() + print("[HeadlessServer] NetworkEvents on_server_start should have fired (NetworkTime not started yet)") + # No host avatar — no RPC errors, no client-side duplicate func _stop_server() -> void: var mp = get_tree().get_multiplayer() @@ -145,44 +156,6 @@ func _stop_server() -> void: mp.multiplayer_peer = null print("[HeadlessServer] Server stopped") -## Fallback startup when NetworkEvents isn't available. -func _manual_startup() -> void: - if Engine.has_singleton("NetworkTime"): - var nt = Engine.get_singleton("NetworkTime") - if nt and nt.has_method("start"): - nt.start() - print("[HeadlessServer] NetworkTime started manually") - - var network = _game_scene.get_node_or_null("Network") if _game_scene else null - if network == null: - return - - var spawner = network.get_node_or_null("Player Spawner") - if spawner: - if spawner.has_method("_handle_host"): - spawner._handle_host() - print("[HeadlessServer] PlayerSpawner._handle_host() called") - - if Engine.has_singleton("NetworkEvents"): - var ne = Engine.get_singleton("NetworkEvents") - if ne and ne.has_signal("on_server_start"): - ne.on_server_start.emit() - print("[HeadlessServer] Emitted NetworkEvents.on_server_start") - if ne and ne.has_signal("on_peer_join"): - ne.on_peer_join.emit(1) - print("[HeadlessServer] Emitted NetworkEvents.on_peer_join(1)") - - var round_mgr = network.get_node_or_null("RoundManager") - if round_mgr: - if round_mgr.has_method("start_match"): - round_mgr.call_deferred("start_match") - print("[HeadlessServer] RoundManager.start_match() called") - elif round_mgr.has_method("start_round"): - round_mgr.call_deferred("start_round") - print("[HeadlessServer] RoundManager.start_round() called") - - print("[HeadlessServer] Manual startup complete") - # --------------------------------------------------------------------------- # RCON admin console # --------------------------------------------------------------------------- diff --git a/tests/bootstrapper.gd.uid b/tests/bootstrapper.gd.uid new file mode 100644 index 0000000..b1cb291 --- /dev/null +++ b/tests/bootstrapper.gd.uid @@ -0,0 +1 @@ +uid://c3h0oeqxo60l1 diff --git a/tests/economy.gd.uid b/tests/economy.gd.uid new file mode 100644 index 0000000..4f52f1d --- /dev/null +++ b/tests/economy.gd.uid @@ -0,0 +1 @@ +uid://c0oo6rmioaxm4 diff --git a/tests/runner.gd.uid b/tests/runner.gd.uid new file mode 100644 index 0000000..3f15896 --- /dev/null +++ b/tests/runner.gd.uid @@ -0,0 +1 @@ +uid://dhrejdsgejls4 diff --git a/tests/team.gd.uid b/tests/team.gd.uid new file mode 100644 index 0000000..5ec76a8 --- /dev/null +++ b/tests/team.gd.uid @@ -0,0 +1 @@ +uid://cg36adsrfnl1r diff --git a/tests/weapons.gd.uid b/tests/weapons.gd.uid new file mode 100644 index 0000000..14a624a --- /dev/null +++ b/tests/weapons.gd.uid @@ -0,0 +1 @@ +uid://dwfsp7sttekha