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.