Clock Synchronization in Ammo.js Multiplayer
This article provides a practical guide on how to establish clock synchronization for deterministic multiplayer physics simulations using ammo.js (the WebAssembly port of Bullet Physics). It covers the mathematical logic behind network time synchronization, the implementation of a fixed physics timestep, and how to execute client-side prediction and reconciliation to keep physics states aligned across the network.
The Challenge of Determinism in Ammo.js
Ammo.js is a deterministic physics engine, meaning that given the exact same initial state and inputs, it will produce the identical output. However, in a multiplayer environment, network latency and variable frame rates cause clients to process inputs at different real-world times. To maintain determinism, every client and the server must run the physics simulation at the exact same logical ticks, requiring a highly accurate synchronized clock.
Step 1: Implementing Network Time Synchronization
To synchronize the client clock with the server, you must calculate the network latency (Round-Trip Time, or RTT) and the clock offset between the machines. This is achieved using a simplified Network Time Protocol (NTP) handshake:
- The client sends a ping containing its current local timestamp (\(T_1\)).
- The server receives the ping and immediately sends a pong containing the server’s current timestamp (\(T_2\)) along with the client’s original timestamp (\(T_1\)).
- The client receives the pong at local timestamp (\(T_3\)).
With these three timestamps, the client calculates the Round-Trip Time and the clock offset (\(\theta\)):
\[\text{RTT} = T_3 - T_1\] \[\theta = T_2 - \left(T_1 + \frac{\text{RTT}}{2}\right)\]
By adding the offset \(\theta\) to the client’s local system clock, the client can calculate the current server time. Because network jitter can skew individual measurements, you should sample this offset periodically (e.g., every 3 seconds) and apply a rolling average or a Kalman filter to smooth out spikes.
Step 2: Enforcing a Fixed Timestep in Ammo.js
A variable delta time passed to ammo.js will immediately break determinism. You must configure ammo.js to step using a strictly fixed timestep (usually 60Hz, or 0.01666s).
When stepping the simulation, do not rely on the engine’s internal interpolation. Instead, manage the accumulator manually and run the simulation using a sub-step size of 0 to force discrete execution:
// Ensure we step exactly by our fixed interval
const fixedTimeStep = 1 / 60;
let physicsAccumulator = 0;
function updatePhysics(deltaTime) {
physicsAccumulator += deltaTime;
while (physicsAccumulator >= fixedTimeStep) {
// stepSimulation(timeStep, maxSubSteps, fixedTimeStep)
// Setting maxSubSteps to 0 forces Ammo to step exactly by fixedTimeStep
dynamicsWorld.stepSimulation(fixedTimeStep, 0, fixedTimeStep);
physicsAccumulator -= fixedTimeStep;
localTick++; // Increment local simulation tick
}
}Step 3: Client-Side Prediction and Reconciliation
With the clock synchronized, the client can run its simulation ahead of the server by the one-way latency duration (\(\text{RTT} / 2\)). This allows user inputs to feel instantaneous.
To resolve discrepancies when the server sends authoritative state updates, implement input prediction and reconciliation:
- Input Buffering: The client stores its inputs and the resulting physics states (positions, rotations, linear/angular velocities) in a historical ring buffer, indexed by the synchronized tick number.
- Server Verification: The server processes inputs sequentially based on the synchronized ticks and periodically broadcasts the authoritative state of all rigid bodies, stamped with the tick number.
- Reconciliation: When the client receives a server
state for tick \(N\), it compares its
buffered state at tick \(N\) with the
server’s state. If the difference exceeds a tiny threshold (accounting
for minor floating-point drift):
- The client snaps the ammo.js rigid bodies back to the server’s authoritative position and velocity values.
- The client clears the accumulated physics time and re-runs the physics loop from tick \(N\) up to the current predicted local tick, re-applying the buffered inputs sequentially.
Step 4: Mitigating WebAssembly Platform Drift
While WebAssembly executes the same compiled C++ code of Bullet, slight differences in floating-point math execution (such as x87 80-bit floats vs. SSE 64-bit floats, or browser-specific JavaScript engine optimizations) can lead to minor divergences over time.
To prevent these drifts from causing permanent desynchronization, the server must act as the ultimate authority. Always apply a soft-correction (lerping/hermite interpolation) to visual meshes when reconciling, rather than hard-snapping, to keep the gameplay visual experience smooth while the underlying ammo.js rigid bodies are precisely corrected to match the synchronized server simulation.