## HeadlessServer — Dedicated server entry point for headless mode. ## ## Loads the multiplayer-fps game scene DIRECTLY under /root/ (as a sibling, ## not a child), so RPC node paths match what clients expect. ## ## 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. ## ## 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 ## Default server port. 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 # --------------------------------------------------------------------------- func _ready() -> void: print("[HeadlessServer] Starting dedicated server...") var port: int = _parse_port() print("[HeadlessServer] Port: %d" % port) # Load the multiplayer-fps game scene var GameScene = load("res://examples/multiplayer-fps/multiplayer-fps.tscn") if GameScene == null: push_error("[HeadlessServer] Failed to load multiplayer-fps.tscn") get_tree().quit(1) return _game_scene = GameScene.instantiate() # 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() # Wait for game scene to enter the tree await get_tree().process_frame await get_tree().process_frame # Set current scene after it's safely in the tree 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() func _verify_scene() -> void: if _game_scene == null: return var network = _game_scene.get_node_or_null("Network") if network == null: push_error("[HeadlessServer] Missing Network node in game scene!") return for required in ["Player Spawner", "TeamManager", "RoundManager", "EconomyManager", "Bomb"]: if network.get_node_or_null(required) == null: push_warning("[HeadlessServer] Missing Network/%s — some features disabled" % required) 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 # --------------------------------------------------------------------------- func _start_server(port: int) -> void: # Create ENet peer directly var peer = ENetMultiplayerPeer.new() var err = peer.create_server(port) if err != OK: push_error("[HeadlessServer] Failed to create ENet server on port %d: %s" % [port, error_string(err)]) get_tree().quit(1) return get_tree().get_multiplayer().multiplayer_peer = peer get_tree().get_multiplayer().server_relay = true print("[HeadlessServer] ENet server started on port %d" % port) # Let NetworkEvents fire on_server_start (without starting NetworkTime) await get_tree().process_frame 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() if mp and mp.multiplayer_peer: mp.multiplayer_peer.close() mp.multiplayer_peer = null print("[HeadlessServer] Server stopped") # --------------------------------------------------------------------------- # RCON admin console # --------------------------------------------------------------------------- func _start_rcon() -> void: var rcon_path = "res://scripts/rcon/rcon_server.gd" if not ResourceLoader.exists(rcon_path): print("[HeadlessServer] RCON script not found — skipping") return var RconServerClass = load(rcon_path) if RconServerClass == null: return var rcon = RconServerClass.new() rcon.name = "RconServer" rcon.enabled = true rcon.port = _parse_rcon_port() rcon.password = _parse_rcon_password() add_child(rcon) print("[HeadlessServer] RCON admin console started on port %d" % rcon.port) func _parse_rcon_port() -> int: var args = OS.get_cmdline_args() for i in range(args.size()): if args[i] == "--rcon-port" and i + 1 < args.size(): return clampi(args[i + 1].to_int(), 1024, 65535) if OS.has_environment("RCON_PORT"): return clampi(OS.get_environment("RCON_PORT").to_int(), 1024, 65535) return 28960 func _parse_rcon_password() -> String: var args = OS.get_cmdline_args() for i in range(args.size()): if args[i] == "--rcon-password" and i + 1 < args.size(): return args[i + 1] if OS.has_environment("RCON_PASSWORD"): return OS.get_environment("RCON_PASSWORD") var pw_path = "res://config/rcon_password.cfg" if ResourceLoader.exists(pw_path): var f = FileAccess.open(pw_path, FileAccess.READ) if f: var line = f.get_line().strip_edges() f.close() if not line.is_empty() and not line.begins_with("#"): return line return "changeme" # --------------------------------------------------------------------------- # Port parsing # --------------------------------------------------------------------------- func _parse_port() -> int: var user_args = OS.get_cmdline_user_args() for i in range(user_args.size()): if user_args[i] == "--port" and i + 1 < user_args.size(): var p = user_args[i + 1].to_int() if p > 0 and p < 65536: return p var args = OS.get_cmdline_args() for i in range(args.size()): if args[i] == "--port" and i + 1 < args.size(): var p = args[i + 1].to_int() if p > 0 and p < 65536: return p if OS.has_environment("SERVER_PORT"): var p = OS.get_environment("SERVER_PORT").to_int() if p > 0 and p < 65536: return p return DEFAULT_PORT