Client Prediction
Client prediction is a key technique in networked games to reduce input latency. By immediately applying player inputs locally while waiting for server confirmation, games feel more responsive.
NetworkPredictionSystem
Section titled “NetworkPredictionSystem”NetworkPredictionSystem is an ECS system dedicated to handling local player prediction.
Basic Usage
Section titled “Basic Usage”import { NetworkPlugin } from '@esengine/network';
const networkPlugin = new NetworkPlugin({ enablePrediction: true, predictionConfig: { moveSpeed: 200, // Movement speed (units/second) maxUnacknowledgedInputs: 60, // Max unacknowledged inputs reconciliationThreshold: 0.5, // Reconciliation threshold reconciliationSpeed: 10, // Reconciliation speed }});
await Core.installPlugin(networkPlugin);Setting Up Local Player
Section titled “Setting Up Local Player”After the local player entity spawns, set its network ID:
networkPlugin.registerPrefab('player', (scene, spawn) => { const entity = scene.createEntity(`player_${spawn.netId}`);
const identity = entity.addComponent(new NetworkIdentity()); identity.netId = spawn.netId; identity.ownerId = spawn.ownerId; identity.bHasAuthority = spawn.ownerId === networkPlugin.localPlayerId; identity.bIsLocalPlayer = identity.bHasAuthority;
entity.addComponent(new NetworkTransform());
// Set local player for prediction if (identity.bIsLocalPlayer) { networkPlugin.setLocalPlayerNetId(spawn.netId); }
return entity;});Sending Input
Section titled “Sending Input”// Send movement input in game loopfunction onUpdate() { const moveX = Input.getAxis('horizontal'); const moveY = Input.getAxis('vertical');
if (moveX !== 0 || moveY !== 0) { networkPlugin.sendMoveInput(moveX, moveY); }
// Send action input if (Input.isPressed('attack')) { networkPlugin.sendActionInput('attack'); }}Prediction Configuration
Section titled “Prediction Configuration”| Property | Type | Default | Description |
|---|---|---|---|
moveSpeed | number | 200 | Movement speed (units/second) |
enabled | boolean | true | Whether prediction is enabled |
maxUnacknowledgedInputs | number | 60 | Max unacknowledged inputs |
reconciliationThreshold | number | 0.5 | Position difference threshold for reconciliation |
reconciliationSpeed | number | 10 | Reconciliation smoothing speed |
How It Works
Section titled “How It Works”Client Server │ │ ├─ 1. Capture input (seq=1) │ ├─ 2. Predict movement locally │ ├─ 3. Send input to server ─────────► │ │ ├─ 4. Continue capturing (seq=2,3...) │ ├─ 5. Continue predicting │ │ │ │ ├─ 6. Process input (seq=1) │ │ ◄──────── 7. Return state (ackSeq=1) ─ │ │ ├─ 8. Compare prediction with server │ ├─ 9. Replay inputs seq=2,3... │ ├─ 10. Smooth correction │ │ │Step by Step
Section titled “Step by Step”- Input Capture: Capture player input and assign sequence number
- Local Prediction: Immediately apply input to local state
- Send Input: Send input to server
- Cache Input: Save input for later reconciliation
- Receive Acknowledgment: Server returns authoritative state with ack sequence
- State Comparison: Compare predicted state with server state
- Input Replay: Recalculate state using cached unacknowledged inputs
- Smooth Correction: Interpolate smoothly to correct position
Low-Level API
Section titled “Low-Level API”For fine-grained control, use the ClientPrediction class directly:
import { createClientPrediction, type IPredictor } from '@esengine/network';
// Define state typeinterface PlayerState { x: number; y: number; rotation: number;}
// Define input typeinterface PlayerInput { dx: number; dy: number;}
// Define predictorconst predictor: IPredictor<PlayerState, PlayerInput> = { predict(state: PlayerState, input: PlayerInput, dt: number): PlayerState { return { x: state.x + input.dx * MOVE_SPEED * dt, y: state.y + input.dy * MOVE_SPEED * dt, rotation: state.rotation, }; }};
// Create client predictionconst prediction = createClientPrediction(predictor, { maxUnacknowledgedInputs: 60, reconciliationThreshold: 0.5, reconciliationSpeed: 10,});
// Record input and get predicted stateconst input = { dx: 1, dy: 0 };const predictedState = prediction.recordInput(input, currentState, deltaTime);
// Get input to sendconst inputToSend = prediction.getInputToSend();
// Reconcile with server stateprediction.reconcile( serverState, serverAckSeq, (state) => ({ x: state.x, y: state.y }), deltaTime);
// Get correction offsetconst offset = prediction.correctionOffset;Enable/Disable Prediction
Section titled “Enable/Disable Prediction”// Toggle prediction at runtimenetworkPlugin.setPredictionEnabled(false);
// Check prediction statusif (networkPlugin.isPredictionEnabled) { console.log('Prediction is active');}Best Practices
Section titled “Best Practices”1. Set Appropriate Reconciliation Threshold
Section titled “1. Set Appropriate Reconciliation Threshold”// Action games: lower threshold, more precisepredictionConfig: { reconciliationThreshold: 0.1,}
// Casual games: higher threshold, smootherpredictionConfig: { reconciliationThreshold: 1.0,}2. Prediction Only for Local Player
Section titled “2. Prediction Only for Local Player”Remote players should use interpolation, not prediction:
const identity = entity.getComponent(NetworkIdentity);
if (identity.bIsLocalPlayer) { // Use prediction system} else { // Use NetworkSyncSystem interpolation}3. Handle High Latency
Section titled “3. Handle High Latency”// High latency network: increase bufferpredictionConfig: { maxUnacknowledgedInputs: 120, // Increase buffer reconciliationSpeed: 5, // Slower correction}4. Deterministic Prediction
Section titled “4. Deterministic Prediction”Ensure client and server use the same physics calculations:
// Use fixed timestepconst FIXED_DT = 1 / 60;
function applyInput(state: PlayerState, input: PlayerInput): PlayerState { // Use fixed timestep instead of actual deltaTime return { x: state.x + input.dx * MOVE_SPEED * FIXED_DT, y: state.y + input.dy * MOVE_SPEED * FIXED_DT, rotation: state.rotation, };}Debugging
Section titled “Debugging”// Get prediction system instanceconst predictionSystem = networkPlugin.predictionSystem;
if (predictionSystem) { console.log('Pending inputs:', predictionSystem.pendingInputCount); console.log('Current sequence:', predictionSystem.inputSequence);}Fixed-Point Client Prediction (Lockstep)
Section titled “Fixed-Point Client Prediction (Lockstep)”Deterministic client prediction for Lockstep architecture.
See Fixed-Point Numbers for math basics
Basic Usage
Section titled “Basic Usage”import { FixedClientPrediction, createFixedClientPrediction, type IFixedPredictor, type IFixedStatePositionExtractor} from '@esengine/network';import { Fixed32, FixedVector2 } from '@esengine/ecs-framework-math';
// Define game stateinterface GameState { position: FixedVector2; velocity: FixedVector2;}
// Implement predictor (must use fixed-point arithmetic)const predictor: IFixedPredictor<GameState, PlayerInput> = { predict(state: GameState, input: PlayerInput, deltaTime: Fixed32): GameState { const speed = Fixed32.from(100); const inputVec = FixedVector2.from(input.dx, input.dy); const velocity = inputVec.normalize().mul(speed); const displacement = velocity.mul(deltaTime);
return { position: state.position.add(displacement), velocity }; }};
// Create predictionconst prediction = createFixedClientPrediction(predictor, { maxUnacknowledgedInputs: 60, fixedDeltaTime: Fixed32.from(1 / 60), reconciliationThreshold: Fixed32.from(0.001), enableSmoothReconciliation: false // Usually disabled for lockstep});Record Input
Section titled “Record Input”function onUpdate(input: PlayerInput, currentState: GameState) { // Record input and get predicted state const predicted = prediction.recordInput(input, currentState);
// Render predicted state const pos = predicted.position.toObject(); sprite.position.set(pos.x, pos.y);
// Send input socket.send(JSON.stringify({ frame: prediction.currentFrame, input }));}Server Reconciliation
Section titled “Server Reconciliation”// Position extractorconst posExtractor: IFixedStatePositionExtractor<GameState> = { getPosition(state: GameState): FixedVector2 { return state.position; }};
// When receiving server statefunction onServerState(serverState: GameState, serverFrame: number) { const reconciled = prediction.reconcile( serverState, serverFrame, posExtractor );}Rollback and Replay
Section titled “Rollback and Replay”// Rollback when desync detectedconst correctedState = prediction.rollbackAndResimulate( serverFrame, authoritativeState);
// View historical stateconst historicalState = prediction.getStateAtFrame(100);Preset Movement Predictor
Section titled “Preset Movement Predictor”import { createFixedMovementPredictor, createFixedMovementPositionExtractor, type IFixedMovementInput, type IFixedMovementState} from '@esengine/network';
// Create movement predictor (speed 100 units/sec)const movePredictor = createFixedMovementPredictor(Fixed32.from(100));const posExtractor = createFixedMovementPositionExtractor();
const prediction = createFixedClientPrediction<IFixedMovementState, IFixedMovementInput>( movePredictor, { fixedDeltaTime: Fixed32.from(1 / 60) });
// Input formatconst input: IFixedMovementInput = { dx: 1, dy: 0 };API Exports
Section titled “API Exports”import { FixedClientPrediction, createFixedClientPrediction, createFixedMovementPredictor, createFixedMovementPositionExtractor, type IFixedInputSnapshot, type IFixedPredictedState, type IFixedPredictor, type IFixedStatePositionExtractor, type FixedClientPredictionConfig, type IFixedMovementInput, type IFixedMovementState} from '@esengine/network';