## ServerMain - headless dedicated server for one world. ## ## Accepts ENet connections, spawns a server-authoritative Player per peer in a ## real loaded level, and broadcasts a full snapshot (every peer's state + the ## last input seq processed for that peer) every physics frame. The project's ## physics rate is pinned to 30 Hz so the sim is deterministic and matches the ## client's prediction step. ## ## Launched by the backend's game-server supervisor ## (backend/app/services/game_servers.py): ## godot --headless --path . res://scenes/server.tscn -- \ ## --port 27000 --world 3 --status-file /path/to/status.json ## All args are optional; the defaults let `godot res://scenes/server.tscn` ## run standalone on 7777 for smoke_test_network_client.gd. extends Node const MAX_PLAYERS := 16 const DEFAULT_PORT := 7777 const PHYSICS_HZ := 30 ## Rewrite the status file (player count, for the supervisor's idle reaper) ## every this many physics frames. const STATUS_WRITE_EVERY := 30 var network_peer: ENetMultiplayerPeer var port: int = DEFAULT_PORT var world_id: String = "" var status_file_path: String = "" ## peer_id -> Player node var players: Dictionary = {} var tick: int = 0 var is_running: bool = false ## Monotonic count of joins ever -- drives spawn spread so two players never ## land on the same spot (which used to shove one through the floor). var _join_count: int = 0 var _player_scene: PackedScene = preload("res://scenes/server_player.tscn") var _level_scene: PackedScene = preload("res://scenes/worlds/test_level.tscn") var _spawn_point: Vector3 = Vector3(0, 1.5, 0) func _enter_tree() -> void: multiplayer.peer_connected.connect(_on_peer_connected) multiplayer.peer_disconnected.connect(_on_peer_disconnected) func _ready() -> void: _parse_args() Engine.physics_ticks_per_second = PHYSICS_HZ # Load the world's level so players have real ground + collision. var level := _level_scene.instantiate() level.name = "World" add_child(level) var marker := level.find_child("PlayerSpawn", true, false) if marker is Node3D: _spawn_point = (marker as Node3D).global_position network_peer = ENetMultiplayerPeer.new() var err := network_peer.create_server(port, MAX_PLAYERS) if err != OK: printerr("[Server] create_server(%d) failed: %d" % [port, err]) get_tree().quit(1) return multiplayer.multiplayer_peer = network_peer is_running = true _write_status() print("[Server] world=%s listening on ENet %d (%d Hz)" % [world_id, port, PHYSICS_HZ]) ## A spawn point offset from the level's marker so joins don't stack. Golden- ## angle spiral: evenly spread, never exactly repeats, grows slowly outward. func _spawn_for(index: int) -> Vector3: if index == 0: return _spawn_point var angle := index * 2.399963 # golden angle (radians) var radius := 0.9 + 0.25 * float(index) return _spawn_point + Vector3(cos(angle) * radius, 0.0, sin(angle) * radius) func _parse_args() -> void: var args := OS.get_cmdline_user_args() var i := 0 while i < args.size(): match args[i]: "--port": if i + 1 < args.size(): port = int(args[i + 1]) i += 1 "--world": if i + 1 < args.size(): world_id = args[i + 1] i += 1 "--status-file": if i + 1 < args.size(): status_file_path = args[i + 1] i += 1 i += 1 func _physics_process(_delta: float) -> void: if not is_running: return tick += 1 if tick % PHYSICS_HZ == 0: _sweep_stale_players() _broadcast_snapshot() if tick % STATUS_WRITE_EVERY == 0: _write_status() ## Backstop for _on_peer_disconnected: once a second, drop any player whose ## peer is no longer connected (covers a missed disconnect signal, so a ## rejoining client never sees a frozen ghost of its old self). func _sweep_stale_players() -> void: var connected := multiplayer.get_peers() for peer_id in players.keys(): if peer_id not in connected: var p: Node = players[peer_id] if is_instance_valid(p): p.queue_free() players.erase(peer_id) print("[Server] swept stale player %d (%d left)" % [peer_id, players.size()]) func _broadcast_snapshot() -> void: var snap := {"tick": tick, "players": {}} for peer_id in players: var player: Node = players[peer_id] if is_instance_valid(player): var s: Dictionary = player.get_server_state() s["last_seq"] = player.last_applied_seq snap["players"][peer_id] = s for pid in multiplayer.get_peers(): if pid == 1: continue rpc_id(pid, "_receive_snapshot", snap) # Client-side receiver lives on the NetworkClient's proxy at the same path. @rpc("any_peer", "call_remote", "reliable") func _receive_snapshot(_snapshot_data: Dictionary) -> void: pass # --------------------------------------------------------------------------- # Connection lifecycle # --------------------------------------------------------------------------- func _on_peer_connected(peer_id: int) -> void: if players.size() >= MAX_PLAYERS: print("[Server] full; rejecting peer %d" % peer_id) multiplayer.multiplayer_peer.disconnect_peer(peer_id) return var player: Node3D = _player_scene.instantiate() player.name = "Player_%d" % peer_id player.peer_id = peer_id add_child(player) var spawn := _spawn_for(_join_count) _join_count += 1 player.global_position = spawn player.spawn_position = spawn players[peer_id] = player print("[Server] peer %d joined (%d players)" % [peer_id, players.size()]) _write_status() func _on_peer_disconnected(peer_id: int) -> void: if players.has(peer_id): var player: Node = players[peer_id] if is_instance_valid(player): player.queue_free() players.erase(peer_id) print("[Server] peer %d left (%d players)" % [peer_id, players.size()]) _write_status() # --------------------------------------------------------------------------- # Status file for the supervisor's idle reaper # --------------------------------------------------------------------------- func _write_status() -> void: if status_file_path.is_empty(): return var f := FileAccess.open(status_file_path, FileAccess.WRITE) if f: f.store_string(JSON.stringify({ "world_id": world_id, "port": port, "players": players.size(), "tick": tick, "updated": Time.get_unix_time_from_system(), })) f.close()