Fix RPC path mismatch in headless server

The headless server added the multiplayer-fps game scene as a child node
(/root/HeadlessServer/multiplayer-fps/...), causing RPC node paths to
include the HeadlessServer/ prefix. Clients expected paths relative to
root (/root/multiplayer-fps/...), so every RPC would fail with
'Node not found'.

Fix: add the game scene directly to /root/ via call_deferred, making it
a sibling of the HeadlessServer node rather than a child. RPC paths now
match between server and client.
This commit is contained in:
2026-07-02 22:30:34 -04:00
parent 841ce19740
commit 4a834a1d55
3 changed files with 157 additions and 53 deletions
+47 -53
View File
@@ -1,26 +1,29 @@
## HeadlessServer — Dedicated server entry point for headless mode.
##
## Loads the multiplayer-fps game scene, strips UI/rendering overhead,
## and auto-starts the ENet server. Designed to run with `--headless`.
## 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 the main scene root in headless mode.
## 1. Loads multiplayer-fps.tscn (the full game scene)
## 2. Removes Environment (sky/light/camera) and UI (HUD/network popup)
## 3. Starts the ENet server on the configured port
## 4. Calls RoundManager.start_match() to begin gameplay
## 5. Listens for player connections via NetworkEvents
## 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
## The multiplayer-fps game scene.
## Reference to the multiplayer-fps game scene (at root level).
var _game_scene: Node = null
# ---------------------------------------------------------------------------
@@ -30,30 +33,36 @@ var _game_scene: Node = null
func _ready() -> void:
print("[HeadlessServer] Starting dedicated server...")
# Determine port from command-line args or default
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")
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()
add_child(_game_scene)
# Strip UI and rendering overhead
# 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()
# Wait one frame for scene to settle, then host
# 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)
@@ -70,19 +79,19 @@ func _strip_for_headless() -> void:
return
# Remove Environment (sky, light, camera, world environment)
var env := _game_scene.get_node_or_null("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")
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")
var network = _game_scene.get_node_or_null("Network")
if network == null:
push_error("[HeadlessServer] Missing Network node in game scene!")
return
@@ -98,9 +107,9 @@ func _strip_for_headless() -> void:
# ---------------------------------------------------------------------------
func _start_server(port: int) -> void:
# Create ENet peer directly (same as lan-bootstrapper.gd::host())
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(port)
# 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)
@@ -114,18 +123,15 @@ func _start_server(port: int) -> void:
# Wait for NetworkEvents to fire on_server_start
await get_tree().process_frame
# The player-spawner.gd handles on_server_start by spawning the host avatar
# and calling round_manager.start_match(). If NetworkEvents fires it, we're set.
# Otherwise, manually start the match.
# NetworkEvents handles on_server_start -> player-spawner -> round_manager
if NetworkEvents and NetworkEvents.enabled:
print("[HeadlessServer] NetworkEvents active — waiting for on_server_start")
else:
# Fallback: manually start NetworkTime and signal server start
print("[HeadlessServer] NetworkEvents not available — manually bootstrapping")
_manual_startup()
func _stop_server() -> void:
var mp := get_tree().get_multiplayer()
var mp = get_tree().get_multiplayer()
if mp and mp.multiplayer_peer:
mp.multiplayer_peer.close()
mp.multiplayer_peer = null
@@ -133,26 +139,22 @@ func _stop_server() -> void:
## Fallback startup when NetworkEvents isn't available.
func _manual_startup() -> void:
# Start NetworkTime if available
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")
# Find and start the match via RoundManager
var network := _game_scene.get_node_or_null("Network") if _game_scene else null
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")
var spawner = network.get_node_or_null("Player Spawner")
if spawner:
# Manually call the host handler that spawns the host avatar
if spawner.has_method("_handle_host"):
spawner._handle_host()
print("[HeadlessServer] PlayerSpawner._handle_host() called")
# Manually emit peer join for host (peer 1)
if Engine.has_singleton("NetworkEvents"):
var ne = Engine.get_singleton("NetworkEvents")
if ne and ne.has_signal("on_server_start"):
@@ -162,8 +164,7 @@ func _manual_startup() -> void:
ne.on_peer_join.emit(1)
print("[HeadlessServer] Emitted NetworkEvents.on_peer_join(1)")
# Start the match via RoundManager
var round_mgr := network.get_node_or_null("RoundManager")
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")
@@ -178,14 +179,13 @@ func _manual_startup() -> void:
# RCON admin console
# ---------------------------------------------------------------------------
## Start the RCON TCP admin console.
func _start_rcon() -> void:
var rcon_path := "res://scripts/rcon/rcon_server.gd"
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)
var RconServerClass = load(rcon_path)
if RconServerClass == null:
return
@@ -197,30 +197,27 @@ func _start_rcon() -> void:
add_child(rcon)
print("[HeadlessServer] RCON admin console started on port %d" % rcon.port)
## Parse RCON port from args/env.
func _parse_rcon_port() -> int:
var args := OS.get_cmdline_args()
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 # default RCON port
return 28960
## Parse RCON password from args/env/file.
func _parse_rcon_password() -> String:
var args := OS.get_cmdline_args()
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")
# Try loading from file
var pw_path := "res://config/rcon_password.cfg"
var pw_path = "res://config/rcon_password.cfg"
if ResourceLoader.exists(pw_path):
var f := FileAccess.open(pw_path, FileAccess.READ)
var f = FileAccess.open(pw_path, FileAccess.READ)
if f:
var line := f.get_line().strip_edges()
var line = f.get_line().strip_edges()
f.close()
if not line.is_empty() and not line.begins_with("#"):
return line
@@ -231,25 +228,22 @@ func _parse_rcon_password() -> String:
# ---------------------------------------------------------------------------
func _parse_port() -> int:
# Check user args (after -- separator): --port 34197
var user_args := OS.get_cmdline_user_args()
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()
var p = user_args[i + 1].to_int()
if p > 0 and p < 65536:
return p
# Check all args as fallback
var args := OS.get_cmdline_args()
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()
var p = args[i + 1].to_int()
if p > 0 and p < 65536:
return p
# Check environment variable
if OS.has_environment("SERVER_PORT"):
var p := OS.get_environment("SERVER_PORT").to_int()
var p = OS.get_environment("SERVER_PORT").to_int()
if p > 0 and p < 65536:
return p