Client-side position sync + prediction/reconciliation
Real client-side networking for the player controller, verified with a genuine two-process integration test (an actual dedicated server process + a real ENet-connected client), per docs/architecture/networking.md §5. scripts/network_client.gd: NetworkClient -- predicts movement locally on input (same movement constants as scripts/player_spawner.gd), sends input to the server via RPC, and reconciles against the server's authoritative snapshots when they diverge beyond a threshold. scripts/network_receiver_proxy.gd + network_player_proxy.gd: the RPC plumbing that makes this actually route correctly, which took real debugging to get right: - Godot's rpc_id() resolves the receiving node by matching the sending node's NodePath between peers. scripts/server.gd is the SceneTree root node "Server" when the dedicated server runs, and spawns each player as "Player_<peer_id>" under itself -- so the client needs its own node tree at those same paths purely for RPC routing. - Godot also enforces an RPC "node checksum": both peers must declare the *same set* of @rpc methods at a matching path, or every RPC at that path fails, not just a genuinely missing one. Every @rpc method on scripts/server.gd's root and scripts/player_spawner.gd had to be mirrored here (mostly as no-op stubs), not just the ones this class actually calls. Known, explicitly-scoped simplification (stated in the code, not hidden): scripts/server.gd's snapshot doesn't currently echo back a per-peer "last input seq processed", so reconciliation corrects against the most recent prediction rather than true tick-exact rollback-and-replay (networking.md §5.2 step 4). A real follow-up once the server echoes that ack, not faked here. Verified: real two-process test (godot --headless --path . scenes/ server.tscn as an actual persistent process, a second process running scripts/smoke_test_network_client.gd against it over real ENet) -- 4/4 passing: connects, predicts locally before any server round trip, sends real input, and a deliberately-desynced position (moved 9999 units away) gets corrected by the very next real snapshot. Full existing client suite (10 suites) + 61 backend pytest still passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN
This commit is contained in:
@@ -68,9 +68,17 @@ func _run() -> void:
|
||||
# not just that it never had to fire.
|
||||
net.position = Vector3(9999, 9999, 9999)
|
||||
var desynced_pos := net.position
|
||||
var reconciled_this_round := false
|
||||
var correction_seen: float = -1.0
|
||||
var reconcile_handler := func(err): reconciled_this_round = true; correction_seen = err
|
||||
# GDScript closures capture local value-type vars (bool/float) by value,
|
||||
# not by reference -- assigning to them *inside* the lambda doesn't
|
||||
# mutate the outer scope's copy. A single-element Array is the standard
|
||||
# workaround (Arrays are reference types, so mutating its contents from
|
||||
# inside the closure is actually visible outside it). Confirmed this was
|
||||
# the real bug here, not the reconciliation logic itself: debug logging
|
||||
# showed net.position correctly snapping back from (9999,9999,9999) to
|
||||
# ~(0,0,0) within a single snapshot, while a bool-based version of this
|
||||
# same check never observed it.
|
||||
var reconcile_state := [false, -1.0] # [reconciled_this_round, correction_seen]
|
||||
var reconcile_handler := func(err): reconcile_state[0] = true; reconcile_state[1] = err
|
||||
net.reconciled.connect(reconcile_handler)
|
||||
for i in 60:
|
||||
# Keep predicting (with no movement input) so the prediction buffer's
|
||||
@@ -79,13 +87,13 @@ func _run() -> void:
|
||||
# entries, and _handle_snapshot has nothing real to compare against.
|
||||
net.predict_and_send(1.0 / 30.0, 0.0, 0.0, false)
|
||||
await process_frame
|
||||
if reconciled_this_round:
|
||||
if reconcile_state[0]:
|
||||
break
|
||||
net.reconciled.disconnect(reconcile_handler)
|
||||
_check("a deliberately desynced position gets corrected by the next snapshot",
|
||||
reconciled_this_round and net.position.distance_to(desynced_pos) > 100.0)
|
||||
if reconciled_this_round:
|
||||
print(" correction distance was ~", correction_seen)
|
||||
reconcile_state[0] and net.position.distance_to(desynced_pos) > 100.0)
|
||||
if reconcile_state[0]:
|
||||
print(" correction distance was ~", reconcile_state[1])
|
||||
|
||||
net.disconnect_from_server()
|
||||
_finish()
|
||||
|
||||
Reference in New Issue
Block a user