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) # 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)