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
This commit is contained in:
+105
-117
@@ -1,121 +1,117 @@
|
||||
## ServerMain - Dedicated server root node.
|
||||
## Accepts ENet connections, spawns Player nodes for each peer,
|
||||
## and runs the simulation tick loop at 20 Hz.
|
||||
## 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
|
||||
|
||||
|
||||
## Maximum number of concurrent players.
|
||||
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
|
||||
|
||||
## Server tick rate (Hz).
|
||||
const TICK_RATE := 20
|
||||
|
||||
## Tick duration in seconds.
|
||||
const TICK_DURATION := 1.0 / TICK_RATE
|
||||
|
||||
## ENet server port.
|
||||
const SERVER_PORT := 7777
|
||||
|
||||
## Network peer used for ENet communication.
|
||||
var network_peer: ENetMultiplayerPeer
|
||||
var port: int = DEFAULT_PORT
|
||||
var world_id: String = ""
|
||||
var status_file_path: String = ""
|
||||
|
||||
## Map of peer_id -> Player node.
|
||||
## peer_id -> Player node
|
||||
var players: Dictionary = {}
|
||||
|
||||
## Current tick counter (server-authoritative).
|
||||
var tick: int = 0
|
||||
|
||||
## Whether the server is running.
|
||||
var is_running: bool = false
|
||||
|
||||
## Accumulated real time for fixed timestep simulation.
|
||||
var accumulator: float = 0.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)
|
||||
|
||||
## Player scene resource (loaded once).
|
||||
var _player_scene: PackedScene
|
||||
|
||||
func _enter_tree() -> void:
|
||||
multiplayer.peer_connected.connect(_on_peer_connected)
|
||||
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Preload the player scene once.
|
||||
_player_scene = preload("res://scenes/server_player.tscn")
|
||||
_parse_args()
|
||||
Engine.physics_ticks_per_second = PHYSICS_HZ
|
||||
|
||||
# Create and assign the ENet server peer.
|
||||
network_peer = _create_server_peer()
|
||||
if network_peer:
|
||||
multiplayer.multiplayer_peer = network_peer
|
||||
is_running = true
|
||||
print("[Server] Started on port %d" % SERVER_PORT)
|
||||
else:
|
||||
printerr("[Server] Failed to create ENet peer.")
|
||||
# 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])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server creation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _create_server_peer() -> ENetMultiplayerPeer:
|
||||
var peer := ENetMultiplayerPeer.new()
|
||||
var error := peer.create_server(SERVER_PORT, MAX_PLAYERS)
|
||||
if error != OK:
|
||||
printerr("[Server] create_server error: %d" % error)
|
||||
return null
|
||||
return peer
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixed-timestep simulation loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
func _physics_process(_delta: float) -> void:
|
||||
if not is_running:
|
||||
return
|
||||
|
||||
# Fixed timestep accumulator.
|
||||
accumulator += delta
|
||||
while accumulator >= TICK_DURATION:
|
||||
accumulator -= TICK_DURATION
|
||||
_tick()
|
||||
|
||||
|
||||
func _tick() -> void:
|
||||
"""Main server simulation tick (called at TICK_RATE)."""
|
||||
tick += 1
|
||||
|
||||
# Process player inputs and update state.
|
||||
for peer_id in players:
|
||||
var player: Node3D = players[peer_id]
|
||||
if player and player.has_method("process_server_input"):
|
||||
player.process_server_input(tick)
|
||||
|
||||
# Broadcast snapshot to all clients.
|
||||
_broadcast_snapshot()
|
||||
if tick % STATUS_WRITE_EVERY == 0:
|
||||
_write_status()
|
||||
|
||||
|
||||
func _broadcast_snapshot() -> void:
|
||||
"""Broadcast the current authoritative state to all connected clients."""
|
||||
var snapshot_data := {
|
||||
"tick": tick,
|
||||
"players": {},
|
||||
}
|
||||
|
||||
var snap := {"tick": tick, "players": {}}
|
||||
for peer_id in players:
|
||||
var player: Node3D = players[peer_id]
|
||||
if player and player.has_method("get_server_state"):
|
||||
snapshot_data.players[peer_id] = player.get_server_state()
|
||||
|
||||
# Send snapshot to all peers except the server itself (peer_id == 1).
|
||||
var peer_ids: Array = multiplayer.get_peers()
|
||||
for pid in peer_ids:
|
||||
if pid == 1: # 1 is the server itself
|
||||
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", snapshot_data)
|
||||
rpc_id(pid, "_receive_snapshot", snap)
|
||||
|
||||
|
||||
# Remote: clients receive snapshots.
|
||||
# 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 # Handled by client scene.
|
||||
func _receive_snapshot(_snapshot_data: Dictionary) -> void:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -123,52 +119,44 @@ func _receive_snapshot(snapshot_data: Dictionary) -> void:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _on_peer_connected(peer_id: int) -> void:
|
||||
print("[Server] Peer %d connected." % peer_id)
|
||||
|
||||
if players.size() >= MAX_PLAYERS:
|
||||
print("[Server] Max players reached. Disconnecting peer %d." % peer_id)
|
||||
rpc_id(peer_id, "_kick", "Server is full.")
|
||||
print("[Server] full; rejecting peer %d" % peer_id)
|
||||
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
|
||||
return
|
||||
|
||||
# Spawn a Player node for this peer.
|
||||
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] Spawned player node for peer %d." % peer_id)
|
||||
print("[Server] peer %d joined (%d players)" % [peer_id, players.size()])
|
||||
_write_status()
|
||||
|
||||
|
||||
func _on_peer_disconnected(peer_id: int) -> void:
|
||||
print("[Server] Peer %d disconnected." % peer_id)
|
||||
if players.has(peer_id):
|
||||
var player: Node3D = players[peer_id]
|
||||
if player.is_inside_tree():
|
||||
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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RPCs
|
||||
# Status file for the supervisor's idle reaper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _kick(reason: String) -> void:
|
||||
print("[Server] Kicking caller: %s" % reason)
|
||||
rpc_id(multiplayer.get_remote_sender_id(), "_disconnect", reason)
|
||||
|
||||
|
||||
@rpc("call_remote", "reliable")
|
||||
func _disconnect(reason: String) -> void:
|
||||
print("[Server] Disconnecting: %s" % reason)
|
||||
get_tree().quit(0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signal connections
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _enter_tree() -> void:
|
||||
multiplayer.peer_connected.connect(_on_peer_connected)
|
||||
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user