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.