Files
modbison/scripts/server.gd
T
daddy 682f1f89f7 PC3 Phase 2.1: server-authoritative sim + prediction/reconciliation with replay
Made the multiplayer core actually work (it had never run a real sim):

- project.godot: physics pinned to 30 Hz -- the shared sim/snapshot/prediction rate.
- server.gd: loads the world's level (real ground + collision), spawns a
  server Player per peer at the level's PlayerSpawn, broadcasts a full
  snapshot every physics frame with each peer's state AND last_seq (the last
  input seq applied for that peer). Accepts `--port/--world/--status-file`
  args (status file = player count for a future idle reaper); defaults keep
  `godot res://scenes/server.tscn` working standalone.
- player_spawner.gd (server Player): rewrote -- drains its whole input queue
  each physics frame in seq order, integrates gravity/walk/jump with the
  shared constants, move_and_slide(), tracks last_applied_seq. No more no-op
  process_server_input().
- network_client.gd: rewrote reconciliation -- drops inputs the server has
  acked (last_seq), re-simulates the still-in-flight ones from the
  authoritative state, snaps only on real drift. Exposes get_remote_states()
  (peer_id -> latest snapshot state) for the game to render other players.
  predict_and_send() now also sends body yaw.
- proxies trimmed to the RPC set the rewrite actually uses.
- scripts/mp_test_client.gd + run_multiplayer_2p_test.sh: two real client
  processes vs one real server -- client A sees client B and its own
  movement round-trips. PASS.
- run_checkpoint_tests.py: also run the 2p shell test.

smoke_test_network_client 4/4, player 15/15, avatar_in_world 10/10, all green.

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

163 lines
4.9 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
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])
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)
player.global_position = _spawn_point
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()