Compare commits

...

5 Commits

Author SHA1 Message Date
claude-checkpoint-tooling bd44ecba14 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
2026-08-21 01:21:37 +00:00
coder-bot bae326e14c WIP checkpoint: Client-side position sync + prediction/reconciliation 2026-08-21 01:18:34 +00:00
coder-bot 140db9df91 Recovered orphaned WIP before switching to a new task 2026-08-21 01:17:16 +00:00
coder-bot 2dff83bae3 WIP checkpoint: Client-side position sync + prediction/reconciliation 2026-08-21 01:17:13 +00:00
coder-bot 1a8821e3c8 WIP checkpoint: Client-side position sync + prediction/reconciliation 2026-08-21 01:14:07 +00:00
9 changed files with 605 additions and 15 deletions
+76 -13
View File
@@ -1,14 +1,77 @@
63f7b34db8d8cdea90c76aacccf841ec 63f7b34db8d8cdea90c76aacccf841ec
::res://::1787259973 ::res://::1787275036
README.md::TextFile::-1::1787146493::0::1::::<><><>0<>0<><>:: export_presets.cfg::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://docs/::1787259973 README.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://docs/architecture/::1787259973 ::res://addons/::1787272930
avatar.md::TextFile::-1::1787146493::0::1::::<><><>0<>0<><>:: ::res://assets/::1787272930
content_moderation.md::TextFile::-1::1787146493::0::1::::<><><>0<>0<><>:: ::res://backend/::1787272930
networking.md::TextFile::-1::1787146493::0::1::::<><><>0<>0<><>:: alembic.ini::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://scenes/::1787259973 docker-compose.yml::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
player.tscn::PackedScene::-1::1787259973::0::1::::<><><>0<>0<><>::res://scripts/player_spawner.gd pytest.ini::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
server.tscn::PackedScene::-1::1787259973::0::1::::<><><>0<>0<><>:: requirements.txt::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://scripts/::1787259973 ::res://backend/alembic/::1787272930
player_spawner.gd::GDScript::3637338339553764865::1787259973::0::1::::PlayerNode<>CharacterBody3D<><>0<>0<><>:: ::res://backend/alembic/versions/::1787272930
server.gd::GDScript::6336910781803684784::1787259973::0::1::::<>Node<><>0<>0<><>:: ::res://backend/app/::1787272930
::res://backend/app/models/::1787272930
::res://backend/app/routers/::1787272930
::res://backend/app/services/::1787272930
::res://backend/tests/::1787272930
::res://docs/::1787272930
::res://docs/architecture/::1787272930
avatar.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
content_moderation.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
editor.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
networking.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
pc2_test_level.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
scripting_sandbox.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
world.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://docs/checkpoints/::1787272930
PC1.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://docs/planning/::1787272930
client-server-wiring.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
economy.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
social.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://docs/testing/::1787275036
README.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>::
test_inventory.md::TextFile::-1::1787275036::0::1::::<><><>0<>0<><>::
::res://scenes/::1787275036
game.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scenes/worlds/test_level.tscn<>res://scenes/player.tscn
main.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/chunk_stream_manager.gd
main_menu.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/main_menu.gd
player.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/player.gd
server.tscn::PackedScene::-1::1787275036::0::1::::<><><>0<>0<><>::res://scripts/server.gd
server_player.tscn::PackedScene::-1::1787275036::0::1::::<><><>0<>0<><>::res://scripts/player_spawner.gd
world_list.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::res://scripts/world_list.gd
::res://scenes/worlds/::1787272930
test_level.tscn::PackedScene::-1::1787272930::0::1::::<><><>0<>0<><>::
::res://scripts/::1787275098
api_client.gd::GDScript::5412288119287126975::1787272930::0::1::::<>Node<><>0<>0<><>::
chunk.gd::GDScript::7364239597441807588::1787272930::0::1::::Chunk<>Node3D<><>0<>0<><>::
chunk_data_decoder.gd::GDScript::3922068956043581888::1787272930::0::1::::ChunkDataDecoder<>RefCounted<><>0<>0<><>::
chunk_scene.gd::GDScript::9207602004456600331::1787272930::0::1::::ChunkScene<>Node3D<><>0<>0<><>::
chunk_stream_manager.gd::GDScript::2366645676568508746::1787272930::0::1::::ChunkStreamManager<>Node<><>0<>0<><>::
dynamic_object_node.gd::GDScript::8863349716576659118::1787272930::0::1::::DynamicObjectNode<>Node3D<><>0<>0<><>::
main_menu.gd::GDScript::2834562513277893052::1787272930::0::1::::<>Control<><>0<>0<><>::
network_client.gd::GDScript::5408296350353838185::1787275098::0::1::::NetworkClient<>Node<><>0<>0<><>::
network_player_proxy.gd::GDScript::4787436336771232797::1787275036::0::1::::<>Node<><>0<>0<><>::
network_receiver_proxy.gd::GDScript::6742760436861380520::1787275036::0::1::::<>Node<><>0<>0<><>::
player.gd::GDScript::4510795778937304264::1787275036::0::1::::Player<>CharacterBody3D<><>0<>0<><>::
player_spawner.gd::GDScript::3637338339553764865::1787275036::0::1::::PlayerNode<>CharacterBody3D<><>0<>0<><>::
server.gd::GDScript::6336910781803684784::1787275036::0::1::::<>Node<><>0<>0<><>::
smoke_test_api.gd::GDScript::1760923834350699002::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_auth.gd::GDScript::3027816090663230577::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_e2e.gd::GDScript::9086409960595132737::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_enter_world.gd::GDScript::4171815758762850910::1787275036::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_main_menu_back.gd::GDScript::4436937414562273157::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_main_menu_full_flow.gd::GDScript::1948803640558784862::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_main_menu_ui.gd::GDScript::396938597300585484::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_network_client.gd::GDScript::3109386529184855644::1787275067::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_player.gd::GDScript::3497042634752676337::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_test_level.gd::GDScript::2607555612829866949::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
smoke_test_world_list.gd::GDScript::4300757286283894147::1787272930::0::1::::<>SceneTree<><>0<>0<><>::
static_object_node.gd::GDScript::5276389741929225897::1787272930::0::1::::StaticObjectNode<>Node3D<><>0<>0<><>::
tile_def.gd::GDScript::2430528079284085109::1787272930::0::1::::TileDef<>RefCounted<><>0<>0<><>::
tile_layer.gd::GDScript::4797906255131447844::1787272930::0::1::::TileLayer<>RefCounted<><>0<>0<><>::
tile_layer_node.gd::GDScript::9033225196818247616::1787272930::0::1::::TileLayerNode<>Node3D<><>0<>0<><>::
world_list.gd::GDScript::6119591848847211153::1787275036::0::1::::<>Control<><>0<>0<><>::
world_object.gd::GDScript::6429674491464200335::1787272930::0::1::::WorldObject<>RefCounted<><>0<>0<><>::
+1 -1
View File
@@ -8,4 +8,4 @@ executable_path="/home/daddy/godot/godot"
[recent_files] [recent_files]
scenes=["res://scenes/server.tscn"] scenes=["res://scenes/main_menu.tscn", "res://scenes/server.tscn"]
+96
View File
@@ -1,4 +1,60 @@
list=[{ list=[{
"base": &"Node3D",
"class": &"Chunk",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/chunk.gd"
}, {
"base": &"RefCounted",
"class": &"ChunkDataDecoder",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/chunk_data_decoder.gd"
}, {
"base": &"Node3D",
"class": &"ChunkScene",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/chunk_scene.gd"
}, {
"base": &"Node",
"class": &"ChunkStreamManager",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/chunk_stream_manager.gd"
}, {
"base": &"Node3D",
"class": &"DynamicObjectNode",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/dynamic_object_node.gd"
}, {
"base": &"Node",
"class": &"NetworkClient",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/network_client.gd"
}, {
"base": &"CharacterBody3D",
"class": &"Player",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/player.gd"
}, {
"base": &"CharacterBody3D", "base": &"CharacterBody3D",
"class": &"PlayerNode", "class": &"PlayerNode",
"icon": "", "icon": "",
@@ -6,4 +62,44 @@ list=[{
"is_tool": false, "is_tool": false,
"language": &"GDScript", "language": &"GDScript",
"path": "res://scripts/player_spawner.gd" "path": "res://scripts/player_spawner.gd"
}, {
"base": &"Node3D",
"class": &"StaticObjectNode",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/static_object_node.gd"
}, {
"base": &"RefCounted",
"class": &"TileDef",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/tile_def.gd"
}, {
"base": &"RefCounted",
"class": &"TileLayer",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/tile_layer.gd"
}, {
"base": &"Node3D",
"class": &"TileLayerNode",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/tile_layer_node.gd"
}, {
"base": &"RefCounted",
"class": &"WorldObject",
"icon": "",
"is_abstract": false,
"is_tool": false,
"language": &"GDScript",
"path": "res://scripts/world_object.gd"
}] }]
Binary file not shown.
+3 -1
View File
@@ -53,7 +53,8 @@ these yet):
## Godot client — `scripts/smoke_test_*.gd` ## Godot client — `scripts/smoke_test_*.gd`
**10 smoke tests merged into `main`.** Each needs a live backend — **11 smoke tests merged into `main`.** Ten need a live backend, one
(`smoke_test_network_client.gd`) needs a live dedicated server instead —
`AGP_API_BASE_URL` env var, falling back to the `AGP_API_BASE_URL` env var, falling back to the
`application/agp/api_base_url` project setting (default `application/agp/api_base_url` project setting (default
`http://127.0.0.1:8000`) if unset. `http://127.0.0.1:8000`) if unset.
@@ -70,6 +71,7 @@ these yet):
| `smoke_test_test_level.gd` | Loads `scenes/worlds/test_level.tscn` directly (the level is player-agnostic and self-contained per `docs/architecture/pc2_test_level.md` §4) and confirms it has real content: 5 props, a `DirectionalLight3D` ("Sun"), and a `WorldEnvironment` — not an empty void. | | `smoke_test_test_level.gd` | Loads `scenes/worlds/test_level.tscn` directly (the level is player-agnostic and self-contained per `docs/architecture/pc2_test_level.md` §4) and confirms it has real content: 5 props, a `DirectionalLight3D` ("Sun"), and a `WorldEnvironment` — not an empty void. |
| `smoke_test_player.gd` | Loads `scenes/game.tscn` and confirms the player exists as a real `CharacterBody3D` with a `CollisionShape3D`+shape, a `CapsuleMesh`, and spawns at the level's `PlayerSpawn` marker position. Also asserts `JUMP_VELOCITY` is positive and a `Camera3D` exists and is `current` — both were real bugs found 2026-08-20/21 (jump velocity was negative, pushing the player down instead of up; no camera existed at all, so the game was unplayable even though the character could technically move). Structural checks only — real movement/jump *direction* correctness was verified separately via a throwaway headless physics simulation during development, not by an automated test that's part of this suite. | | `smoke_test_player.gd` | Loads `scenes/game.tscn` and confirms the player exists as a real `CharacterBody3D` with a `CollisionShape3D`+shape, a `CapsuleMesh`, and spawns at the level's `PlayerSpawn` marker position. Also asserts `JUMP_VELOCITY` is positive and a `Camera3D` exists and is `current` — both were real bugs found 2026-08-20/21 (jump velocity was negative, pushing the player down instead of up; no camera existed at all, so the game was unplayable even though the character could technically move). Structural checks only — real movement/jump *direction* correctness was verified separately via a throwaway headless physics simulation during development, not by an automated test that's part of this suite. |
| `smoke_test_enter_world.gd` | The full PC2 path end to end: register → seed a real world → load `world_list.tscn` → click the real "Enter World" button on a real card (found by walking the card's children, not by index, so it doesn't silently pass if the card layout changes) → confirms `scenes/game.tscn` actually becomes `current_scene` with the player spawned at the level's `PlayerSpawn` marker → sends a real `KEY_ESCAPE` input event (not a direct method call) and confirms it returns to `world_list.tscn`. This is the test that proves "browse → enter → walk around → leave" actually works as one continuous flow, not just that each piece works in isolation. | | `smoke_test_enter_world.gd` | The full PC2 path end to end: register → seed a real world → load `world_list.tscn` → click the real "Enter World" button on a real card (found by walking the card's children, not by index, so it doesn't silently pass if the card layout changes) → confirms `scenes/game.tscn` actually becomes `current_scene` with the player spawned at the level's `PlayerSpawn` marker → sends a real `KEY_ESCAPE` input event (not a direct method call) and confirms it returns to `world_list.tscn`. This is the test that proves "browse → enter → walk around → leave" actually works as one continuous flow, not just that each piece works in isolation. |
| `smoke_test_network_client.gd` | **Real two-process integration test** for `scripts/network_client.gd` (client-side prediction + reconciliation, docs/architecture/networking.md §5) — needs an actual dedicated server running first: `godot --headless --path . scenes/server.tscn &`, *not* the usual HTTP backend. Connects a real `NetworkClient` over ENet, confirms local input prediction moves the player immediately (no waiting on a round trip), confirms real snapshots arrive and get processed, then deliberately desyncs the predicted position by 9999 units and confirms the very next snapshot corrects it back — the actual proof reconciliation logic works, not just that it never had to fire. Known simplification, stated in the script's own docstring: the server'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 (docs/architecture/networking.md §5.2 step 4) — a real, scoped follow-up once the server echoes that ack, not something faked here. |
Run (once, to build the class cache): `godot --headless --path . --import`, Run (once, to build the class cache): `godot --headless --path . --import`,
then for each smoke test: `godot --headless --path . --script then for each smoke test: `godot --headless --path . --script
+234
View File
@@ -0,0 +1,234 @@
class_name NetworkClient
extends Node
## Client-side networked player: input prediction + server reconciliation.
##
## 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, 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):
## var net := NetworkClient.new()
## get_tree().root.add_child(net)
## var ok := await net.connect_to_server("127.0.0.1", 7777)
# ---------------------------------------------------------------------------
# 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.
# ---------------------------------------------------------------------------
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
signal reconciled(correction_distance: float)
signal connected_to_server
signal connection_failed
var peer: ENetMultiplayerPeer
var _server_root: Node # local /root/Server proxy
var _player_proxy: Node # local /root/Server/Player_<peer_id> proxy
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.
var _prediction_buffer: Array[Dictionary] = []
var position: Vector3 = Vector3.ZERO
var velocity: Vector3 = Vector3.ZERO
var _on_floor: bool = false
func connect_to_server(host: String, port: int) -> bool:
peer = ENetMultiplayerPeer.new()
var err := peer.create_client(host, port)
if err != OK:
push_error("NetworkClient: create_client failed: %d" % err)
connection_failed.emit()
return false
multiplayer.multiplayer_peer = peer
multiplayer.connected_to_server.connect(_on_connected)
multiplayer.connection_failed.connect(func():
connection_failed.emit()
)
for i in 300: # ~5s at 60fps
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
connection_failed.emit()
return false
func _on_connected() -> void:
var my_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"))
get_tree().root.add_child(_server_root)
_server_root.set("owner_client", self)
_player_proxy = Node.new()
_player_proxy.name = "Player_%d" % my_id
_player_proxy.set_script(preload("res://scripts/network_player_proxy.gd"))
_server_root.add_child(_player_proxy)
connected_to_server.emit()
func disconnect_from_server() -> void:
if peer:
peer.close()
if _server_root and is_instance_valid(_server_root):
_server_root.queue_free()
multiplayer.multiplayer_peer = null
# ---------------------------------------------------------------------------
# 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.
# ---------------------------------------------------------------------------
func predict_and_send(delta: float, move_x: float, move_z: float, jump: bool) -> void:
_apply_movement(delta, move_x, move_z, jump)
_seq += 1
var record := {
"seq": _seq,
"input": {"move_x": move_x, "move_z": move_z, "jump": jump},
"position": position,
"velocity": velocity,
}
_prediction_buffer.append(record)
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)
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
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
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.
if position.y <= 0.0:
position.y = 0.0
velocity.y = 0.0
_on_floor = true
else:
_on_floor = false
# ---------------------------------------------------------------------------
# Reconciliation -- called by _ReceiverScript 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):
return
var authoritative_pos: Vector3 = (my_state as Dictionary).get("position", position)
# 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]
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.
# 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)
+43
View File
@@ -0,0 +1,43 @@
extends Node
## Client-side proxy for the server's real PlayerNode (scripts/
## player_spawner.gd), attached at runtime to a Node named "Player_<peer_id>"
## under the /root/Server proxy by NetworkClient._on_connected().
##
## 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("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
+38
View File
@@ -0,0 +1,38 @@
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.
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.
@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_<id> 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
+114
View File
@@ -0,0 +1,114 @@
extends SceneTree
## Real integration test for NetworkClient (client-side prediction +
## reconciliation) against an actual dedicated server process.
##
## This does NOT boot its own server -- run scenes/server.tscn as a separate
## process first (see docs/architecture/networking.md's ENet/port setup):
## godot --headless --path . scenes/server.tscn &
## then, from the same project directory:
## godot --headless --path . --script res://scripts/smoke_test_network_client.gd
##
## Verifies: the client can actually connect over ENet, local prediction
## moves the player immediately on input (no waiting for the server),
## server snapshots actually arrive and update the client's authoritative
## view, and a deliberately-desynced predicted position gets corrected by
## reconciliation.
var passed := 0
var failed := 0
func _initialize() -> void:
_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", 7777)
if not _check("connected to the real dedicated server", ok):
printerr(" Is `godot --headless --path . scenes/server.tscn` running on port 7777?")
_finish()
return
# Give the server a few frames to process the connection and spawn our
# player node before we start sending input.
for i in 10:
await process_frame
# 1. Prediction: pressing "forward" should move the local position
# immediately, without waiting for any server round trip.
var pos_before: Vector3 = net.position
for i in 10:
net.predict_and_send(1.0 / 30.0, 0.0, -1.0, false) # move -Z (forward)
await process_frame
var pos_after_predict: Vector3 = net.position
_check("local prediction moved the player without waiting for the server",
pos_after_predict.distance_to(pos_before) > 0.1)
# 2. Snapshots actually arrive from the real server and get processed --
# wait for at least one reconciliation-eligible snapshot by giving it
# real time (server broadcasts at its own tick rate).
var got_snapshot := false
net.reconciled.connect(func(_err): got_snapshot = true)
for i in 60:
net.predict_and_send(1.0 / 30.0, 0.0, -1.0, false)
await process_frame
# Reconciliation only actually fires on a real mismatch -- absence of a
# large mismatch is a PASS outcome too (means prediction tracked the
# server closely), so this alone isn't proof snapshots arrived. Check
# that directly instead:
_check("prediction buffer has entries (input was actually sent)", net._prediction_buffer.size() > 0)
# 3. Deliberately desync prediction from reality, then confirm the next
# snapshot corrects it -- the real proof reconciliation logic works,
# not just that it never had to fire.
net.position = Vector3(9999, 9999, 9999)
var desynced_pos := net.position
# 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
# latest entry actually reflects the desynced position -- without
# this, the buffer either empties out or keeps stale pre-desync
# 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 reconcile_state[0]:
break
net.reconciled.disconnect(reconcile_handler)
_check("a deliberately desynced position gets corrected by the next snapshot",
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()
func _check(description: String, condition: bool) -> bool:
if condition:
print(" PASS: ", description)
passed += 1
else:
printerr(" FAIL: ", description)
failed += 1
return condition
func _finish() -> void:
print("\n=== SMOKE RESULT: %d/%d passed ===" % [passed, passed + failed])
quit(0 if failed == 0 else 1)