Deterministic Physics Replays with Ammo.js
This article explains how to achieve deterministic physics replays in web applications using the ammo.js physics engine. You will learn how to configure the physics world for consistency, record player inputs based on simulation ticks rather than real-world time, reset the initial physical state, and reliably reconstruct the simulation during playback.
1. Configure a Fixed Physics Timestep
The foundation of determinism is ensuring that the physics engine
updates by the exact same time increment on every step. By default,
ammo.js uses a variable timestep to smooth out frame rate
drops, which introduces non-deterministic behavior.
To force a deterministic step, bypass the internal interpolation of
stepSimulation by passing 0 as the max
sub-steps parameter. This forces the engine to simulate exactly the step
size you provide.
const physicsTimeStep = 1 / 60; // 60Hz simulation
// Update loop
function updatePhysics() {
// Passing 0 forces Ammo to step exactly by the physicsTimeStep
physicsWorld.stepSimulation(physicsTimeStep, 0, physicsTimeStep);
}2. Record Inputs Using Tick-Based Indexing
Never record inputs using system timestamps (e.g.,
Date.now() or performance.now()), as frame
rates vary between recording and playback. Instead, use an integer-based
tick counter that increments exactly once per physics step.
Keep a registry of inputs mapped directly to the specific tick number.
let currentTick = 0;
const inputHistory = []; // Array of inputs indexed by tick
function recordTickInput(playerInput) {
inputHistory.push({
tick: currentTick,
input: { ...playerInput } // Store keyboard/mouse states or forces
});
// Apply inputs to physics bodies here
applyInputToPhysics(playerInput);
// Step the simulation
physicsWorld.stepSimulation(physicsTimeStep, 0, physicsTimeStep);
currentTick++;
}3. Reset the Physics World State
Before playing back a recorded sequence, you must restore the exact initial state of all physics bodies. This requires resetting positions, rotations, linear velocities, angular velocities, and clearing active forces.
function resetBodyToState(body, initialState) {
const transform = new Ammo.btTransform();
transform.setIdentity();
// Set position
const origin = transform.getOrigin();
origin.setValue(initialState.posX, initialState.posY, initialState.posZ);
// Set rotation (quaternion)
const rotation = transform.getRotation();
rotation.setValue(initialState.rotX, initialState.rotY, initialState.rotZ, initialState.rotW);
body.setWorldTransform(transform);
// Reset velocities to zero or initial values
const zeroVector = new Ammo.btVector3(0, 0, 0);
body.setLinearVelocity(zeroVector);
body.setAngularVelocity(zeroVector);
// Clear forces
body.clearForces();
// Activate the body so it responds to the new simulation
body.activate(true);
}4. Replay the Recorded Inputs
To replay the simulation, reset the global tick counter to zero and iterate through the recorded input history. Apply the saved inputs at their respective ticks and step the physics world using the identical fixed timestep.
let replayTick = 0;
function runReplayStep() {
if (replayTick >= inputHistory.length) {
console.log("Replay finished");
return;
}
// Retrieve recorded input for the current tick
const recordedFrame = inputHistory[replayTick];
// Apply the inputs exactly as they were recorded
applyInputToPhysics(recordedFrame.input);
// Step the simulation using the exact same timestep
physicsWorld.stepSimulation(physicsTimeStep, 0, physicsTimeStep);
replayTick++;
}5. Address Floating-Point Variations
While the steps above guarantee determinism on the same machine and browser, Ammo.js is a WebAssembly port of the C++ Bullet Physics library. Minor differences in floating-point math execution across different hardware architectures, browsers, or compilation flags can still lead to tiny drifts over long periods.
To mitigate cross-platform drift in multiplayer environments, perform periodic state synchronization (snapshots) to correct minor discrepancies between client simulations.