## 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 \ ## --api-url http://127.0.0.1:8000 --server-token ## All args are optional; the defaults let `godot res://scenes/server.tscn` ## run standalone on 7777 for smoke_test_network_client.gd (which passes ## neither --api-url nor --server-token, so startup goes straight to the ## test_level.tscn fallback below). ## ## Startup sequence (docs/architecture/world_persistence_impl.md §7.2): ## 1. Parse args. ## 2. If --api-url/--server-token/--world are all present, pull the world's ## manifest + the spawn-neighbourhood chunks from the backend's internal ## API (§8.3), decode them, and build real static collision from the ## tile layers + objects. This happens BEFORE the ENet port binds. ## 3. Fall back to test_level.tscn if the backend is unreachable, the world ## has no rooms, or the room has zero chunks -- a content outage must ## never make a world un-joinable (§7.2 step 5). ## 4. Bind the ENet port, then write "ready": true and "content" ("loaded" ## or "fallback") to the status file -- this is what ## GameServerSupervisor._await_ready() polls for (§7.4), replacing the ## old bare-port-bind readiness check. 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 ## world_persistence_impl.md §4.1's default room slug. const MAIN_ROOM_SLUG := "main" ## §7.2 step 3: "the spawn neighbourhood (radius from the spawn point, ## default 3 chunks)". const SPAWN_CHUNK_RADIUS := 3 ## HTTP timeout for internal-API calls during startup. Generous but bounded ## -- a hung backend must not hang the server startup (§7.2 fallback rule). const CONTENT_FETCH_TIMEOUT := 8.0 var network_peer: ENetMultiplayerPeer var port: int = DEFAULT_PORT var world_id: String = "" var status_file_path: String = "" ## world_persistence_impl.md §7.3: internal-API base URL and this spawn's ## bearer token, both passed on the command line by ## GameServerSupervisor._spawn(). Empty means "no backend to talk to" -- ## content loading is skipped and the fallback level loads immediately. var api_url: String = "" var server_token: String = "" ## What startup actually loaded, written into the status file's "content" ## field: "loaded" (real content from the backend) or "fallback" ## (test_level.tscn, either because there was nothing to load or nothing ## reachable to load it from). var _content_status: String = "fallback" ## Diagnostic-only counters (not part of the doc's required status fields, ## but real: they're the actual number of chunks decoded and collision ## shapes added to the tree, computed in the same pass that builds them -- ## useful for verifying a real load happened without needing to poke the ## running server's scene tree from outside). var _chunks_loaded: int = 0 var _collision_shapes_built: int = 0 ## 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 _fallback_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 var level: Node = null if api_url != "" and server_token != "" and world_id != "": level = await _try_load_real_content() if level == null: level = _fallback_level_scene.instantiate() _content_status = "fallback" elif _chunks_loaded == 0: _content_status = "fallback" else: _content_status = "loaded" 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) content=%s chunks=%d collision_shapes=%d" % [ world_id, port, PHYSICS_HZ, _content_status, _chunks_loaded, _collision_shapes_built ]) ## 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 "--api-url": if i + 1 < args.size(): api_url = args[i + 1].rstrip("/") i += 1 "--server-token": if i + 1 < args.size(): server_token = args[i + 1] i += 1 i += 1 # --------------------------------------------------------------------------- # Startup content load (world_persistence_impl.md §7.2/§7.3/§8.3) # --------------------------------------------------------------------------- ## Attempt to build a real level from the backend's internal content API. ## Returns null on ANY failure (unreachable backend, no rooms, zero chunks) ## so the caller falls back to test_level.tscn -- a content outage must ## never make a world un-joinable. func _try_load_real_content() -> Node: var manifest_result := await _internal_get_json("/api/internal/worlds/%s/manifest" % world_id) if not manifest_result.ok: print("[Server] manifest fetch failed (%s); falling back to test_level" % manifest_result.get("detail", "?")) return null var manifest: Dictionary = manifest_result.data var rooms: Array = manifest.get("rooms", []) var room: Variant = _pick_room(rooms) if room == null: print("[Server] world %s has no rooms; falling back to test_level" % world_id) return null if int(room.get("chunk_count", 0)) <= 0: print("[Server] room '%s' has zero chunks; falling back to test_level" % room.get("slug", "?")) return null var slug: String = room.get("slug", MAIN_ROOM_SLUG) var spawn_pos := _spawn_pos_from_room(room) var spawn_coord := Chunk.coord_for_position(spawn_pos) var index_path := "/api/internal/worlds/%s/rooms/%s/chunks?cx_min=%d&cx_max=%d&cy_min=%d&cy_max=%d&cz=%d" % [ world_id, slug, spawn_coord.x - SPAWN_CHUNK_RADIUS, spawn_coord.x + SPAWN_CHUNK_RADIUS, spawn_coord.y - SPAWN_CHUNK_RADIUS, spawn_coord.y + SPAWN_CHUNK_RADIUS, spawn_coord.z, ] var index_result := await _internal_get_json(index_path) if not index_result.ok: print("[Server] chunk index fetch failed (%s); falling back to test_level" % index_result.get("detail", "?")) return null var world_root: Node3D = _fallback_level_scene.instantiate() as Node3D world_root.name = "World" var marker: Marker3D = world_root.find_child("PlayerSpawn", true, false) as Marker3D if marker == null: marker = Marker3D.new() marker.name = "PlayerSpawn" world_root.add_child(marker) marker.position = spawn_pos var entries: Array = (index_result.data as Dictionary).get("chunks", []) for entry in entries: var cx: int = int(entry["cx"]) var cy: int = int(entry["cy"]) var cz: int = int(entry["cz"]) var chunk_path := "/api/internal/worlds/%s/rooms/%s/chunks/%d/%d/%d" % [world_id, slug, cx, cy, cz] var payload_result := await _internal_get_binary(chunk_path) if not payload_result.ok: printerr("[Server] chunk fetch failed for %d/%d/%d (status %d); skipping" % [cx, cy, cz, payload_result.status]) continue var decoded: ChunkDataDecoder.ChunkPayload = ChunkDataDecoder.decode_binary(payload_result.body) if decoded == null: printerr("[Server] chunk decode failed for %d/%d/%d; skipping" % [cx, cy, cz]) continue _build_chunk_collision(world_root, Vector3i(cx, cy, cz), decoded) _chunks_loaded += 1 return world_root func _pick_room(rooms: Array) -> Variant: for r in rooms: if r.get("slug", "") == MAIN_ROOM_SLUG: return r if not rooms.is_empty(): return rooms[0] return null func _spawn_pos_from_room(room: Dictionary) -> Vector3: var spawn: Variant = room.get("spawn") if spawn is Dictionary and (spawn as Dictionary).has("pos"): var p: Array = (spawn as Dictionary)["pos"] if p.size() >= 3: var pos := Vector3(float(p[0]), float(p[1]), float(p[2])) if pos.y < 1.0: pos.y = 1.5 return pos return Vector3(0, 1.5, 0) ## Build a StaticBody3D of box collision shapes for one decoded chunk: one ## box per solid tile (TileDef.solid, world.md §3.2) plus one per static ## (non-dynamic) object that isn't walkable. This is real collision derived ## from the actual decoded payload -- there is no client-side precedent to ## reuse (ChunkScene/TileLayerNode only build visuals; the dedicated server ## has no renderer and needs physics bodies instead). func _build_chunk_collision(parent: Node3D, coord: Vector3i, data: ChunkDataDecoder.ChunkPayload) -> void: var body := StaticBody3D.new() body.name = "chunk_%d_%d_%d" % [coord.x, coord.y, coord.z] var chunk_origin := Vector3( float(coord.x * Chunk.CHUNK_SIZE_X), float(coord.z * Chunk.CHUNK_SIZE_Z), float(coord.y * Chunk.CHUNK_SIZE_Y) ) for layer in data.tile_layers: for z in range(layer.depth): for y in range(layer.height): for x in range(layer.width): var tile_id := layer.get_tile(x, y, z) if tile_id == 0: continue var tile_def := layer.get_tile_def(tile_id) if tile_def == null or not tile_def.solid: continue var shape := CollisionShape3D.new() var box := BoxShape3D.new() box.size = Vector3.ONE shape.shape = box shape.position = chunk_origin + Vector3(x + 0.5, z + 0.5, y + 0.5) body.add_child(shape) _collision_shapes_built += 1 for obj in data.objects: if obj.dynamic: continue # dynamic templates are spawnable entities, not static geometry if obj.type == "spawn_point" or bool(obj.properties.get("walkable", false)): continue var shape := CollisionShape3D.new() var box := BoxShape3D.new() var aid := str(obj.asset_id).to_lower() var cat_id := str(obj.properties.get("catalog_id", "")).to_lower() if "lamp" in aid or "lamp" in cat_id: box.size = Vector3(0.5, 3.2, 0.5) shape.shape = box shape.position = chunk_origin + Vector3(obj.position.x, obj.position.z + 1.6, obj.position.y) elif "tree" in aid or "tree" in cat_id: box.size = Vector3(0.8, 4.0, 0.8) shape.shape = box shape.position = chunk_origin + Vector3(obj.position.x, obj.position.z + 2.0, obj.position.y) elif "chest" in aid or "chest" in cat_id: box.size = Vector3(1.0, 0.8, 0.6) shape.shape = box shape.position = chunk_origin + Vector3(obj.position.x, obj.position.z + 0.4, obj.position.y) elif "rock" in aid or "rock" in cat_id: box.size = Vector3(1.2, 1.0, 1.2) shape.shape = box shape.position = chunk_origin + Vector3(obj.position.x, obj.position.z + 0.5, obj.position.y) else: box.size = Vector3(obj.scale.x, obj.scale.z, obj.scale.y) shape.shape = box shape.position = chunk_origin + Vector3(obj.position.x, obj.position.z + obj.scale.y * 0.5, obj.position.y) body.add_child(shape) _collision_shapes_built += 1 if body.get_child_count() > 0: parent.add_child(body) ## Minimal internal-API HTTP GET, bearer-authenticated with --server-token ## (world_persistence_impl.md §7.3). Deliberately separate from ApiClient: ## that autoload's `request()` always decodes the response body as ## UTF-8/JSON, which corrupts binary chunk payloads, and its auth model is ## the player's JWT, not this process's per-spawn server token. func _internal_get_json(path: String) -> Dictionary: var raw := await _internal_get(path, "application/json") if not raw.ok: return {"ok": false, "status": raw.status, "detail": raw.get("detail", ""), "data": null} var parsed: Variant = JSON.parse_string(raw.body.get_string_from_utf8()) if not (parsed is Dictionary): return {"ok": false, "status": raw.status, "detail": "invalid JSON response", "data": null} return {"ok": true, "status": raw.status, "detail": "", "data": parsed} func _internal_get_binary(path: String) -> Dictionary: return await _internal_get(path, "application/octet-stream") func _internal_get(path: String, accept: String) -> Dictionary: var http := HTTPRequest.new() http.timeout = CONTENT_FETCH_TIMEOUT add_child(http) var headers := PackedStringArray() headers.append("Accept: " + accept) headers.append("Authorization: Bearer " + server_token) var url := api_url + path var err := http.request(url, headers, HTTPClient.METHOD_GET) if err != OK: http.queue_free() return {"ok": false, "status": 0, "detail": "request() failed to start (%d)" % err, "body": PackedByteArray()} var args: Array = await http.request_completed var result_code: int = args[0] var response_code: int = args[1] var body: PackedByteArray = args[3] http.queue_free() if result_code != HTTPRequest.RESULT_SUCCESS: return {"ok": false, "status": 0, "detail": "transport error (%d)" % result_code, "body": PackedByteArray()} if response_code < 200 or response_code >= 300: return {"ok": false, "status": response_code, "detail": "HTTP %d" % response_code, "body": body} return {"ok": true, "status": response_code, "detail": "", "body": body} 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 + readiness wait # (world_persistence_impl.md §7.4 / game_servers.py's _await_ready) # --------------------------------------------------------------------------- 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(), # game_servers.py's _await_ready() polls for this -- only ever # written true, and only once content has been decided (loaded # or fallen back) AND create_server() has already succeeded. "ready": is_running, "content": _content_status, "chunks_loaded": _chunks_loaded, "collision_shapes": _collision_shapes_built, })) f.close()