From 682f1f89f7fc22a13b6a26b867110006a1e28107 Mon Sep 17 00:00:00 2001 From: daddy Date: Sat, 5 Sep 2026 22:00:18 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ --- backend/app/config.py | 21 +++ project.godot | 6 + scripts/mp_test_client.gd | 58 ++++++ scripts/network_client.gd | 272 +++++++++++++++-------------- scripts/network_player_proxy.gd | 46 +---- scripts/network_receiver_proxy.gd | 35 +--- scripts/player_spawner.gd | 257 ++++++--------------------- scripts/run_multiplayer_2p_test.sh | 45 +++++ scripts/server.gd | 222 +++++++++++------------ 9 files changed, 437 insertions(+), 525 deletions(-) create mode 100644 scripts/mp_test_client.gd create mode 100755 scripts/run_multiplayer_2p_test.sh diff --git a/backend/app/config.py b/backend/app/config.py index a899e6e..89712b2 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -80,6 +80,27 @@ class Settings(BaseSettings): MAX_UPLOAD_SIZE_MB: int = 100 GLTF_MAX_FILE_SIZE_MB: int = 200 + # ── Game-server orchestration ────────────────────────────────────────── + # The join flow (POST /api/worlds/{id}/join) ensures a headless Godot + # dedicated-server process exists for a world and returns its address. + # Disabled by default so nothing tries to spawn Godot in environments + # that don't have it (plain pytest, CI without the engine); the + # checkpoint/playtest launcher turns it on. + GAME_SERVERS_ENABLED: bool = False + # Path to the Godot 4 binary. "godot" resolves via PATH. + GODOT_BIN: str = str(Path.home() / "godot" / "godot") + # Godot project directory (the game repo root -- backend/ is a subdir). + GAME_PROJECT_DIR: str = str(Path(__file__).resolve().parents[2]) + # The host clients should connect to for ENet. Defaults to the LAN IP the + # launcher bakes into the exe; overridable for local testing. + GAME_SERVER_HOST: str = "127.0.0.1" + # Port range for per-world dedicated servers. + GAME_SERVER_PORT_MIN: int = 27000 + GAME_SERVER_PORT_MAX: int = 27099 + # Kill a world's server this long after its last join with no active + # players reported. + GAME_SERVER_IDLE_TIMEOUT_SECONDS: int = 300 + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", diff --git a/project.godot b/project.godot index b545262..96dca88 100644 --- a/project.godot +++ b/project.godot @@ -45,6 +45,12 @@ move_down={ ] } +[physics] + +; Fixed 30 Hz sim -- the multiplayer server, client prediction, and snapshot +; rate all key off this. See docs/architecture/networking.md 3.1. +common/physics_ticks_per_second=30 + [rendering] renderer/rendering_method="gl_compatibility" diff --git a/scripts/mp_test_client.gd b/scripts/mp_test_client.gd new file mode 100644 index 0000000..fb69de5 --- /dev/null +++ b/scripts/mp_test_client.gd @@ -0,0 +1,58 @@ +extends SceneTree +## One client process for the 2-player multiplayer test +## (scripts/run_multiplayer_2p_test.sh). Two real OS processes = real network. +## +## godot --headless --path . --script res://scripts/mp_test_client.gd -- --role a --port 7777 +## +## role "a": moves forward for ~2 s, then prints +## RESULT a remotes= moved= and exits 0 iff it both moved +## and saw >=1 remote player. +## role "b": connects, holds still for ~4 s, prints RESULT b connected exits 0. + +var role := "b" +var port := 7777 + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + for i in args.size(): + if args[i] == "--role" and i + 1 < args.size(): + role = args[i + 1] + elif args[i] == "--port" and i + 1 < args.size(): + port = int(args[i + 1]) + _run() + + +func _run() -> void: + await process_frame + var net := NetworkClient.new() + root.add_child(net) + var ok: bool = await net.connect_to_server("127.0.0.1", port) + if not ok: + print("RESULT %s connect_failed" % role) + quit(1) + return + for i in 15: + await process_frame + + if role == "b": + # Hold still and stay connected long enough for A to boot, connect, + # and observe us (A's Godot startup alone is a couple of seconds). + print("RESULT b connected") + for i in 1200: + await process_frame + net.disconnect_from_server() + quit(0) + return + + # role a: move forward, watch for a remote and our own displacement. + var start: Vector3 = net.position + var max_remotes := 0 + for i in 90: + net.predict_and_send(1.0 / 30.0, 0.0, -1.0, false, 0.0) + await process_frame + max_remotes = maxi(max_remotes, net.get_remote_states().size()) + var moved: bool = net.position.distance_to(start) > 1.0 + print("RESULT a remotes=%d moved=%s" % [max_remotes, str(moved)]) + net.disconnect_from_server() + quit(0 if (moved and max_remotes >= 1) else 1) diff --git a/scripts/network_client.gd b/scripts/network_client.gd index c9a1df4..b5f95a2 100644 --- a/scripts/network_client.gd +++ b/scripts/network_client.gd @@ -1,75 +1,62 @@ class_name NetworkClient extends Node -## Client-side networked player: input prediction + server reconciliation. +## Client-side multiplayer: ENet connection, local-player input prediction + +## server reconciliation (with input replay), and remote-player state for the +## game to render. ## -## Implements docs/architecture/networking.md §5 (Prediction & Reconciliation) -## for the *local* player only -- remote-entity interpolation (§5.3) and full -## delta-compressed multi-entity snapshots (§6) are explicitly out of scope -## for this first pass; the server already only ever has one player in PC2's -## single-peer testing, and this task's own description scopes it to "the -## player controller built in the dependency task" (singular). +## RPC path-matching: Godot resolves an rpc_id() call's target by matching the +## sending node's NodePath on the receiver. scripts/server.gd is the SceneTree +## root named "Server"; it spawns each peer's PlayerNode as a child named +## "Player_". So the client needs local nodes at /root/Server (for the +## server-initiated _receive_snapshot) and /root/Server/Player_ (the +## target of rpc_request_input). connect_to_server() builds that once the peer +## id is known. ## -## RPC path-matching, the part that makes this actually work: -## Godot's high-level rpc_id() resolves the *receiving* node by matching the -## *sending* node's NodePath on the caller's side. The server (scripts/ -## server.gd) is the SceneTree root node named "Server" when the dedicated -## server runs scenes/server.tscn as its main scene, and it spawns each -## connected player as a *child* of itself named "Player_" -## (scripts/server.gd's _on_peer_connected). For an RPC sent by the client to -## actually land on the right node, the client needs a LOCAL node tree at the -## exact same paths -- /root/Server for _receive_snapshot (called by the -## server on itself), and /root/Server/Player_ for -## rpc_request_input (a real method on the server's PlayerNode). Those local -## nodes don't need to run any server logic -- they only need to exist at the -## right path so Godot's RPC routing finds them. connect_to_server() builds -## that structure once the peer id is known (only available after the ENet -## handshake completes). -## -## Usage: instantiate directly (not an autoload -- this only exists for the -## lifetime of an actual multiplayer session, not on every client launch): +## Usage: ## var net := NetworkClient.new() ## get_tree().root.add_child(net) -## var ok := await net.connect_to_server("127.0.0.1", 7777) +## if await net.connect_to_server(host, port): +## # each physics frame, from the local player controller: +## net.predict_and_send(delta, move_x, move_z, jump, body_yaw) +## # read net.position / net.get_remote_states() to render +# Movement constants -- MUST match scripts/server.gd and player_spawner.gd. +const WALK_SPEED := 5.0 +const JUMP_VELOCITY := 4.5 +const GRAVITY := 9.8 -# --------------------------------------------------------------------------- -# Movement constants -- MUST match scripts/player_spawner.gd exactly, per -# docs/architecture/networking.md §5.1 ("the same physics constants as the -# server"). A mismatch here doesn't break anything outright, it just makes -# reconciliation corrections more frequent/visible. -# --------------------------------------------------------------------------- +## Predicted vs authoritative drift beyond this (metres) snaps rather than +## trusting the prediction. +const RECONCILE_ERROR_THRESHOLD := 0.35 -const WALK_SPEED: float = 5.0 -const JUMP_VELOCITY: float = 4.5 -const GRAVITY: float = 9.8 - -## How far predicted and authoritative position can drift before snapping -## rather than trusting the prediction was "close enough." Per §5.2. -const RECONCILE_ERROR_THRESHOLD: float = 0.5 - -## How many past predicted states to keep for reconciliation replay. Real -## sizing per §5.4 is RTT-adaptive; a fixed generous buffer is the -## appropriately-scoped first pass here -- 120 ticks at a 30Hz send rate is -## 4 seconds of history, comfortably more than any realistic LAN/local RTT. -const PREDICTION_BUFFER_SIZE: int = 120 +## Ring buffer cap. 120 frames at 30 Hz = 4 s of history, well past any +## realistic LAN RTT. +const PREDICTION_BUFFER_SIZE := 120 signal reconciled(correction_distance: float) signal connected_to_server signal connection_failed - +signal server_disconnected var peer: ENetMultiplayerPeer -var _server_root: Node # local /root/Server proxy -var _player_proxy: Node # local /root/Server/Player_ proxy +var my_peer_id: int = 0 +var _server_root: Node +var _player_proxy: Node var _seq: int = 0 -## Ring buffer of {seq, input, position, velocity} predicted at send time, -## oldest first. Trimmed as the server acknowledges ticks via snapshots. +## Ring buffer of {seq, move_x, move_z, jump, delta, position, velocity}, +## oldest first. Entries are dropped as the server acks their seq. var _prediction_buffer: Array[Dictionary] = [] +## Local predicted player state. var position: Vector3 = Vector3.ZERO var velocity: Vector3 = Vector3.ZERO -var _on_floor: bool = false +var _on_floor: bool = true + +## peer_id -> latest authoritative state dict from the most recent snapshot, +## for every peer that isn't us. The game reads this to render other players. +var _remote_states: Dictionary = {} +var _last_snapshot_tick: int = -1 func connect_to_server(host: String, port: int) -> bool: @@ -81,28 +68,24 @@ func connect_to_server(host: String, port: int) -> bool: return false multiplayer.multiplayer_peer = peer - multiplayer.connected_to_server.connect(_on_connected) - multiplayer.connection_failed.connect(func(): - connection_failed.emit() - ) + multiplayer.server_disconnected.connect(func(): server_disconnected.emit()) - for i in 300: # ~5s at 60fps + for _i in 300: # ~5 s at 60 fps await get_tree().process_frame - var status := peer.get_connection_status() - if status == MultiplayerPeer.CONNECTION_CONNECTED: - return true - elif status == MultiplayerPeer.CONNECTION_DISCONNECTED: - connection_failed.emit() - return false + match peer.get_connection_status(): + MultiplayerPeer.CONNECTION_CONNECTED: + _on_connected() + return true + MultiplayerPeer.CONNECTION_DISCONNECTED: + connection_failed.emit() + return false connection_failed.emit() return false func _on_connected() -> void: - var my_id := multiplayer.get_unique_id() + my_peer_id = multiplayer.get_unique_id() - # Build the local proxy tree at the exact paths the server's own RPC - # calls target -- see the class docstring for why this has to exist. _server_root = Node.new() _server_root.name = "Server" _server_root.set_script(preload("res://scripts/network_receiver_proxy.gd")) @@ -110,7 +93,7 @@ func _on_connected() -> void: _server_root.set("owner_client", self) _player_proxy = Node.new() - _player_proxy.name = "Player_%d" % my_id + _player_proxy.name = "Player_%d" % my_peer_id _player_proxy.set_script(preload("res://scripts/network_player_proxy.gd")) _server_root.add_child(_player_proxy) @@ -123,60 +106,56 @@ func disconnect_from_server() -> void: if _server_root and is_instance_valid(_server_root): _server_root.queue_free() multiplayer.multiplayer_peer = null + _prediction_buffer.clear() + _remote_states.clear() + + +func is_connected_to_server() -> bool: + return peer != null and peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED # --------------------------------------------------------------------------- -# Prediction -- called every physics frame by whatever drives input (a real -# controller, or a test harness). Applies input locally immediately (so it -# renders with zero perceived latency) and sends it to the server, per §5.1. +# Prediction -- call every physics frame from the local player controller. +# Applies input locally immediately (zero perceived latency) and sends it. # --------------------------------------------------------------------------- -func predict_and_send(delta: float, move_x: float, move_z: float, jump: bool) -> void: +func predict_and_send(delta: float, move_x: float, move_z: float, jump: bool, look_yaw: float = 0.0) -> void: _apply_movement(delta, move_x, move_z, jump) _seq += 1 - var record := { + _prediction_buffer.append({ "seq": _seq, - "input": {"move_x": move_x, "move_z": move_z, "jump": jump}, - "position": position, - "velocity": velocity, - } - _prediction_buffer.append(record) + "move_x": move_x, "move_z": move_z, "jump": jump, + "delta": delta, + "position": position, "velocity": velocity, + }) if _prediction_buffer.size() > PREDICTION_BUFFER_SIZE: _prediction_buffer.pop_front() if _player_proxy and is_instance_valid(_player_proxy): - _player_proxy.rpc_id(1, "rpc_request_input", record["input"], _seq) + _player_proxy.rpc_id(1, "rpc_request_input", + {"move_x": move_x, "move_z": move_z, "jump": jump, "look_yaw": look_yaw}, _seq) func _apply_movement(delta: float, move_x: float, move_z: float, jump: bool) -> void: - # Mirrors scripts/player_spawner.gd's _process_input + gravity/jump, - # intentionally without its ground-friction smoothing -- friction is a - # server-authoritative-feel detail, not something worth risking a - # prediction/server mismatch over in this first pass. Position still - # reconciles against the server's real (frictioned) result every - # snapshot, so this doesn't silently drift. if not _on_floor: velocity.y -= GRAVITY * delta + elif jump: + velocity.y = JUMP_VELOCITY - var direction := Vector3(move_x, 0.0, move_z) - if direction.length() > 0.01: - direction = direction.normalized() - velocity.x = direction.x * WALK_SPEED - velocity.z = direction.z * WALK_SPEED + var dir := Vector3(move_x, 0.0, move_z) + if dir.length() > 0.01: + dir = dir.normalized() + velocity.x = dir.x * WALK_SPEED + velocity.z = dir.z * WALK_SPEED else: velocity.x = 0.0 velocity.z = 0.0 - if jump and _on_floor: - velocity.y = JUMP_VELOCITY - position += velocity * delta - # Headless prediction has no collision shape of its own to test - # is_on_floor() against -- treat "at or below the spawn-plane ground" - # as grounded. A real render-side controller would swap this for the - # actual CharacterBody3D.is_on_floor() of whatever visual node this - # drives; this class only owns the *networking* half. + # Headless prediction has no collision shape; treat the spawn plane as the + # floor. A render-side controller drives the real CharacterBody3D and reads + # `position` back for reconciliation only. if position.y <= 0.0: position.y = 0.0 velocity.y = 0.0 @@ -186,49 +165,76 @@ func _apply_movement(delta: float, move_x: float, move_z: float, jump: bool) -> # --------------------------------------------------------------------------- -# Reconciliation -- called by _ReceiverScript when a snapshot arrives. +# Reconciliation -- _ReceiverProxy calls this when a snapshot arrives. # --------------------------------------------------------------------------- -func _handle_snapshot(snapshot_data: Dictionary) -> void: - var my_id := multiplayer.get_unique_id() - var players: Dictionary = snapshot_data.get("players", {}) - # Server keys player_states by peer_id -- Dictionary keys from JSON-ish - # RPC payloads can arrive as String or int depending on the transport - # encoding, so check both rather than assuming. - var my_state: Variant = players.get(my_id, players.get(str(my_id), null)) - if my_state == null or not (my_state is Dictionary): +func _handle_snapshot(snap: Dictionary) -> void: + var snap_tick: int = snap.get("tick", 0) + if snap_tick <= _last_snapshot_tick: + return # Stale / out-of-order (unreliable channel). + _last_snapshot_tick = snap_tick + + var players: Dictionary = snap.get("players", {}) + + # --- Remote players --- + _remote_states.clear() + for key in players: + var pid := int(key) + if pid == my_peer_id: + continue + _remote_states[pid] = players[key] + + # --- Local player: reconcile --- + var mine: Variant = players.get(my_peer_id, players.get(str(my_peer_id), null)) + if not (mine is Dictionary): return + var auth_pos: Vector3 = mine.get("position", position) + var auth_vel: Vector3 = mine.get("velocity", velocity) + var acked_seq: int = int(mine.get("last_seq", 0)) - var authoritative_pos: Vector3 = (my_state as Dictionary).get("position", position) + # Drop inputs the server has already applied. + while not _prediction_buffer.is_empty() and _prediction_buffer[0]["seq"] <= acked_seq: + _prediction_buffer.pop_front() - # Real limitation, noted explicitly rather than silently faked: the - # server's snapshot_state (scripts/server.gd's _broadcast_snapshot) does - # not currently echo back "last input seq processed for this peer" -- - # its `tick` field is the server's own internal simulation tick counter, - # a completely different clock from this client's per-input `_seq`. True - # tick-correlated rollback (docs/architecture/networking.md §5.2 step 4, - # "re-simulate any inputs that occurred after the server's tick") needs - # that ack echoed back per-peer -- a real, scoped follow-up, not - # something to fake here. Until then, reconcile against the MOST RECENT - # prediction (not a tick-matched one): still a real error measurement - # and a real correction, just without precise replay-from-exact-tick. - if _prediction_buffer.is_empty(): - return - var latest_entry: Dictionary = _prediction_buffer[-1] + # Re-simulate the still-in-flight inputs from the authoritative state. + var replay_pos := auth_pos + var replay_vel := auth_vel + var replay_floor := replay_pos.y <= 0.0 + for entry in _prediction_buffer: + var d: float = entry["delta"] + if not replay_floor: + replay_vel.y -= GRAVITY * d + elif entry["jump"]: + replay_vel.y = JUMP_VELOCITY + var dir := Vector3(entry["move_x"], 0.0, entry["move_z"]) + if dir.length() > 0.01: + dir = dir.normalized() + replay_vel.x = dir.x * WALK_SPEED + replay_vel.z = dir.z * WALK_SPEED + else: + replay_vel.x = 0.0 + replay_vel.z = 0.0 + replay_pos += replay_vel * d + if replay_pos.y <= 0.0: + replay_pos.y = 0.0 + replay_vel.y = 0.0 + replay_floor = true + else: + replay_floor = false - var error: float = (latest_entry["position"] as Vector3).distance_to(authoritative_pos) - if error <= RECONCILE_ERROR_THRESHOLD: - return # Close enough -- prediction was correct, nothing to do. + var error := position.distance_to(replay_pos) + if error > RECONCILE_ERROR_THRESHOLD: + position = replay_pos + velocity = replay_vel + _on_floor = replay_floor + reconciled.emit(error) - # Correct: snap to the authoritative position. Without a real ack-seq - # from the server there's no reliable "replay inputs after this point" - # window to recompute, so the buffer is cleared and prediction resumes - # fresh from the corrected position on the next input -- still visibly - # responsive (no waiting for a round trip before the *next* input - # applies), just without this pass replaying in-flight inputs. - position = authoritative_pos - velocity = Vector3.ZERO - _prediction_buffer.clear() - reconciled.emit(error) +# --------------------------------------------------------------------------- +# Remote-player state for rendering +# --------------------------------------------------------------------------- +## peer_id -> {position, rotation_y, velocity, on_floor, last_seq} from the most +## recent snapshot. Read this each frame to spawn/update remote avatars. +func get_remote_states() -> Dictionary: + return _remote_states diff --git a/scripts/network_player_proxy.gd b/scripts/network_player_proxy.gd index b756cb8..5d197a9 100644 --- a/scripts/network_player_proxy.gd +++ b/scripts/network_player_proxy.gd @@ -1,43 +1,11 @@ extends Node -## Client-side proxy for the server's real PlayerNode (scripts/ -## player_spawner.gd), attached at runtime to a Node named "Player_" -## under the /root/Server proxy by NetworkClient._on_connected(). +## Client-side proxy for the server's PlayerNode (scripts/player_spawner.gd), +## attached at runtime as "Player_" under the /root/Server proxy. ## -## rpc_request_input is the only one of these actually CALLED from this -## side -- the real implementations all live on the server. But two things -## force every @rpc method on the real PlayerNode to be mirrored here too, -## not just the one this class uses: -## 1. Godot's rpc_id() looks up the RPC transfer config (mode/reliability) -## from the LOCAL script before it will even send a call, so the method -## being called has to be declared locally with a matching annotation. -## 2. Godot's multiplayer layer also verifies an "RPC node checksum" -- -## both peers must declare the *same set* of @rpc methods at a matching -## path, or every RPC at that path starts failing, not just the one -## that's actually missing. So even rpc_request_interact/attack/ -## set_position/set_rotation, which this class never calls, have to be -## declared here with matching annotations or rpc_request_input itself -## breaks too. +## rpc_request_input is the only RPC the client sends. Godot resolves the RPC +## transfer config from the LOCAL node's declaration and verifies both peers +## declare the same @rpc set at this path, so it must match player_spawner.gd. @rpc("any_peer", "call_remote", "reliable") -func rpc_request_input(_input_state: Dictionary, _seq: int) -> void: - pass # Never invoked locally -- see the note above. - - -@rpc("any_peer", "call_remote", "reliable") -func rpc_request_interact(_target_id: int) -> void: - pass - - -@rpc("any_peer", "call_remote", "reliable") -func rpc_request_attack(_target_id: int, _aim_dir: Vector3) -> void: - pass - - -@rpc("authority", "call_remote", "reliable") -func rpc_set_position(_pos: Vector3) -> void: - pass - - -@rpc("authority", "call_remote", "reliable") -func rpc_set_rotation(_rot: Vector3) -> void: - pass +func rpc_request_input(_input: Dictionary, _seq: int) -> void: + pass # Real implementation is on the server. diff --git a/scripts/network_receiver_proxy.gd b/scripts/network_receiver_proxy.gd index c4ad4b5..844a616 100644 --- a/scripts/network_receiver_proxy.gd +++ b/scripts/network_receiver_proxy.gd @@ -1,38 +1,15 @@ extends Node -## Client-side proxy node, attached at runtime to a Node named "Server" at -## the SceneTree root by NetworkClient.connect_to_server(). Exists purely so -## Godot's RPC path-matching finds a receiver for _receive_snapshot -- see -## network_client.gd's class docstring for the full explanation of why this -## has to exist at this exact path. Not a real server in any sense. -## -## Godot's multiplayer layer verifies an "RPC node checksum" -- both peers -## must declare the *same set* of @rpc methods at a matching path, or every -## RPC at that path (not just the mismatched one) starts failing with -## "rpc node checksum failed". So every @rpc method scripts/server.gd -## declares on its own root "Server" node has to be mirrored here too, even -## the ones this proxy never actually needs to receive. +## Client-side proxy node, attached at runtime as a Node named "Server" at the +## SceneTree root by NetworkClient. Exists purely so Godot's RPC path-matching +## finds a receiver for _receive_snapshot (the server calls it on its own root +## "Server" node, so the client needs a node at /root/Server declaring the same +## @rpc set). Not a real server. var owner_client: Node = null -## Must match scripts/server.gd's own @rpc annotation on _receive_snapshot -## exactly -- Godot enforces the receiving method's declared mode/transfer -## config, a mismatch here would silently drop the call. +## Must match scripts/server.gd's @rpc annotation on _receive_snapshot exactly. @rpc("any_peer", "call_remote", "reliable") func _receive_snapshot(snapshot_data: Dictionary) -> void: if owner_client and is_instance_valid(owner_client): owner_client._handle_snapshot(snapshot_data) - - -## Mirrors scripts/server.gd's _kick -- never actually invoked on this side -## (the server calls it on a Player_ node's own rpc_id target, not this -## root proxy), declared only so the RPC checksum for this path matches. -@rpc("any_peer", "call_remote", "reliable") -func _kick(_reason: String) -> void: - pass - - -## Mirrors scripts/server.gd's _disconnect -- same reasoning as _kick above. -@rpc("call_remote", "reliable") -func _disconnect(_reason: String) -> void: - pass diff --git a/scripts/player_spawner.gd b/scripts/player_spawner.gd index 1b53a10..104b6ff 100644 --- a/scripts/player_spawner.gd +++ b/scripts/player_spawner.gd @@ -1,232 +1,75 @@ class_name PlayerNode extends CharacterBody3D -## PlayerNode - Server-side player entity. +## Server-authoritative player entity. ## -## Represents a connected player on the server. Handles movement, -## input processing, and state synchronization. +## One per connected peer, spawned as a child of the Server node. Movement runs +## in _physics_process at the project's fixed physics rate (30 Hz, matching +## scripts/server.gd's snapshot rate and the client's prediction step). -# --- Signals --- -signal health_changed(new_health: int) -signal player_died(player_node: PlayerNode) - - -# --- Constants --- +## Movement constants -- mirror scripts/server.gd (the source of truth) and +## scripts/network_client.gd (the client's prediction). const WALK_SPEED := 5.0 -const SPRINT_MULTIPLIER := 1.6 const JUMP_VELOCITY := 4.5 const GRAVITY := 9.8 -const MAX_FALL_SPEED := 20.0 -const FRICTION := 8.0 -const AIR_CONTROL := 0.3 -# --- Configurable --- -@export var walk_speed: float = WALK_SPEED -@export var sprint_multiplier: float = SPRINT_MULTIPLIER -@export var jump_velocity: float = JUMP_VELOCITY -@export var max_fall_speed: float = MAX_FALL_SPEED - -# --- State --- -@export var max_health: int = 100 -var current_health: int = 0 -var is_alive: bool = true -var is_sprinting: bool = false var peer_id: int = -1 -var player_name: String = "" -var entity_id: int = 0 -# --- Input buffer --- +## Highest input seq applied authoritatively so far (echoed in snapshots so +## the client can drop acked inputs and replay the rest). +var last_applied_seq: int = 0 + +## Queue of {seq:int, move_x:float, move_z:float, jump:bool, look_yaw:float} +## appended by rpc_request_input, drained each physics frame. var _input_queue: Array[Dictionary] = [] -var _last_processed_seq: int = -1 - -# --- Movement --- -var _velocity: Vector3 = Vector3.ZERO - - -func _ready() -> void: - current_health = max_health - velocity = _velocity - # Start alive - is_alive = true +var _look_yaw: float = 0.0 func _physics_process(delta: float) -> void: - if not is_alive: - return - - # Process input from queue - _process_input(delta) - - # Apply gravity + if _input_queue.is_empty(): + _integrate(0.0, 0.0, false, delta) + else: + # Reliable ENet preserves order; sort defensively anyway. + _input_queue.sort_custom(func(a, b): return a["seq"] < b["seq"]) + for input in _input_queue: + _look_yaw = input.get("look_yaw", _look_yaw) + _integrate(input.get("move_x", 0.0), input.get("move_z", 0.0), input.get("jump", false), delta) + last_applied_seq = input["seq"] + _input_queue.clear() + + rotation.y = _look_yaw + + +func _integrate(move_x: float, move_z: float, jump: bool, delta: float) -> void: if not is_on_floor(): velocity.y -= GRAVITY * delta - velocity.y = clampf(velocity.y, -max_fall_speed, max_fall_speed) - - # Apply friction on ground - if is_on_floor(): - var move_dir := Vector2(velocity.x, velocity.z) - if move_dir.length() > 0.01: - var friction_factor := clampf(FRICTION * delta, 0.0, 1.0) - velocity.x = move_dir.normalized().x * move_dir.length() * (1.0 - friction_factor) - velocity.z = move_dir.normalized().y * move_dir.length() * (1.0 - friction_factor) - else: - velocity.x = 0.0 - velocity.z = 0.0 - - # Move and slide - velocity.y = clampf(velocity.y, -max_fall_speed, max_fall_speed) - move_and_slide() - - # Update visual position (for remote clients via RPC) - _update_visual_state() + elif jump: + velocity.y = JUMP_VELOCITY - -func _process_input(delta: float) -> void: - """Process buffered input from the server's input queue.""" - if _input_queue.is_empty(): - return - - var input_data: Dictionary = _input_queue.pop_front() - var input_state: Dictionary = input_data.get("input_state", {}) - - # Calculate movement direction - var direction := Vector3.ZERO - direction.x = input_state.get("move_x", 0.0) - direction.z = input_state.get("move_z", 0.0) - - # Sprint check - is_sprinting = input_state.get("sprint", false) - - if direction.length() > 0.01: - direction = direction.normalized() - var speed = walk_speed * sprint_multiplier if is_sprinting else walk_speed - velocity.x = direction.x * speed - velocity.z = direction.z * speed + var dir := Vector3(move_x, 0.0, move_z) + if dir.length() > 0.01: + dir = dir.normalized() + velocity.x = dir.x * WALK_SPEED + velocity.z = dir.z * WALK_SPEED else: - # On ground, apply friction (handled in physics_process) - if not is_on_floor(): - velocity.x *= (1.0 - AIR_CONTROL * delta * 5.0) - velocity.z *= (1.0 - AIR_CONTROL * delta * 5.0) - - # Jump - if input_state.get("jump", false) and is_on_floor(): - velocity.y = jump_velocity + velocity.x = 0.0 + velocity.z = 0.0 - -func take_damage(amount: int, source_id: int) -> Dictionary: - """Take damage and return result dict.""" - if not is_alive: - return {"success": false, "reason": "already_dead"} - - current_health = max(0, current_health - amount) - health_changed.emit(current_health) - - if current_health <= 0: - die() - return {"success": true, "health": 0, "dead": true} - - return {"success": true, "health": current_health} - - -func die() -> void: - """Handle player death.""" - is_alive = false - velocity = Vector3.ZERO - player_died.emit(self) - - # Respawn after delay - await get_tree().create_timer(3.0).timeout - respawn() - - -func respawn() -> void: - """Respawn the player at spawn point.""" - current_health = max_health - is_alive = true - velocity = Vector3.ZERO - health_changed.emit(current_health) - - # Move to spawn point (default: origin) - position = Vector3.ZERO - - -func set_input_queue(inputs: Array[Dictionary]) -> void: - """Set the input queue from the server.""" - _input_queue = inputs - - -func get_state() -> Dictionary: - """Get current player state for replication.""" - return { - "entity_id": entity_id, - "peer_id": peer_id, - "position": position, - "rotation": rotation, - "health": current_health, - "max_health": max_health, - "is_alive": is_alive, - "is_sprinting": is_sprinting, - } + move_and_slide() + if is_on_floor() and velocity.y < 0.0: + velocity.y = 0.0 func get_server_state() -> Dictionary: - """Get current player state for server snapshot broadcast.""" - return get_state() - - -func process_server_input(tick: int) -> void: - """Called by server tick to process buffered input.""" - # This is called from the server's _tick loop. - # The input queue is populated by RPCs from clients. - pass - - -func _update_visual_state() -> void: - """Update visual state (for remote interpolation).""" - # In a full implementation, this would update a remote player's - # visual representation. For now, the node itself is the authority. - pass - - -# --- RPCs --- - -@rpc("any_peer", "call_remote", "reliable") -func rpc_request_input(input_state: Dictionary, seq: int) -> void: - """Receive input from client.""" - if not is_alive: - return - # Store for processing in _physics_process - _input_queue.append({ - "input_state": input_state, - "seq": seq, - "timestamp": Time.get_ticks_msec(), - }) + return { + "position": global_position, + "rotation_y": rotation.y, + "velocity": velocity, + "on_floor": is_on_floor(), + } @rpc("any_peer", "call_remote", "reliable") -func rpc_request_interact(target_id: int) -> void: - """Handle interaction request from client.""" - if not is_alive: - return - # TODO: Implement interaction logic - print("Player %d interacted with entity %d" % [peer_id, target_id]) - - -@rpc("any_peer", "call_remote", "reliable") -func rpc_request_attack(target_id: int, aim_dir: Vector3) -> void: - """Handle attack request from client.""" - if not is_alive: - return - # TODO: Implement attack logic - print("Player %d attacked entity %d with aim %s" % [peer_id, target_id, aim_dir]) - - -@rpc("authority", "call_remote", "reliable") -func rpc_set_position(pos: Vector3) -> void: - """Server authoritative position update.""" - position = pos - - -@rpc("authority", "call_remote", "reliable") -func rpc_set_rotation(rot: Vector3) -> void: - """Server authoritative rotation update.""" - rotation = rot +func rpc_request_input(input: Dictionary, seq: int) -> void: + var d := input.duplicate() + d["seq"] = seq + _input_queue.append(d) diff --git a/scripts/run_multiplayer_2p_test.sh b/scripts/run_multiplayer_2p_test.sh new file mode 100755 index 0000000..51647c7 --- /dev/null +++ b/scripts/run_multiplayer_2p_test.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Two real client processes against one real dedicated server: client A must +# see client B and its own movement must round-trip through the server. +# +# Usage: scripts/run_multiplayer_2p_test.sh +# Exit 0 iff A reports moved=true and remotes>=1. +set -uo pipefail +cd "$(dirname "$0")/.." + +GODOT_BIN="${GODOT_BIN:-godot}" +command -v "$GODOT_BIN" >/dev/null 2>&1 || GODOT_BIN="$HOME/godot/godot" +PORT=7787 + +cleanup() { kill "${SRV_PID:-}" "${B_PID:-}" 2>/dev/null; wait 2>/dev/null; } +trap cleanup EXIT + +echo "=== starting dedicated server on $PORT ===" +"$GODOT_BIN" --headless --path . res://scenes/server.tscn -- --port "$PORT" >/tmp/mp2p_server.log 2>&1 & +SRV_PID=$! +sleep 3 + +echo "=== starting client B (idle observer) ===" +"$GODOT_BIN" --headless --path . --script res://scripts/mp_test_client.gd -- --role b --port "$PORT" >/tmp/mp2p_b.log 2>&1 & +B_PID=$! +# Wait until B is actually connected before starting A. +for _ in $(seq 1 30); do grep -q 'RESULT b connected' /tmp/mp2p_b.log && break; sleep 1; done + +echo "=== running client A (mover) ===" +"$GODOT_BIN" --headless --path . --script res://scripts/mp_test_client.gd -- --role a --port "$PORT" 2>&1 | tee /tmp/mp2p_a.log +A_LINE=$(grep '^RESULT a' /tmp/mp2p_a.log || true) + +wait "$B_PID" 2>/dev/null +B_LINE=$(grep '^RESULT b' /tmp/mp2p_b.log || true) + +echo +echo "server: $(grep -c 'joined' /tmp/mp2p_server.log) joins seen" +echo "A: ${A_LINE:-}" +echo "B: ${B_LINE:-}" + +if echo "$A_LINE" | grep -q 'moved=true' && echo "$A_LINE" | grep -qE 'remotes=[1-9]'; then + echo "=== SMOKE RESULT: PASS ===" + exit 0 +fi +echo "=== SMOKE RESULT: FAIL ===" +exit 1 diff --git a/scripts/server.gd b/scripts/server.gd index fdfa38d..5850b91 100644 --- a/scripts/server.gd +++ b/scripts/server.gd @@ -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()