Files
modbison/scripts/server.gd
T
daddy da4e1420ee
checkpoint / checkpoint (push) Successful in 1m40s
Fix: rejoining stacked players on top of each other (one fell through)
All players spawned at the exact same PlayerSpawn point and were on the
same collision layer, so two overlapping CharacterBody3Ds triggered a
physics depenetration shove that could launch one through the 1-unit-thick
floor.

- server.gd: golden-angle spawn spread -- each join lands ~1m+ from the
  others, growing outward, never exactly repeating.
- server_player.tscn + player.tscn: players on collision layer 2, mask 1
  (world only) -- players no longer collide with each other at all
  (fine for a social game; also removes the shove).
- player_spawner.gd + player.gd: hard safety net -- if y < -10 (any
  tunnel-through), snap back to the spawn instead of falling forever.

Verified: 3 server players spawn 1.15m+ apart, none fall, all rest on the
floor. 2p multiplayer + network_client smokes green.

Client-side -- rebuild the exe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-06 00:00:27 +00:00

179 lines
5.5 KiB
GDScript

## 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
_broadcast_snapshot()
if tick % STATUS_WRITE_EVERY == 0:
_write_status()
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()