Matter.js Lag Compensation and Hit Rewind
This article explains how to implement server-side lag compensation using state rewind in Matter.js to verify hit registration in multiplayer games. You will learn how to maintain a rolling circular buffer of entity transforms, calculate target historical timestamps, temporarily restore Matter.js bodies to their past positions, execute spatial hit tests using Matter.js queries, and restore the physics world to the current tick without disrupting the active simulation.
The Problem of Latency in Server-Authoritative Physics
In a server-authoritative multiplayer architecture, clients render other players at an interpolated position in the past, while the server runs the simulation in real time. When a client fires a weapon at a target, they aim at where the target was rendered on their screen. Without lag compensation, a shot fired directly at a moving player will miss on the server because the target has already moved forward in time during the transit of the network packet.
Lag compensation resolves this by rolling back the hitboxes of relevant bodies to match the exact point in time the client fired, evaluating the hit, and restoring the physics state to the present.
Step 1: Record Historical World State
Maintain a circular buffer (history buffer) on the server that logs the transform of every shootable entity at each fixed tick. Keep roughly 500ms to 1000ms of history.
class HistoryBuffer {
constructor(maxTicks = 60) {
this.maxTicks = maxTicks;
this.frames = [];
}
addFrame(timestamp, bodies) {
const frame = {
timestamp,
entities: new Map()
};
for (const body of bodies) {
frame.entities.set(body.id, {
position: { x: body.position.x, y: body.position.y },
angle: body.angle
});
}
this.frames.push(frame);
if (this.frames.length > this.maxTicks) {
this.frames.shift();
}
}
getSurroundingFrames(targetTimestamp) {
for (let i = this.frames.length - 1; i >= 0; i--) {
if (this.frames[i].timestamp <= targetTimestamp) {
const prev = this.frames[i];
const next = this.frames[i + 1] || prev;
return { prev, next };
}
}
return null;
}
}Run this recording step immediately after each server physics step:
// Run after Matter.Engine.update(engine, delta)
historyBuffer.addFrame(Date.now(), targetBodies);Step 2: Calculate the Target Timestamp
When receiving a fire action from the client, determine the exact historical moment the client observed:
\[\text{Target Timestamp} = \text{Current Server Time} - \text{One-Way Latency (Half Ping)} - \text{Client Interpolation Delay}\]
Validate this timestamp to prevent cheat injection. Clamp the timestamp so it cannot be further back than the maximum size of your history buffer or in the future:
function getValidatedTargetTime(clientTimestamp, rtt, interpDelay, maxRewindMs = 500) {
const now = Date.now();
const halfPing = rtt / 2;
const estimatedClientRenderTime = now - halfPing - interpDelay;
// Prevent values outside the historical tracking window or in the future
return Math.max(now - maxRewindMs, Math.min(now, estimatedClientRenderTime));
}Step 3: Interpolate and Rewind Matter.js Bodies
Retrieve the two bounding snapshots from the buffer and compute a linear interpolation factor (\(t\)) to match the target time accurately.
Before applying the historical data, cache the current positions and
angles of the active Matter.js bodies. Use
Matter.Body.setPosition and
Matter.Body.setAngle to apply the rewind:
import Matter from 'matter-js';
function lerp(start, end, t) {
return start + (end - start) * t;
}
function rewindBodies(historyBuffer, targetTime, activeBodies) {
const surrounding = historyBuffer.getSurroundingFrames(targetTime);
if (!surrounding) return null;
const { prev, next } = surrounding;
const range = next.timestamp - prev.timestamp;
const factor = range > 0 ? (targetTime - prev.timestamp) / range : 0;
// Store present states to restore later
const presentState = new Map();
for (const body of activeBodies) {
const prevSnap = prev.entities.get(body.id);
const nextSnap = next.entities.get(body.id);
if (prevSnap && nextSnap) {
presentState.set(body.id, {
position: { x: body.position.x, y: body.position.y },
angle: body.angle
});
const interpolatedPos = {
x: lerp(prevSnap.position.x, nextSnap.position.x, factor),
y: lerp(prevSnap.position.y, nextSnap.position.y, factor)
};
const interpolatedAngle = lerp(prevSnap.angle, nextSnap.angle, factor);
Matter.Body.setPosition(body, interpolatedPos);
Matter.Body.setAngle(body, interpolatedAngle);
}
}
return presentState;
}Step 4: Perform Raycast and Hit Validation
With target bodies rewound to the historical state, perform the hit
test. Use Matter.Query.ray to find intersections along the
bullet path:
function verifyHit(targetBodies, rayStart, rayEnd, rayWidth = 2) {
// Query bodies along the line segment
const collisions = Matter.Query.ray(targetBodies, rayStart, rayEnd, rayWidth);
if (collisions.length > 0) {
// Sort hits by distance to origin to find the first body struck
collisions.sort((a, b) => {
const distA = Matter.Vector.magnitudeSquared(Matter.Vector.sub(a.body.position, rayStart));
const distB = Matter.Vector.magnitudeSquared(Matter.Vector.sub(b.body.position, rayStart));
return distA - distB;
});
return collisions[0].body;
}
return null;
}Step 5: Restore Bodies to the Present
Instantly restore all modified bodies back to their present positions using the saved state cache. This must happen synchronously in the same tick before the physics engine steps forward:
function restoreBodies(presentState, activeBodies) {
for (const body of activeBodies) {
const original = presentState.get(body.id);
if (original) {
Matter.Body.setPosition(body, original.position);
Matter.Body.setAngle(body, original.angle);
}
}
}Complete Verification Pipeline
Combine all steps into an atomic validation call:
function handlePlayerShot(shooter, shotData, historyBuffer, targets) {
const targetTimestamp = getValidatedTargetTime(
shotData.clientTimestamp,
shooter.rtt,
shooter.interpDelay
);
// 1. Rewind
const presentState = rewindBodies(historyBuffer, targetTimestamp, targets);
if (!presentState) return;
// 2. Validate hit in rewound state
const hitBody = verifyHit(targets, shotData.origin, shotData.direction);
// 3. Restore immediately
restoreBodies(presentState, targets);
// 4. Apply damage if hit was confirmed
if (hitBody) {
applyDamage(hitBody, shooter);
}
}Executing the rewind, raycast, and restoration synchronously within the same event loop tick prevents side effects on the ongoing simulation. Velocities, angular velocities, and force accumulators remain intact, while collision broadphases stay synchronized with the active tick.