Client-Side Prediction in Matter.js Multiplayer Games
Client-side prediction is an essential networking technique that eliminates perceived input latency in multiplayer physics games by simulating player actions locally before receiving confirmation from the server. This guide covers the technical steps required to implement client-side prediction and server reconciliation using Matter.js, including setting up deterministic fixed-timestep loops, managing input sequence buffers, replaying physics steps upon server corrections, and visually smoothing state discrepancies.
1. Establish a Fixed Timestep Loop
Matter.js defaults to using Matter.Runner, which relies
on requestAnimationFrame and variable delta times. Variable
time steps introduce non-deterministic physics, making client-server
synchronization nearly impossible. You must replace the default runner
with a fixed-timestep loop on both the client and the authoritative
server.
const fixedDelta = 1000 / 60; // 60 Hz physics step
let accumulator = 0;
let lastTime = performance.now();
function gameLoop(currentTime) {
const frameDelta = currentTime - lastTime;
lastTime = currentTime;
accumulator += frameDelta;
while (accumulator >= fixedDelta) {
// Run game logic and physics
updatePhysics(fixedDelta);
accumulator -= fixedDelta;
}
requestAnimationFrame(gameLoop);
}
function updatePhysics(delta) {
Matter.Engine.update(engine, delta);
}2. Capture and Buffer Tagged Inputs
To reconcile states later, every input must be tagged with a sequential sequence number (tick) and stored in a local buffer.
Each tick:
- Capture user inputs (e.g., keyboard movement, mouse vectors).
- Assign an incrementing sequence number to the input.
- Apply the input immediately to the local Matter.js player body using
methods like
Matter.Body.applyForceorMatter.Body.setVelocity. - Store the input and the generated tick in a pending input queue.
- Send the input along with its sequence number to the server.
let sequenceNumber = 0;
const pendingInputs = [];
function handleInput() {
sequenceNumber++;
const input = {
sequence: sequenceNumber,
dx: keys.right ? 1 : keys.left ? -1 : 0,
dy: keys.down ? 1 : keys.up ? -1 : 0
};
// Apply locally
applyPlayerInput(playerBody, input);
// Save for reconciliation
pendingInputs.push(input);
// Send to server
socket.emit('playerInput', input);
}3. Authoritative Server Processing
The server runs an identical Matter.js simulation at the same fixed frequency. It processes incoming client inputs, steps the physics world forward, and periodically broadcasts the authoritative game state to clients.
The server packet must include:
- The authoritative position, angle, velocity, and angular velocity of the player's body.
- The sequence number of the most recent input processed for that
specific player (
lastProcessedInput).
4. Implement Server Reconciliation
When the client receives the authoritative state snapshot from the server, it must verify whether the predicted state matches the server state. If the discrepancy exceeds a defined error threshold, the client must roll back to the server state and replay all unacknowledged inputs.
- Prune Input Buffer: Remove all inputs from
pendingInputswhose sequence number is less than or equal to the server'slastProcessedInput. - Detect Desynchronization: Compare the historical predicted position with the received server position.
- Replay Physics: If the difference is significant:
- Set the Matter.js player body properties (position, velocity, angle) to the server snapshot.
- Step through each remaining input in
pendingInputssequentially, re-applying the forces and manually callingMatter.Engine.update(engine, fixedDelta).
socket.on('serverState', (serverState) => {
// 1. Discard acknowledged inputs
const lastAckIndex = pendingInputs.findIndex(i => i.sequence === serverState.lastProcessedInput);
if (lastAckIndex !== -1) {
pendingInputs.splice(0, lastAckIndex + 1);
}
// 2. Check position error threshold
const distanceError = Math.hypot(
playerBody.position.x - serverState.x,
playerBody.position.y - serverState.y
);
// 3. Reconcile if diverged
if (distanceError > 1.0) { // Threshold in pixels/units
Matter.Body.setPosition(playerBody, { x: serverState.x, y: serverState.y });
Matter.Body.setVelocity(playerBody, { x: serverState.vx, y: serverState.vy });
Matter.Body.setAngle(playerBody, serverState.angle);
Matter.Body.setAngularVelocity(playerBody, serverState.angularVelocity);
// Replay all unacknowledged inputs
for (const input of pendingInputs) {
applyPlayerInput(playerBody, input);
Matter.Engine.update(engine, fixedDelta);
}
}
});5. Apply Visual Smoothing
Hard snaps during reconciliation cause visible jitter. To ensure smooth gameplay, decouple the visual rendering from the physical Matter.js body.
When a reconciliation offset occurs, store the difference between the
corrected physical position and the prior visual position as an offset
vector. In your render loop, draw the player sprite at
playerBody.position + visualOffset, and decay
visualOffset toward zero over several frames using linear
interpolation:
let visualOffset = { x: 0, y: 0 };
function reconcilePosition(serverX, serverY) {
const prevX = playerBody.position.x;
const prevY = playerBody.position.y;
// Reset physics body to server position
Matter.Body.setPosition(playerBody, { x: serverX, y: serverY });
// Store the delta for smoothing
visualOffset.x = prevX - serverX;
visualOffset.y = prevY - serverY;
}
function render() {
// Exponentially decay the visual offset
visualOffset.x *= 0.85;
visualOffset.y *= 0.85;
renderSprite(
playerBody.position.x + visualOffset.x,
playerBody.position.y + visualOffset.y
);
}Best Practices for Matter.js
- Disable Inertia and Friction for Direct Controls:
Platformers or top-down games with direct input should set
inertia: Infinityand zero air friction on the player body to prevent non-deterministic rotational accumulation. - Isolate Player Collisions During Replay: When
running re-simulation loops via
Matter.Engine.update, avoid triggering duplicate game-logic events (such as damage or sound effects) by wrapping reconciliation steps in a flag that suppresses game event listeners.