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:
2026-09-05 22:00:18 +00:00
parent 587a15a52e
commit 682f1f89f7
9 changed files with 437 additions and 525 deletions
+139 -133
View File
@@ -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_<peer_id>". So the client needs local nodes at /root/Server (for the
## server-initiated _receive_snapshot) and /root/Server/Player_<own_id> (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_<peer_id>"
## (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_<own_peer_id> 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_<peer_id> 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