class_name NetworkClient extends Node ## Client-side multiplayer: ENet connection, local-player input prediction + ## server reconciliation (with input replay), and remote-player state for the ## game to render. ## ## RPC path-matching: Godot resolves an rpc_id() call's target by matching the ## sending node's NodePath on the receiver. scripts/server.gd is the SceneTree ## root named "Server"; it spawns each peer's PlayerNode as a child named ## "Player_". So the client needs local nodes at /root/Server (for the ## server-initiated _receive_snapshot) and /root/Server/Player_ (the ## target of rpc_request_input). connect_to_server() builds that once the peer ## id is known. ## ## Usage: ## var net := NetworkClient.new() ## get_tree().root.add_child(net) ## if await net.connect_to_server(host, port): ## # each physics frame, from the local player controller: ## net.predict_and_send(delta, move_x, move_z, jump, body_yaw) ## # read net.position / net.get_remote_states() to render # Movement constants -- MUST match scripts/server.gd and player_spawner.gd. const WALK_SPEED := 5.0 const JUMP_VELOCITY := 4.5 const GRAVITY := 9.8 ## Predicted vs authoritative drift beyond this (metres) snaps rather than ## trusting the prediction. const RECONCILE_ERROR_THRESHOLD := 0.35 ## Ring buffer cap. 120 frames at 30 Hz = 4 s of history, well past any ## realistic LAN RTT. const PREDICTION_BUFFER_SIZE := 120 signal reconciled(correction_distance: float) signal connected_to_server signal connection_failed signal server_disconnected var peer: ENetMultiplayerPeer var my_peer_id: int = 0 var _server_root: Node var _player_proxy: Node var pending_loadout: Dictionary = {} var _seq: int = 0 ## Ring buffer of {seq, move_x, move_z, jump, delta, position, velocity}, ## oldest first. Entries are dropped as the server acks their seq. var _prediction_buffer: Array[Dictionary] = [] ## Local predicted player state. var position: Vector3 = Vector3.ZERO var velocity: Vector3 = Vector3.ZERO var _on_floor: bool = true ## peer_id -> latest authoritative state dict from the most recent snapshot, ## for every peer that isn't us. The game reads this to render other players. var _remote_states: Dictionary = {} ## Our own latest authoritative state from the most recent snapshot. var _own_state: Dictionary = {} var _last_snapshot_tick: int = -1 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.server_disconnected.connect(func(): server_disconnected.emit()) for _i in 300: # ~5 s at 60 fps await get_tree().process_frame match peer.get_connection_status(): MultiplayerPeer.CONNECTION_CONNECTED: _on_connected() return true MultiplayerPeer.CONNECTION_DISCONNECTED: connection_failed.emit() return false connection_failed.emit() return false func _on_connected() -> void: my_peer_id = multiplayer.get_unique_id() _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_peer_id _player_proxy.set_script(preload("res://scripts/network_player_proxy.gd")) _server_root.add_child(_player_proxy) connected_to_server.emit() if not pending_loadout.is_empty(): _player_proxy.rpc_id(1, "rpc_set_loadout", pending_loadout) func disconnect_from_server() -> void: if peer: peer.close() if _server_root and is_instance_valid(_server_root): _server_root.queue_free() if multiplayer: multiplayer.multiplayer_peer = null _prediction_buffer.clear() _remote_states.clear() func is_connected_to_server() -> bool: return peer != null and peer.get_connection_status() == MultiplayerPeer.CONNECTION_CONNECTED # --------------------------------------------------------------------------- # Prediction -- call every physics frame from the local player controller. # Applies input locally immediately (zero perceived latency) and sends it. # --------------------------------------------------------------------------- func predict_and_send(delta: float, move_x: float, move_z: float, jump: bool, look_yaw: float = 0.0) -> void: _apply_movement(delta, move_x, move_z, jump) _seq += 1 _prediction_buffer.append({ "seq": _seq, "move_x": move_x, "move_z": move_z, "jump": jump, "delta": delta, "position": position, "velocity": velocity, }) if _prediction_buffer.size() > PREDICTION_BUFFER_SIZE: _prediction_buffer.pop_front() if _player_proxy and is_instance_valid(_player_proxy): _player_proxy.rpc_id(1, "rpc_request_input", {"move_x": move_x, "move_z": move_z, "jump": jump, "look_yaw": look_yaw}, _seq) func _apply_movement(delta: float, move_x: float, move_z: float, jump: bool) -> void: if not _on_floor: velocity.y -= GRAVITY * delta elif jump: velocity.y = JUMP_VELOCITY var dir := Vector3(move_x, 0.0, move_z) if dir.length() > 0.01: dir = dir.normalized() velocity.x = dir.x * WALK_SPEED velocity.z = dir.z * WALK_SPEED else: velocity.x = 0.0 velocity.z = 0.0 position += velocity * delta # Headless prediction has no collision shape; treat the spawn plane as the # floor. A render-side controller drives the real CharacterBody3D and reads # `position` back for reconciliation only. if position.y <= 0.0: position.y = 0.0 velocity.y = 0.0 _on_floor = true else: _on_floor = false # --------------------------------------------------------------------------- # Reconciliation -- _ReceiverProxy calls this when a snapshot arrives. # --------------------------------------------------------------------------- func _handle_snapshot(snap: Dictionary) -> void: var snap_tick: int = snap.get("tick", 0) if snap_tick <= _last_snapshot_tick: return # Stale / out-of-order (unreliable channel). _last_snapshot_tick = snap_tick var players: Dictionary = snap.get("players", {}) # --- Remote players --- _remote_states.clear() for key in players: var pid := int(key) if pid == my_peer_id: continue _remote_states[pid] = players[key] # --- Local player: reconcile --- var mine: Variant = players.get(my_peer_id, players.get(str(my_peer_id), null)) if not (mine is Dictionary): return _own_state = mine var auth_pos: Vector3 = mine.get("position", position) var auth_vel: Vector3 = mine.get("velocity", velocity) var acked_seq: int = int(mine.get("last_seq", 0)) # Drop inputs the server has already applied. while not _prediction_buffer.is_empty() and _prediction_buffer[0]["seq"] <= acked_seq: _prediction_buffer.pop_front() # Re-simulate the still-in-flight inputs from the authoritative state. var replay_pos := auth_pos var replay_vel := auth_vel var replay_floor := replay_pos.y <= 0.0 for entry in _prediction_buffer: var d: float = entry["delta"] if not replay_floor: replay_vel.y -= GRAVITY * d elif entry["jump"]: replay_vel.y = JUMP_VELOCITY var dir := Vector3(entry["move_x"], 0.0, entry["move_z"]) if dir.length() > 0.01: dir = dir.normalized() replay_vel.x = dir.x * WALK_SPEED replay_vel.z = dir.z * WALK_SPEED else: replay_vel.x = 0.0 replay_vel.z = 0.0 replay_pos += replay_vel * d if replay_pos.y <= 0.0: replay_pos.y = 0.0 replay_vel.y = 0.0 replay_floor = true else: replay_floor = false var error := position.distance_to(replay_pos) if error > RECONCILE_ERROR_THRESHOLD: position = replay_pos velocity = replay_vel _on_floor = replay_floor reconciled.emit(error) # --------------------------------------------------------------------------- # Remote-player state for rendering # --------------------------------------------------------------------------- ## peer_id -> {position, rotation_y, velocity, on_floor, last_seq} from the most ## recent snapshot. Read this each frame to spawn/update remote avatars. func get_remote_states() -> Dictionary: return _remote_states ## Our own latest authoritative state from the most recent snapshot, or {}. func get_own_server_state() -> Dictionary: return _own_state ## Send an input frame to the server WITHOUT running NetworkClient's own ## flat-plane prediction -- for callers that predict locally with their own ## collision-aware CharacterBody3D (scripts/game.gd) and only want the server ## as an authority/correction backstop. func send_input(move_x: float, move_z: float, jump: bool, look_yaw: float) -> void: _seq += 1 if _player_proxy and is_instance_valid(_player_proxy): _player_proxy.rpc_id(1, "rpc_request_input", {"move_x": move_x, "move_z": move_z, "jump": jump, "look_yaw": look_yaw}, _seq) ## Send the local player's avatar loadout to the server so it can be replicated ## to other clients. func send_loadout(loadout_dict: Dictionary) -> void: pending_loadout = loadout_dict.duplicate(true) if _player_proxy and is_instance_valid(_player_proxy): _player_proxy.rpc_id(1, "rpc_set_loadout", pending_loadout)