## 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. ## ## 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 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 # --------------------------------------------------------------------------- # 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() # 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() 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 # 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") # 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: 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!") 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 stripped for headless mode") # --------------------------------------------------------------------------- # 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) # Wait for NetworkEvents to fire on_server_start 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() 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") ## 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 # --------------------------------------------------------------------------- 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