How to use fixed time-step in planck.js?
Implementing a fixed time-step with interpolation in planck.js ensures that your physics simulation runs at a consistent speed regardless of frame rate fluctuations, while maintaining butter-smooth visuals. This article covers why a fixed time-step is necessary, how to accumulate delta time, and how to interpolate body positions between physics frames to eliminate visual stutter.
Why Use Fixed Time-Stepping and Interpolation?
In game development, a variable time-step (passing the raw time elapsed since the last frame directly into the physics engine) causes unpredictable physics behavior. If the frame rate drops, the physics engine takes a massive leap forward, causing objects to pass through walls or fly off the screen.
Using a fixed time-step solves this by updating the physics world in strict, uniform increments (e.g., exactly 60 times per second). However, because your rendering frame rate rarely matches the physics update rate perfectly, the physics updates will often fall between render frames. Interpolation bridges this gap by blending an object’s previous position and its current position based on the remaining time, delivering perfectly smooth movement.
Step 1: Setting Up the Accumulator Loop
Instead of calling world.step() once per render frame
with a variable time, you accumulate the elapsed time (\(dt\)) from your main loop. You then run the
physics step as many times as necessary to consume that accumulated time
in fixed chunks.
Here is how you set up the core game loop variables and the update logic:
import { World, Vec2 } from 'planck';
const world = new World(new Vec2(0, -9.8));
const PHYSICS_STEP = 1 / 60; // 60Hz fixed time-step
let accumulator = 0;
let lastTime = performance.now();
function gameLoop(currentTime) {
// Calculate delta time in seconds
let dt = (currentTime - lastTime) / 1000;
lastTime = currentTime;
// Cap the delta time to prevent "spiral of death" during heavy lag
if (dt > 0.25) dt = 0.25;
accumulator += dt;
// Consume the accumulated time in fixed steps
while (accumulator >= PHYSICS_STEP) {
// Save current positions as previous positions before stepping
savePreviousStates();
world.step(PHYSICS_STEP);
accumulator -= PHYSICS_STEP;
}
// Calculate the interpolation factor
const alpha = accumulator / PHYSICS_STEP;
// Render your graphics using the alpha value
render(alpha);
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);Step 2: Tracking Previous and Current States
To interpolate, your rendering logic needs to know two things for every moving body:
- Where the body was before the last physics step.
- Where the body is after the last physics step.
You can attach custom userData to your planck.js bodies to track these properties seamlessly.
function createDynamicBody(position) {
const body = world.createBody({
type: 'dynamic',
position: position
});
// Attach custom tracking object to userData
body.setUserData({
prevPosition: position.clone(),
prevAngle: 0,
currentPosition: position.clone(),
currentAngle: 0
});
return body;
}
function savePreviousStates() {
for (let body = world.getBodyList(); body; body = body.getNext()) {
if (body.getType() === 'dynamic') {
const data = body.getUserData();
if (data) {
data.prevPosition.set(body.getPosition());
data.prevAngle = body.getAngle();
}
}
}
}Step 3: Calculating and Rendering the Interpolation
The interpolation factor (\(\alpha\) or alpha) represents how far along we are between the last physics state and the next physics state. It is always a value between \(0.0\) and \(1.0\).
You calculate the rendered position using linear interpolation (LERP) for positions, and angular linear interpolation for the rotation:
\[\text{Rendered Position} = \text{Previous Position} \times (1 - \alpha) + \text{Current Position} \times \alpha\]
function render(alpha) {
// Clear your canvas/context here...
for (let body = world.getBodyList(); body; body = body.getNext()) {
const data = body.getUserData();
if (!data) continue;
// Fetch the actual current state directly from the body
const currentPos = body.getPosition();
const currentAngle = body.getAngle();
// Linearly interpolate position
const renderX = data.prevPosition.x * (1 - alpha) + currentPos.x * alpha;
const renderY = data.prevPosition.y * (1 - alpha) + currentPos.y * alpha;
// Linearly interpolate angle
const renderAngle = data.prevAngle * (1 - alpha) + currentAngle * alpha;
// Pass renderX, renderY, and renderAngle to your drawing library (PixiJS, Canvas, Three.js)
drawSprite(body, renderX, renderY, renderAngle);
}
}