## ClientPrediction — Client-side prediction & reconciliation for networked FPS. ## ## Architecture (tick loop): ## 1. on_before_tick() → captures a local-world Snapshot into a 64-entry ## ring buffer BEFORE input is applied. ## 2. The character controller runs its normal local movement (predicted ## simulation — instant feedback). ## 3. on_after_tick() → sends the raw input to the server via ENet ## channel 0 and advances the local tick counter. ## 4. When a server state snapshot arrives, the controller reconciles: ## a) Compares predicted state with authoritative state at that tick. ## b) If mismatch detected → emits state_mispredicted(delta). ## c) Rewinds to confirmed state, re-applies all unconfirmed inputs, ## then emits reconciled(). ## ## The same node also supports INTERPOLATION MODE for remote players ## (those not owned by this peer). See set_interpolate_mode(). ## ## Server-side input queue: ## This script also contains server-side logic for receiving client ## inputs and making them available to GameServer via consume_pending_inputs(). ## ## Dependencies: ## - NetworkManager autoload (for RPC relay) ## - Snapshot (data container) ## - FPSCharacterController (linked via setup()) extends Node # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- const BUFFER_SIZE: int = 64 # --------------------------------------------------------------------------- # Signals # --------------------------------------------------------------------------- ## Emitted when a misprediction is detected during reconciliation. ## delta_position: Vector3 — the position difference (server - predicted). signal state_mispredicted(delta_position: Vector3) ## Emitted after successful reconciliation (state corrected). signal reconciled() # --------------------------------------------------------------------------- # State — Mode # --------------------------------------------------------------------------- ## If true, this instance is actively predicting (client-side local player). var prediction_enabled: bool = false ## If true, this instance is interpolating a remote player's position. var interpolate_mode: bool = false # --------------------------------------------------------------------------- # State — Prediction (local player) # --------------------------------------------------------------------------- ## Local tick counter, incremented each physics tick. var local_tick: int = 0 ## Ring buffer of snapshots taken BEFORE input each tick. ## Indexed by tick % BUFFER_SIZE. var _snapshot_buffer: Array[Snapshot] = [] ## Pending (unconfirmed) inputs, keyed by local_tick. ## Each value is a Dictionary matching the FPSCharacterController input format. var _pending_inputs: Dictionary = {} ## Last confirmed snapshot received from the server. var _last_confirmed_snapshot: Snapshot = null ## Tick of the last confirmed snapshot. var _last_confirmed_tick: int = -1 # --------------------------------------------------------------------------- # State — Interpolation (remote players) # --------------------------------------------------------------------------- var _prev_snapshot: Snapshot = null var _next_snapshot: Snapshot = null var _interp_fraction: float = 0.0 # --------------------------------------------------------------------------- # References # --------------------------------------------------------------------------- var _controller: FPSCharacterController = null # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _init() -> void: _snapshot_buffer.resize(BUFFER_SIZE) func _ready() -> void: if not multiplayer.is_server() and NetworkManager: # Client instances: listen for authoritative server state. NetworkManager.server_state_received.connect(_on_client_state_received) # --------------------------------------------------------------------------- # Public API — setup # --------------------------------------------------------------------------- ## Link this prediction node to its FPSCharacterController. func setup(controller: FPSCharacterController) -> void: _controller = controller if not multiplayer.is_server(): prediction_enabled = true print("[ClientPrediction] Prediction active for entity %d" % controller.entity_id) ## Switch to interpolation mode (for remote player representations). func set_interpolate_mode(enabled: bool) -> void: interpolate_mode = enabled if enabled: prediction_enabled = false # --------------------------------------------------------------------------- # Prediction hooks (called by FPSCharacterController._physics_process) # --------------------------------------------------------------------------- ## MUST be called at the start of _physics_process, BEFORE any input ## processing or movement. Captures the current character state into ## the snapshot ring buffer. func on_before_tick() -> void: if _controller == null or interpolate_mode or not prediction_enabled: return var snap: Snapshot = _capture_snapshot(local_tick) _snapshot_buffer[local_tick % BUFFER_SIZE] = snap _pending_inputs[local_tick] = null # placeholder, filled by on_after_tick ## MUST be called at the end of _physics_process, AFTER local movement ## and input processing. Stores the applied input, sends it to the ## server via RPC, and advances the local tick. func on_after_tick(input_dict: Dictionary) -> void: if _controller == null or interpolate_mode or not prediction_enabled: return # Store the input for potential reconciliation. _pending_inputs[local_tick] = input_dict.duplicate() # Send input to the server via NetworkManager RPC (ENet channel 0). if NetworkManager and NetworkManager.has_method(&"send_client_input"): NetworkManager.send_client_input.rpc_id(1, local_tick, input_dict) local_tick += 1 # --------------------------------------------------------------------------- # Snapshot capture # --------------------------------------------------------------------------- func _capture_snapshot(tick: int) -> Snapshot: var s = Snapshot.new() s.timestamp = tick s.position = _controller.global_position s.rotation = _controller.global_transform.basis.get_rotation_quaternion() s.velocity = _controller.velocity s.grounded = _controller.is_on_floor() return s # --------------------------------------------------------------------------- # Server state handling (called when authoritative state arrives) # --------------------------------------------------------------------------- ## Handle a server state snapshot: detect misprediction and reconcile. func on_server_state(server_snapshot: Snapshot) -> void: if prediction_enabled: _reconcile_from_server(server_snapshot) elif interpolate_mode: _store_interp_snapshot(server_snapshot) func _reconcile_from_server(server_snapshot: Snapshot) -> void: var server_tick: int = server_snapshot.timestamp # Ignore stale / out-of-order state. if server_tick <= _last_confirmed_tick: return # 1. Retrieve our predicted snapshot for this tick. var predicted: Snapshot = _snapshot_buffer[server_tick % BUFFER_SIZE] if predicted != null and predicted.timestamp == server_tick: # 2. Compare predicted vs authoritative state. var delta: Vector3 = server_snapshot.position - predicted.position if delta.length_squared() > 0.0001: state_mispredicted.emit(delta) # 3. Update last confirmed state. _last_confirmed_snapshot = server_snapshot _last_confirmed_tick = server_tick # 4. Rewind and re-predict. _reconcile(server_snapshot, server_tick) ## Core reconciliation: rewind to the confirmed server state, then ## re-apply every unconfirmed input that was sent after that tick. func _reconcile(confirmed: Snapshot, confirmed_tick: int) -> void: if _controller == null: return # Collect pending ticks that are strictly after the confirmed tick. var pending_ticks: Array[int] = [] for tick in _pending_inputs.keys(): if tick > confirmed_tick: pending_ticks.append(tick) pending_ticks.sort() if pending_ticks.is_empty(): # Nothing to re-apply — snap directly to confirmed state. _apply_snapshot(confirmed) reconciled.emit() return # Rewind to the confirmed state. _apply_snapshot(confirmed) # Re-apply every pending input in tick order. var tick_delta: float = 1.0 / 128.0 # matches the 128 Hz tick rate for tick in pending_ticks: var input_dict: Dictionary = _pending_inputs.get(tick, {}) if input_dict.is_empty(): continue _simulate_input(input_dict, tick_delta) reconciled.emit() ## Apply a snapshot directly to the character controller (rewind step). func _apply_snapshot(snap: Snapshot) -> void: if _controller == null: return _controller.global_position = snap.position _controller.global_transform.basis = Basis(snap.rotation) _controller.velocity = snap.velocity ## Re-simulate a single tick of input on the character controller. ## Uses the controller's own _move_local() for identical physics. ## NOTE: crouch and sprint state are not re-applied during reconciliation ## because they are frame-state toggles; the server state already accounts ## for them. Only movement direction and jump matter for position correction. func _simulate_input(input_dict: Dictionary, delta: float) -> void: if _controller == null: return var move_dir: Vector3 = input_dict.get("move_direction", Vector3.ZERO) var jump: bool = input_dict.get("jump", false) var yaw_deg: float = input_dict.get("look_yaw", 0.0) # Re-apply look yaw so movement is oriented correctly. _controller.rotation.y = deg_to_rad(yaw_deg) # Re-run local movement (this calls move_and_slide internally). _controller._move_local(move_dir, delta, jump) # --------------------------------------------------------------------------- # Remote player interpolation # --------------------------------------------------------------------------- func _store_interp_snapshot(snap: Snapshot) -> void: _prev_snapshot = _next_snapshot _next_snapshot = snap _interp_fraction = 0.0 ## Advance interpolation for a remote player. Call each _process frame. func advance_interpolation(delta: float) -> void: if not interpolate_mode or _controller == null: return if _prev_snapshot == null or _next_snapshot == null: return _interp_fraction += delta * 128.0 # tick rate if _interp_fraction >= 1.0: _apply_snapshot(_next_snapshot) _prev_snapshot = _next_snapshot _next_snapshot = null return # Smooth interpolation between snapshots. _controller.global_position = _prev_snapshot.position.lerp( _next_snapshot.position, _interp_fraction ) _controller.global_transform.basis = Basis(_prev_snapshot.rotation).slerp( Basis(_next_snapshot.rotation), _interp_fraction ) _controller.velocity = _prev_snapshot.velocity.lerp( _next_snapshot.velocity, _interp_fraction ) # --------------------------------------------------------------------------- # Client-side state reception # --------------------------------------------------------------------------- ## Client-side: called when NetworkManager emits server_state_received. ## Converts the raw dict into a Snapshot and passes it to the ## reconciliation pipeline. func _on_client_state_received(entity_id: int, snapshot_dict: Dictionary) -> void: # Route to the correct prediction controller based on entity_id. if _controller == null or _controller.entity_id != entity_id: return var snap: Snapshot = Snapshot.from_dict(snapshot_dict) on_server_state(snap) # --------------------------------------------------------------------------- # Utility # --------------------------------------------------------------------------- ## Reset all prediction state (call on respawn / round restart). func reset() -> void: _snapshot_buffer = [] _snapshot_buffer.resize(BUFFER_SIZE) _pending_inputs.clear() _last_confirmed_snapshot = null _last_confirmed_tick = -1 local_tick = 0 _prev_snapshot = null _next_snapshot = null _interp_fraction = 0.0