Your browser game runs beautifully on the machine you built it on. Then someone opens it on a 144 Hz gaming laptop and the jump height changes, or on a throttled phone and a crate falls through the floor. The engine is rarely at fault. What breaks is the seam between the physics simulation and the render loop, and once you build that seam correctly the same code behaves identically everywhere.
Two Worlds, One Bridge
The first thing worth internalising is that a physics engine draws nothing. Its entire job is to maintain a list of bodies, advance them through time, and hand you back positions and rotations. Your renderer maintains a completely separate list of meshes or sprites. These are two parallel data structures that happen to describe the same scene, and your code is the only thing connecting them.
That separation is why engine choice is less locked in than people assume. Rapier will drive Three.js, Babylon.js, PixiJS or a raw WebGL context equally happily. Cannon-es plugs into the same renderers. Havok ships as a first party plugin inside Babylon.js but is not actually tied to it. You are always writing the same bridge: read the transform out of the physics body, write it into the visual object, once per frame.
The Accumulator Loop
Here is the shape that fixes almost every device-dependent bug.
const STEP = 1 / 60;
let accumulator = 0;
let last = performance.now();
function frame(now) {
const dt = Math.min((now - last) / 1000, 0.25); // clamp long stalls
last = now;
accumulator += dt;
while (accumulator >= STEP) {
savePreviousTransforms();
world.step(STEP);
accumulator -= STEP;
}
render(accumulator / STEP); // alpha in 0..1 for interpolation
requestAnimationFrame(frame);
}
Three things are happening. The step size is a constant, so the integrator produces the same result on every machine and the simulation stays deterministic. The while loop runs whole steps until the buffered time is used up, so a device that dropped a frame catches up with several small steps rather than one enormous one, which is what keeps fast objects from tunneling through thin geometry. And the leftover fraction is passed to the renderer as an interpolation factor, so a 144 Hz display gets smooth motion out of a 60 Hz simulation instead of visible stepping.
The clamp on dt matters more than it looks. Without it, a user who switches tabs for thirty seconds comes back to a loop trying to run 1,800 physics steps in one frame, and the page locks up.
Where The Frame Budget Actually Goes
Once the loop is right, the remaining cost is almost entirely collision related, and it is dominated by the shapes you chose rather than by the engine.
Collision shapes are deliberate approximations of your visual models. A character mesh with 10,000 polygons is far too expensive to test every step, so you replace it with a capsule, a box or a convex hull. A sphere against a sphere is a single distance comparison. A pair of convex hulls needs GJK. A triangle mesh is the most expensive option available and should be reserved for static terrain, never for anything that moves.
Body type is the other lever. Static bodies are free to have around, kinematic bodies skip force integration entirely, and only dynamic bodies pay the full cost. Most scenery should be static and most moving platforms should be kinematic. Reaching for dynamic by default is the usual reason a scene that should run at 60 fps does not.
Picking Between JavaScript And WebAssembly
Web physics engines come in two flavours. Pure JavaScript ones like Cannon-es run in the main thread or a worker, are easy to read and debug, and hold up to roughly 500 to 1,000 active bodies at 60 fps. WebAssembly ones like Rapier and Havok compile native Rust or C++ down to WASM and give you near-native speed, which is what you want once the body count climbs.
Rapier's recent SIMD-accelerated packages run several times faster than its 2024 releases thanks to a new dynamic BVH with SIMD tree traversals, and it covers rigid bodies, colliders, joints, character controllers and ray casting. Havok's free WebAssembly build, released under MIT and wired into Babylon.js 6 and later, is dramatically quicker than the older Ammo.js integration but does require WebAssembly SIMD, which means iOS 16.4 or newer. Both produce the same gameplay results as the JavaScript engines. They simply spend less of your frame budget doing it. If you want the side by side including the 2D options, we keep a full breakdown of the web physics landscape up to date.
The Takeaway
Fix the step, accumulate the remainder, clamp the outliers, and interpolate for rendering. That single loop is what turns "works on my machine" into a game that behaves identically on a phone, a laptop and a 144 Hz monitor. Everything after it, engine choice included, is a performance decision rather than a correctness one.
Top comments (0)