From 1a8821e3c80e134844e287053ece203afe9db865 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Fri, 21 Aug 2026 01:14:07 +0000 Subject: [PATCH 1/5] WIP checkpoint: Client-side position sync + prediction/reconciliation --- .godot/editor/filesystem_cache10 | 89 ++++++++-- .godot/editor/project_metadata.cfg | 2 +- .godot/global_script_class_cache.cfg | 96 +++++++++++ .godot/uid_cache.bin | Bin 82 -> 1348 bytes scripts/network_client.gd | 235 +++++++++++++++++++++++++++ scripts/network_player_proxy.gd | 43 +++++ scripts/network_receiver_proxy.gd | 38 +++++ scripts/smoke_test_network_client.gd | 101 ++++++++++++ 8 files changed, 590 insertions(+), 14 deletions(-) create mode 100644 scripts/network_client.gd create mode 100644 scripts/network_player_proxy.gd create mode 100644 scripts/network_receiver_proxy.gd create mode 100644 scripts/smoke_test_network_client.gd diff --git a/.godot/editor/filesystem_cache10 b/.godot/editor/filesystem_cache10 index 021c67e..94fe177 100644 --- a/.godot/editor/filesystem_cache10 +++ b/.godot/editor/filesystem_cache10 @@ -1,14 +1,77 @@ 63f7b34db8d8cdea90c76aacccf841ec -::res://::1787259973 -README.md::TextFile::-1::1787146493::0::1::::<><><>0<>0<><>:: -::res://docs/::1787259973 -::res://docs/architecture/::1787259973 -avatar.md::TextFile::-1::1787146493::0::1::::<><><>0<>0<><>:: -content_moderation.md::TextFile::-1::1787146493::0::1::::<><><>0<>0<><>:: -networking.md::TextFile::-1::1787146493::0::1::::<><><>0<>0<><>:: -::res://scenes/::1787259973 -player.tscn::PackedScene::-1::1787259973::0::1::::<><><>0<>0<><>::res://scripts/player_spawner.gd -server.tscn::PackedScene::-1::1787259973::0::1::::<><><>0<>0<><>:: -::res://scripts/::1787259973 -player_spawner.gd::GDScript::3637338339553764865::1787259973::0::1::::PlayerNode<>CharacterBody3D<><>0<>0<><>:: -server.gd::GDScript::6336910781803684784::1787259973::0::1::::<>Node<><>0<>0<><>:: +::res://::1787274081 +export_presets.cfg::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>:: +README.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>:: +::res://addons/::1787272930 +::res://assets/::1787272930 +::res://backend/::1787272930 +alembic.ini::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>:: +docker-compose.yml::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>:: +pytest.ini::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>:: +requirements.txt::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>:: +::res://backend/alembic/::1787272930 +::res://backend/alembic/versions/::1787272930 +::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/::1787274081 +README.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>:: +test_inventory.md::TextFile::-1::1787274081::0::1::::<><><>0<>0<><>:: +::res://scenes/::1787274081 +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::1787274081::0::1::::<><><>0<>0<><>::res://scripts/server.gd +server_player.tscn::PackedScene::-1::1787274081::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/::1787274812 +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::1787274408::0::1::::NetworkClient<>Node<><>0<>0<><>:: +network_player_proxy.gd::GDScript::4787436336771232797::1787274812::0::1::::<>Node<><>0<>0<><>:: +network_receiver_proxy.gd::GDScript::6742760436861380520::1787274804::0::1::::<>Node<><>0<>0<><>:: +player.gd::GDScript::4510795778937304264::1787274081::0::1::::Player<>CharacterBody3D<><>0<>0<><>:: +player_spawner.gd::GDScript::3637338339553764865::1787274081::0::1::::PlayerNode<>CharacterBody3D<><>0<>0<><>:: +server.gd::GDScript::6336910781803684784::1787274081::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::1787274081::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::1787274351::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::1787274081::0::1::::<>Control<><>0<>0<><>:: +world_object.gd::GDScript::6429674491464200335::1787272930::0::1::::WorldObject<>RefCounted<><>0<>0<><>:: diff --git a/.godot/editor/project_metadata.cfg b/.godot/editor/project_metadata.cfg index 32e0564..fbf5d53 100644 --- a/.godot/editor/project_metadata.cfg +++ b/.godot/editor/project_metadata.cfg @@ -8,4 +8,4 @@ executable_path="/home/daddy/godot/godot" [recent_files] -scenes=["res://scenes/server.tscn"] +scenes=["res://scenes/main_menu.tscn", "res://scenes/server.tscn"] diff --git a/.godot/global_script_class_cache.cfg b/.godot/global_script_class_cache.cfg index d3081bc..68ebafa 100644 --- a/.godot/global_script_class_cache.cfg +++ b/.godot/global_script_class_cache.cfg @@ -1,4 +1,60 @@ 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", "class": &"PlayerNode", "icon": "", @@ -6,4 +62,44 @@ list=[{ "is_tool": false, "language": &"GDScript", "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" }] diff --git a/.godot/uid_cache.bin b/.godot/uid_cache.bin index d020f748272913ae5846a8ad08b41da46aa7c5e5..91bf0931afdae1815823039f7eaa7cb72156b280 100644 GIT binary patch literal 1348 zcmb1RU|`rUty>c3FXJr@WEZ6tTj}c;Cl_TFloabH7G%aJ=VYelmFT6XJSj=4d&*&) zCWfIbIioZ$8>r&I%n7yc|Kyq}V^5?%8Qdz z^HPE8yedU>w=myOP{pOLq$o8pH$FEpFEJf#@Vc})1+zagRI6ZUNU6+A%*{-W&rixq zO)iPg1G)rg!5+(rh0~J5)TJ=g=O$+6#pkBxl>${|UU(SvK!ML&9z#`LYDsy1Q8pr| zWWR9l`<-0igy~|K(t@1C%G9Fxf};G2N}$;*)Yr8r^c707MH{WQw&gP;T%K9jo(GrwO07#}$)KrOA;=ZgwyPqx*<=Ms1<$0sEwgA#Jw z#JWFIySSzGiPM!1Y?g*mBZsX0JbHQ$xj`X0UB8m|s;ILGH? z7K2KNmG?|eG&fj!V&?zilEe~>Y*(rnE|>XZm!c$wlS(pkQh^0U8qiD?p*@oVr%iRj zES$iKz}X0>?7;fc8zp5iwF=nHg5)-6cAxm`r9iC4+Yn46QGC;HARGGVlvSiGhLI3u JkTL{lHvlxR@)!UB delta 12 TcmX@Y6~w~Cz`!v1E~6g+5=8>$ diff --git a/scripts/network_client.gd b/scripts/network_client.gd new file mode 100644 index 0000000..c5738b4 --- /dev/null +++ b/scripts/network_client.gd @@ -0,0 +1,235 @@ +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_" +## (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): +## 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_ 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) + var acked_seq: int = snapshot_data.get("tick", 0) + + # Find our own predicted position at (approximately) the acknowledged + # tick -- the buffer is seq-ordered, so the first entry >= acked_seq is + # the comparison point. If the server hasn't caught up to any of our + # sent input yet (empty/very early buffer), nothing to reconcile. + var predicted_entry: Dictionary = {} + for entry in _prediction_buffer: + if entry["seq"] >= acked_seq: + predicted_entry = entry + break + if predicted_entry.is_empty(): + return + + var error: float = (predicted_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, then replay every input + # sent after the acknowledged tick so the visible result still reflects + # input the server hasn't processed yet (§5.2 step 4). + position = authoritative_pos + velocity = Vector3.ZERO + var replay_from_index := _prediction_buffer.find(predicted_entry) + 1 + for i in range(replay_from_index, _prediction_buffer.size()): + var entry: Dictionary = _prediction_buffer[i] + var inp: Dictionary = entry["input"] + _apply_movement(1.0 / 30.0, inp.get("move_x", 0.0), inp.get("move_z", 0.0), inp.get("jump", false)) + entry["position"] = position + entry["velocity"] = velocity + + reconciled.emit(error) + diff --git a/scripts/network_player_proxy.gd b/scripts/network_player_proxy.gd new file mode 100644 index 0000000..b756cb8 --- /dev/null +++ b/scripts/network_player_proxy.gd @@ -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_" +## 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 diff --git a/scripts/network_receiver_proxy.gd b/scripts/network_receiver_proxy.gd new file mode 100644 index 0000000..c4ad4b5 --- /dev/null +++ b/scripts/network_receiver_proxy.gd @@ -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_ 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/smoke_test_network_client.gd b/scripts/smoke_test_network_client.gd new file mode 100644 index 0000000..ddebe8a --- /dev/null +++ b/scripts/smoke_test_network_client.gd @@ -0,0 +1,101 @@ +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 + var reconciled_this_round := false + var correction_seen: float = -1.0 + var reconcile_handler := func(err): reconciled_this_round = true; correction_seen = err + net.reconciled.connect(reconcile_handler) + for i in 60: + await process_frame + if reconciled_this_round: + 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) + + 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) -- 2.52.0 From 2dff83bae3532239aaf528fa41c9e68b4f44a5d1 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Fri, 21 Aug 2026 01:17:13 +0000 Subject: [PATCH 2/5] WIP checkpoint: Client-side position sync + prediction/reconciliation --- .godot/editor/filesystem_cache10 | 6 ++--- scripts/network_client.gd | 43 +++++++++++++++---------------- scripts/network_receiver_proxy.gd | 1 + 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.godot/editor/filesystem_cache10 b/.godot/editor/filesystem_cache10 index 94fe177..af08089 100644 --- a/.godot/editor/filesystem_cache10 +++ b/.godot/editor/filesystem_cache10 @@ -1,5 +1,5 @@ 63f7b34db8d8cdea90c76aacccf841ec -::res://::1787274081 +::res://::1787274847 export_presets.cfg::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>:: README.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>:: ::res://addons/::1787272930 @@ -44,7 +44,7 @@ server_player.tscn::PackedScene::-1::1787274081::0::1::::<><><>0<>0<><>::res://s 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/::1787274812 +::res://scripts/::1787274976 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<><>:: @@ -52,7 +52,7 @@ chunk_scene.gd::GDScript::9207602004456600331::1787272930::0::1::::ChunkScene<>N 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::1787274408::0::1::::NetworkClient<>Node<><>0<>0<><>:: +network_client.gd::GDScript::5408296350353838185::1787274976::0::1::::NetworkClient<>Node<><>0<>0<><>:: network_player_proxy.gd::GDScript::4787436336771232797::1787274812::0::1::::<>Node<><>0<>0<><>:: network_receiver_proxy.gd::GDScript::6742760436861380520::1787274804::0::1::::<>Node<><>0<>0<><>:: player.gd::GDScript::4510795778937304264::1787274081::0::1::::Player<>CharacterBody3D<><>0<>0<><>:: diff --git a/scripts/network_client.gd b/scripts/network_client.gd index c5738b4..c9a1df4 100644 --- a/scripts/network_client.gd +++ b/scripts/network_client.gd @@ -200,36 +200,35 @@ func _handle_snapshot(snapshot_data: Dictionary) -> void: return var authoritative_pos: Vector3 = (my_state as Dictionary).get("position", position) - var acked_seq: int = snapshot_data.get("tick", 0) - # Find our own predicted position at (approximately) the acknowledged - # tick -- the buffer is seq-ordered, so the first entry >= acked_seq is - # the comparison point. If the server hasn't caught up to any of our - # sent input yet (empty/very early buffer), nothing to reconcile. - var predicted_entry: Dictionary = {} - for entry in _prediction_buffer: - if entry["seq"] >= acked_seq: - predicted_entry = entry - break - if predicted_entry.is_empty(): + # 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 = (predicted_entry["position"] as Vector3).distance_to(authoritative_pos) + 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, then replay every input - # sent after the acknowledged tick so the visible result still reflects - # input the server hasn't processed yet (§5.2 step 4). + # 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 - var replay_from_index := _prediction_buffer.find(predicted_entry) + 1 - for i in range(replay_from_index, _prediction_buffer.size()): - var entry: Dictionary = _prediction_buffer[i] - var inp: Dictionary = entry["input"] - _apply_movement(1.0 / 30.0, inp.get("move_x", 0.0), inp.get("move_z", 0.0), inp.get("jump", false)) - entry["position"] = position - entry["velocity"] = velocity + _prediction_buffer.clear() reconciled.emit(error) diff --git a/scripts/network_receiver_proxy.gd b/scripts/network_receiver_proxy.gd index c4ad4b5..d200cc2 100644 --- a/scripts/network_receiver_proxy.gd +++ b/scripts/network_receiver_proxy.gd @@ -20,6 +20,7 @@ var owner_client: Node = null ## config, a mismatch here would silently drop the call. @rpc("any_peer", "call_remote", "reliable") func _receive_snapshot(snapshot_data: Dictionary) -> void: + print("DEBUG proxy received snapshot: ", snapshot_data) if owner_client and is_instance_valid(owner_client): owner_client._handle_snapshot(snapshot_data) -- 2.52.0 From 140db9df9178b6dd42d6feab59a0fc8a90a66696 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Fri, 21 Aug 2026 01:17:16 +0000 Subject: [PATCH 3/5] Recovered orphaned WIP before switching to a new task --- .godot/editor/filesystem_cache10 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.godot/editor/filesystem_cache10 b/.godot/editor/filesystem_cache10 index af08089..05c94a4 100644 --- a/.godot/editor/filesystem_cache10 +++ b/.godot/editor/filesystem_cache10 @@ -1,5 +1,5 @@ 63f7b34db8d8cdea90c76aacccf841ec -::res://::1787274847 +::res://::1787275033 export_presets.cfg::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>:: README.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>:: ::res://addons/::1787272930 @@ -44,7 +44,7 @@ server_player.tscn::PackedScene::-1::1787274081::0::1::::<><><>0<>0<><>::res://s 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/::1787274976 +::res://scripts/::1787275024 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<><>:: @@ -54,7 +54,7 @@ dynamic_object_node.gd::GDScript::8863349716576659118::1787272930::0::1::::Dynam main_menu.gd::GDScript::2834562513277893052::1787272930::0::1::::<>Control<><>0<>0<><>:: network_client.gd::GDScript::5408296350353838185::1787274976::0::1::::NetworkClient<>Node<><>0<>0<><>:: network_player_proxy.gd::GDScript::4787436336771232797::1787274812::0::1::::<>Node<><>0<>0<><>:: -network_receiver_proxy.gd::GDScript::6742760436861380520::1787274804::0::1::::<>Node<><>0<>0<><>:: +network_receiver_proxy.gd::GDScript::6742760436861380520::1787275024::0::1::::<>Node<><>0<>0<><>:: player.gd::GDScript::4510795778937304264::1787274081::0::1::::Player<>CharacterBody3D<><>0<>0<><>:: player_spawner.gd::GDScript::3637338339553764865::1787274081::0::1::::PlayerNode<>CharacterBody3D<><>0<>0<><>:: server.gd::GDScript::6336910781803684784::1787274081::0::1::::<>Node<><>0<>0<><>:: -- 2.52.0 From bae326e14c3c02d6ff5a2f1887452a876cbd3ad9 Mon Sep 17 00:00:00 2001 From: coder-bot Date: Fri, 21 Aug 2026 01:18:34 +0000 Subject: [PATCH 4/5] WIP checkpoint: Client-side position sync + prediction/reconciliation --- .godot/editor/filesystem_cache10 | 32 ++++++++++++++-------------- scripts/network_client.gd | 1 + scripts/smoke_test_network_client.gd | 5 +++++ 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/.godot/editor/filesystem_cache10 b/.godot/editor/filesystem_cache10 index 05c94a4..85e1158 100644 --- a/.godot/editor/filesystem_cache10 +++ b/.godot/editor/filesystem_cache10 @@ -1,5 +1,5 @@ 63f7b34db8d8cdea90c76aacccf841ec -::res://::1787275033 +::res://::1787275036 export_presets.cfg::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>:: README.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>:: ::res://addons/::1787272930 @@ -31,20 +31,20 @@ PC1.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>:: 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/::1787274081 +::res://docs/testing/::1787275036 README.md::TextFile::-1::1787272930::0::1::::<><><>0<>0<><>:: -test_inventory.md::TextFile::-1::1787274081::0::1::::<><><>0<>0<><>:: -::res://scenes/::1787274081 +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::1787274081::0::1::::<><><>0<>0<><>::res://scripts/server.gd -server_player.tscn::PackedScene::-1::1787274081::0::1::::<><><>0<>0<><>::res://scripts/player_spawner.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/::1787275024 +::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<><>:: @@ -52,20 +52,20 @@ chunk_scene.gd::GDScript::9207602004456600331::1787272930::0::1::::ChunkScene<>N 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::1787274976::0::1::::NetworkClient<>Node<><>0<>0<><>:: -network_player_proxy.gd::GDScript::4787436336771232797::1787274812::0::1::::<>Node<><>0<>0<><>:: -network_receiver_proxy.gd::GDScript::6742760436861380520::1787275024::0::1::::<>Node<><>0<>0<><>:: -player.gd::GDScript::4510795778937304264::1787274081::0::1::::Player<>CharacterBody3D<><>0<>0<><>:: -player_spawner.gd::GDScript::3637338339553764865::1787274081::0::1::::PlayerNode<>CharacterBody3D<><>0<>0<><>:: -server.gd::GDScript::6336910781803684784::1787274081::0::1::::<>Node<><>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::1787274081::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::1787274351::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<><>:: @@ -73,5 +73,5 @@ static_object_node.gd::GDScript::5276389741929225897::1787272930::0::1::::Static 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::1787274081::0::1::::<>Control<><>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<><>:: diff --git a/scripts/network_client.gd b/scripts/network_client.gd index c9a1df4..6e1ef80 100644 --- a/scripts/network_client.gd +++ b/scripts/network_client.gd @@ -196,6 +196,7 @@ func _handle_snapshot(snapshot_data: Dictionary) -> void: # 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)) + print("DEBUG _handle_snapshot my_id=", my_id, " my_state=", my_state, " buffer_size=", _prediction_buffer.size(), " net.position=", position) if my_state == null or not (my_state is Dictionary): return diff --git a/scripts/smoke_test_network_client.gd b/scripts/smoke_test_network_client.gd index ddebe8a..413862d 100644 --- a/scripts/smoke_test_network_client.gd +++ b/scripts/smoke_test_network_client.gd @@ -73,6 +73,11 @@ func _run() -> void: var reconcile_handler := func(err): reconciled_this_round = true; correction_seen = 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 reconciled_this_round: break -- 2.52.0 From bd44ecba148eb22f7b2773ad149e7dcd1b8e28c2 Mon Sep 17 00:00:00 2001 From: claude-checkpoint-tooling Date: Fri, 21 Aug 2026 01:21:37 +0000 Subject: [PATCH 5/5] Client-side position sync + prediction/reconciliation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_" 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 Claude-Session: https://claude.ai/code/session_01M6rUEynmv2QmweocAQYARN --- docs/testing/test_inventory.md | 4 +++- scripts/network_client.gd | 1 - scripts/network_receiver_proxy.gd | 1 - scripts/smoke_test_network_client.gd | 22 +++++++++++++++------- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/docs/testing/test_inventory.md b/docs/testing/test_inventory.md index f4f7f68..5b96a19 100644 --- a/docs/testing/test_inventory.md +++ b/docs/testing/test_inventory.md @@ -53,7 +53,8 @@ these yet): ## 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 `application/agp/api_base_url` project setting (default `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_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_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`, then for each smoke test: `godot --headless --path . --script diff --git a/scripts/network_client.gd b/scripts/network_client.gd index 6e1ef80..c9a1df4 100644 --- a/scripts/network_client.gd +++ b/scripts/network_client.gd @@ -196,7 +196,6 @@ func _handle_snapshot(snapshot_data: Dictionary) -> void: # 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)) - print("DEBUG _handle_snapshot my_id=", my_id, " my_state=", my_state, " buffer_size=", _prediction_buffer.size(), " net.position=", position) if my_state == null or not (my_state is Dictionary): return diff --git a/scripts/network_receiver_proxy.gd b/scripts/network_receiver_proxy.gd index d200cc2..c4ad4b5 100644 --- a/scripts/network_receiver_proxy.gd +++ b/scripts/network_receiver_proxy.gd @@ -20,7 +20,6 @@ var owner_client: Node = null ## config, a mismatch here would silently drop the call. @rpc("any_peer", "call_remote", "reliable") func _receive_snapshot(snapshot_data: Dictionary) -> void: - print("DEBUG proxy received snapshot: ", snapshot_data) if owner_client and is_instance_valid(owner_client): owner_client._handle_snapshot(snapshot_data) diff --git a/scripts/smoke_test_network_client.gd b/scripts/smoke_test_network_client.gd index 413862d..e1c5152 100644 --- a/scripts/smoke_test_network_client.gd +++ b/scripts/smoke_test_network_client.gd @@ -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() -- 2.52.0