Files
tactical-shooter/scripts/network/client_prediction.gd
T
shawn f6d69545c9 feat: implement client-side prediction and reconciliation system
Add client-side prediction (t_p1_pred) with:

- scripts/network/snapshot.gd — lightweight snapshot resource with
  to_dict/from_dict serialization for RPC. Stores position (Vector3),
  rotation (Quaternion), velocity (Vector3), grounded (bool).

- scripts/network/client_prediction.gd — prediction/reconciliation
  controller. 64-entry ring buffer of local snapshots, sends inputs to
  server each physics tick (128Hz, ENet channel 0), detects misprediction
  on server state arrival, rewinds and re-applies unconfirmed inputs.
  Also supports remote player interpolation.

- scripts/network/network_manager.gd — new RPC endpoints for client
  prediction: send_client_input (client->server, ch 0) and
  send_server_state (server->client, ch 1). New signals for routing.

- client/characters/character/fps_character_controller.gd — prediction
  hooks in _physics_process: on_before_tick() captures pre-input
  snapshot, on_after_tick() sends input to server. Client prediction
  path uses local movement (instant feedback) instead of reading from
  SimulationServer entity.

Architecture:
  Each tick: client applies input → predicts new state locally
  → sends input to server → server returns authoritative state
  → client compares and reconciles if mismatch.
2026-07-01 20:21:16 -04:00

314 lines
12 KiB
GDScript

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