Fix dedicated server: delay netfox tick loop until first client connects, keep full scene tree for identity sync

This commit is contained in:
2026-07-03 14:47:00 -04:00
parent ba2fc37502
commit fc2c0236cb
7 changed files with 85 additions and 89 deletions
@@ -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():
+60 -87
View File
@@ -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
# ---------------------------------------------------------------------------
+1
View File
@@ -0,0 +1 @@
uid://c3h0oeqxo60l1
+1
View File
@@ -0,0 +1 @@
uid://c0oo6rmioaxm4
+1
View File
@@ -0,0 +1 @@
uid://dhrejdsgejls4
+1
View File
@@ -0,0 +1 @@
uid://cg36adsrfnl1r
+1
View File
@@ -0,0 +1 @@
uid://dwfsp7sttekha