Integrators and the Fixed Timestep
What You’ll Learn
Section titled “What You’ll Learn”Section 7.1 ended with a problem: a jump solved exactly comes up 4% short once you step it at 60 fps. This Section is about why, and about the two things that actually fix it.
Explicit against semi-implicit Euler — two lines swapped, and the reason games use one and not
the other, which turns out not to be accuracy. Velocity Verlet, which is exact for a constant
acceleration at any timestep. The fixed timestep with an accumulator, so the simulation never
sees a dt it has not seen before. And interpolating between ticks, without which a correct
simulation still looks like it stutters.
Two Lines, and Their Order
Section titled “Two Lines, and Their Order”Every integrator answers the same question: given an acceleration, where is the object next frame? The simplest answer is two lines, and there are two ways to write them.
// Explicit Euler: move, then accelerate.position += velocity * dt;velocity += acceleration * dt;
// Semi-implicit Euler: accelerate, then move.velocity += acceleration * dt;position += velocity * dt;That is the entire difference. And it decides which side of the truth you land on.
Explicit uses the velocity from the start of the step, so a falling object is credited with the speed it had before gravity sped it up — it travels too far. Semi-implicit uses the velocity from the end, so it is credited with the speed it has after speeding up, and travels too little.
src/lib/gamedev/demos/integrators.scene.ts /** The same throw stepped three ways, with the exact parabola underneath for comparison. */
import * as THREE from "three";
import {
METHODS,
exactPath,
maxErrorOf,
path,
type Method,
} from "./integrators-shared.ts";
import {
makeCanvas,
addSlider,
addReadout,
addPolyline,
addKey,
} from "./ui.ts";
import type { MountFn } from "./runner.ts";
const COLOUR: Record<Method, number> = {
explicit: 0xff7b72,
"semi-implicit": 0x39d3c3,
Verlet: 0x7ee787,
};
const TRUTH = 0x8b949e;
const mount: MountFn = (el) => {
const { renderer, width, height, background } = makeCanvas(el, 290);
const scene = new THREE.Scene();
scene.background = background;
const aspect = width / height;
const halfHeight = 1.5;
const camera = new THREE.OrthographicCamera(
-halfHeight * aspect,
halfHeight * aspect,
halfHeight,
-halfHeight,
0.1,
100,
);
camera.position.set(halfHeight * aspect - 0.4, 0.55, 10);
const ground = addPolyline(scene, TRUTH);
ground([new THREE.Vector3(-1, 0, 0), new THREE.Vector3(20, 0, 0)]);
const truth = addPolyline(scene, TRUTH, {
dashed: true,
dashSize: 0.1,
gapSize: 0.08,
});
const lines = METHODS.map((m) => addPolyline(scene, COLOUR[m]));
const show = addReadout(el);
const key = addKey(el, [TRUTH, ...METHODS.map((m) => COLOUR[m])]);
const rate = addSlider(
el,
"physics ticks per second",
5,
90,
12,
draw,
" Hz",
1,
);
function draw() {
const fps = rate();
truth(exactPath().map((p) => new THREE.Vector3(p.x, p.y, 0)));
METHODS.forEach((m, i) => {
lines[i](path(m, fps).map((p) => new THREE.Vector3(p.x, p.y, 0)));
});
key([
"exact parabola",
...METHODS.map((m) => `${m}: out by ${maxErrorOf(m, fps).toFixed(3)} m`),
]);
show(
`${fps} ticks per second, so each step covers ${(1 / fps).toFixed(3)} s \u00B7 ` +
`Verlet sits on the exact curve at every rate, because gravity is the same everywhere`,
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; Wind the tick rate right down and watch them separate. The dashed grey line is the exact parabola.
Two things fall out, both checked at every rate:
- The two Eulers are wrong by exactly the same amount, in opposite directions. Their average is the truth, to the last bit. At 60 Hz the 1.2 m apex becomes 1.25 and 1.15.
- All three get the velocity exactly right. Velocity under a constant acceleration is a straight
line, and all three add
a * dtonce per step. Every disagreement here is about position, which is worth knowing before blaming an integrator for the wrong symptom.
So why prefer semi-implicit?
Section titled “So why prefer semi-implicit?”Not accuracy. Undershooting is no better than overshooting. The reason is stability, and it only shows up when the acceleration depends on where you are.
Gravity does not. A spring does.
src/lib/gamedev/demos/stability.scene.ts /** A spring drawn as position against velocity, where added energy shows up as a spiral. */
import * as THREE from "three";
import {
METHODS,
amplitudeRatio,
phasePath,
type Method,
} from "./stability-shared.ts";
import {
makeCanvas,
addSlider,
addReadout,
addPolyline,
addKey,
} from "./ui.ts";
import type { MountFn } from "./runner.ts";
const COLOUR: Record<Method, number> = {
explicit: 0xff7b72,
"semi-implicit": 0x39d3c3,
Verlet: 0x7ee787,
};
const TRUTH = 0x8b949e;
const VIEW = 2.6;
const mount: MountFn = (el) => {
const { renderer, width, height, background } = makeCanvas(el, 290);
const scene = new THREE.Scene();
scene.background = background;
const aspect = width / height;
const camera = new THREE.OrthographicCamera(
-VIEW * aspect,
VIEW * aspect,
VIEW,
-VIEW,
0.1,
100,
);
camera.position.z = 10;
// The exact orbit: a unit circle, because energy is conserved and velocity is scaled to match.
const circle = addPolyline(scene, TRUTH, {
dashed: true,
dashSize: 0.1,
gapSize: 0.09,
});
circle(
Array.from({ length: 97 }, (_, i) => {
const a = (i / 96) * Math.PI * 2;
return new THREE.Vector3(Math.cos(a), Math.sin(a), 0);
}),
);
const axes = addPolyline(scene, 0x30363d);
axes([
new THREE.Vector3(-VIEW * aspect, 0, 0),
new THREE.Vector3(VIEW * aspect, 0, 0),
]);
const lines = METHODS.map((m) => addPolyline(scene, COLOUR[m]));
const show = addReadout(el);
const key = addKey(el, [TRUTH, ...METHODS.map((m) => COLOUR[m])]);
const rate = addSlider(
el,
"physics ticks per second",
20,
120,
60,
draw,
" Hz",
5,
);
const cycles = addSlider(el, "oscillations to run", 1, 12, 4, draw, "", 1);
function draw() {
const fps = rate();
const n = cycles();
METHODS.forEach((m, i) => {
lines[i](
phasePath(m, fps, n)
.filter((p) => Math.abs(p.x) < 40 && Math.abs(p.y) < 40)
.map((p) => new THREE.Vector3(p.x, p.y, 0)),
);
});
key([
"exact: a closed circle",
...METHODS.map((m) => `${m} x${amplitudeRatio(m, fps, n).toFixed(2)}`),
]);
show(
`across is position, up is velocity \u00B7 after ${n} oscillation${n === 1 ? "" : "s"} at ${fps} Hz, ` +
`explicit Euler has grown to ${amplitudeRatio("explicit", fps, n).toFixed(2)}x its starting swing ` +
`while semi-implicit is still at ${amplitudeRatio("semi-implicit", fps, n).toFixed(2)}x`,
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; This is phase space: position across, velocity up. An undamped spring should hold its energy forever, so the exact answer is a closed circle — round and round, the same orbit every time.
Explicit Euler spirals outward. It adds a little energy every step, from nothing, and the oscillation grows until the numbers blow up. Semi-implicit stays on the circle.
The magnitudes are not subtle. At 60 Hz over twenty seconds, explicit Euler multiplies the spring’s energy by 573,000. Semi-implicit ends within 5% of where it started. Verlet within 0.2%.
That is the whole argument. Semi-implicit gives you a wrong answer that stays wrong by a fixed amount. Explicit gives you a wrong answer that becomes infinity. For anything with a spring in it — suspension, cloth, ragdolls, camera smoothing, a grappling hook — that is the difference between a game and a crash.
The check runs the spring for forty oscillations at three rates and requires semi-implicit to stay between half and double its starting energy, while requiring explicit to keep growing every time it is asked.
Velocity Verlet
Section titled “Velocity Verlet”There is a third option that costs one more evaluation and is worth knowing about:
The extra is the term both Eulers are missing — it is the second half of the Taylor expansion the Eulers truncate. For a constant acceleration that makes Verlet exact.
Not close. Exact. Set the tick rate to 5 Hz in the scene above and its error is still zero. Section 7.1’s shortfall simply does not happen, and the check asserts the samples sit on the parabola to at rates from 5 Hz upward.
The Fixed Timestep
Section titled “The Fixed Timestep”Now the second fix, and the more important one.
Everything above assumed a single dt. In a real game dt is whatever the last frame took, and it
varies — a garbage collection, a shader compile, someone dragging a window. And a variable dt
makes physics non-deterministic: the same inputs give different results, so replays desync,
networked clients disagree, and a bug that only happens on a slow machine cannot be reproduced on a
fast one.
Worse, a single long frame can step an object straight through a wall, which is Section 6.3’s tunneling arriving from a different direction.
The fix is to stop letting the simulation see the frame time at all:
- Add the frame’s elapsed time to an accumulator.
- While the accumulator holds at least one fixed step, take one out and simulate exactly that.
- Keep the remainder for next frame.
The simulation only ever sees one value of dt. Ever.
src/lib/gamedev/demos/accumulator.ts /** How many physics ticks each display frame gets, and the leftover that has to be interpolated. */
import { alphaFrom, stepsFor } from "../integrators.ts";
import type { Demo } from "./runner.ts";
const FIXED = 1 / 60;
/** The tick count for each of twelve frames at a given display rate. */
function pattern(displayHz: number): { counts: number[]; leftover: number } {
let leftover = 0;
const counts: number[] = [];
for (let i = 0; i < 12; i += 1) {
const r = stepsFor(leftover, 1 / displayHz, FIXED);
leftover = r.leftover;
counts.push(r.steps);
}
return { counts, leftover };
}
const demo: Demo = (log) => {
for (const hz of [60, 30, 144, 50]) {
const { counts, leftover } = pattern(hz);
log(
`${hz} Hz display, 60 Hz physics: ticks over twelve frames`,
counts.join(" "),
hz === 144
? "most frames get none, so without interpolation nothing moves on them"
: hz === 50
? "an uneven pattern, which is what stutter actually looks like"
: undefined,
);
log(
` leftover in the accumulator`,
`${(leftover * 1000).toFixed(2)} ms, so alpha = ${alphaFrom(leftover, FIXED).toFixed(2)}`,
hz === 60 ? "alpha is how far between two ticks to draw" : undefined,
);
}
// A frame that takes far too long has to be clamped, or the accumulator never empties.
const stall = stepsFor(0, 1, FIXED);
log(
"after a one second stall, ticks wanted was 60 but",
`${stall.steps} ran, ${(stall.leftover * 1000).toFixed(0)} ms still owed`,
"the clamp trades slow motion for not locking up",
);
};
export default demo; Read the tick patterns. At 60 Hz display against 60 Hz physics, every frame gets exactly one tick. At 30 Hz, every frame gets two. At 144 Hz, most frames get none at all — and over ten seconds every one of those rates runs the same number of ticks, within one, which is the entire promise.
Interpolating Between Ticks
Section titled “Interpolating Between Ticks”Fixed ticks introduce a new problem. Look at the 144 Hz row again: most frames get no tick, so if you draw the latest simulated position, the object does not move on those frames. The physics is perfectly smooth and the picture stutters.
The leftover in the accumulator is the answer. It is not waste — it says how far past the last tick the display currently is:
So keep two states, the previous tick and the current one, and draw Section 4.2’s lerp between
them. The object then moves every frame, at whatever rate the display runs, from a simulation that
never varied its step.
This is why physics state and render state are separate things in every engine that gets this right. The check confirms the blend never leaves the interval between the two ticks, and that alpha is clamped at both ends rather than allowed to extrapolate.
Interpolating rotations needs Section 3.3’s slerp, not lerp, for all the reasons that page gave.
source Three integrators, the accumulator, and the render blend
/**
* Turning an acceleration into motion, one step at a time - and the two orderings that look
* identical and are not.
*
* Section 7.1 ended on a problem: a jump solved exactly and then stepped at 60 fps comes up short
* by `dt / t_apex`. This is where that gets addressed. The fix is not a smaller timestep, it is
* caring about **which order the two updates happen in** and about **not letting the timestep vary
* at all**.
*
* Everything here is written for one axis. That is not a simplification: position and velocity in
* `x` are unaffected by anything happening in `y`, so an integrator applies componentwise and a
* scalar version is the whole story with less noise around it.
*/
/** Where something is and how fast it is going, on one axis. */
export type State = { position: number; velocity: number };
/** The rule for the acceleration. Constant for gravity, position-dependent for a spring. */
export type Acceleration = (state: State) => number;
/** Gravity: the same number wherever you are and however fast you are going. */
export const constant =
(a: number): Acceleration =>
() =>
a;
/** A spring pulling towards zero. Undamped, so it should oscillate forever. */
export const spring =
(stiffness: number): Acceleration =>
(s) =>
-stiffness * s.position;
/**
* **Explicit Euler**, also called forward Euler: move, then accelerate.
*
* The position update uses the velocity from the *start* of the step, before gravity has been
* applied. So a falling object is credited with the speed it had before it sped up, and it travels
* further than it should. On an oscillator it does something worse than inaccurate: it **gains
* energy** every cycle and spirals outward until the simulation explodes.
*
* It is the version everyone writes first, because it reads in the order you would say it aloud.
*/
export function stepExplicit(
state: State,
accel: Acceleration,
dt: number,
): State {
const position = state.position + state.velocity * dt;
const velocity = state.velocity + accel(state) * dt;
return { position, velocity };
}
/**
* **Semi-implicit Euler**, also called symplectic Euler: accelerate, then move.
*
* Two lines swapped, and this is what games use. The position update uses the velocity from the
* *end* of the step, so a falling object is credited with the speed it has after speeding up, and
* it travels slightly too little rather than slightly too much.
*
* Undershooting is not obviously better than overshooting, and accuracy is not the reason to
* prefer it. **Stability** is. On an oscillator it stays bounded forever instead of spiralling
* outward, which means a wrong answer that stays wrong by a fixed amount rather than a wrong
* answer that becomes infinity.
*/
export function stepSemiImplicit(
state: State,
accel: Acceleration,
dt: number,
): State {
const velocity = state.velocity + accel(state) * dt;
return { position: state.position + velocity * dt, velocity };
}
/**
* **Velocity Verlet**: use the average of the accelerations at both ends of the step.
*
* $$x_{n+1} = x_n + v_n\,dt + \tfrac{1}{2} a_n\,dt^2 \qquad v_{n+1} = v_n + \tfrac{1}{2}(a_n + a_{n+1})\,dt$$
*
* That extra $\tfrac{1}{2} a\,dt^2$ term is the one the two Eulers are missing, and for a
* **constant** acceleration it makes this exact - it reproduces the parabola at any timestep, to
* the last bit. Section 7.1's shortfall simply does not happen.
*
* It costs a second acceleration evaluation per step, which is why it is not the default: for a
* character controller the acceleration is gravity plus input and the Euler error is invisible.
* Where it earns its keep is cloth, rope and soft bodies, where the acceleration depends on
* position and the error compounds across thousands of connected particles.
*/
export function stepVerlet(
state: State,
accel: Acceleration,
dt: number,
): State {
const a = accel(state);
const position = state.position + state.velocity * dt + 0.5 * a * dt * dt;
const halfway = { position, velocity: state.velocity };
const velocity = state.velocity + 0.5 * (a + accel(halfway)) * dt;
return { position, velocity };
}
/** The exact answer for a constant acceleration, to compare all three against. */
export function exact(start: State, acceleration: number, t: number): State {
return {
position: start.position + start.velocity * t + 0.5 * acceleration * t * t,
velocity: start.velocity + acceleration * t,
};
}
/**
* The energy in a spring-mass system, which is what tells the integrators apart.
*
* Kinetic plus potential. An undamped spring should hold this exactly forever, so any drift is the
* integrator's and not the physics'. Explicit Euler grows it, semi-implicit wobbles around it, and
* that difference is the whole argument.
*/
export function energy(state: State, stiffness: number): number {
return (
0.5 * state.velocity * state.velocity +
0.5 * stiffness * state.position * state.position
);
}
// ---- The fixed timestep -------------------------------------------------------------------
/**
* How many fixed steps to run for a frame of real time, and what is left over.
*
* The loop is: add the frame's elapsed time to an accumulator, then take **whole fixed steps** out
* of it while there are any, and keep the remainder for next time. The simulation only ever sees
* one value of `dt`, so it behaves identically on a 30 Hz laptop and a 240 Hz desktop.
*
* `maxSteps` is not optional. If a frame takes longer to compute than the fixed step it is
* simulating, the accumulator grows, so next frame needs more steps, which takes longer still -
* the **spiral of death**. Clamping means the simulation falls behind real time under load, which
* looks like slow motion and is survivable, instead of locking up.
*/
export function stepsFor(
accumulated: number,
frameTime: number,
fixed: number,
maxSteps = 5,
): { steps: number; leftover: number; dropped: boolean } {
let pool = accumulated + frameTime;
const wanted = Math.floor(pool / fixed);
const steps = Math.min(wanted, maxSteps);
pool -= steps * fixed;
return { steps, leftover: pool, dropped: wanted > maxSteps };
}
/**
* How far through the next fixed step the display currently is, from 0 to 1.
*
* The leftover in the accumulator is not waste, it is information: it says the renderer is looking
* at a moment *between* two simulated states.
*/
export function alphaFrom(leftover: number, fixed: number): number {
const a = leftover / fixed;
return a < 0 ? 0 : a > 1 ? 1 : a;
}
/**
* The state to actually draw: the previous tick and the current one, blended.
*
* Skip this and the object jumps to whichever tick was most recent, which reads as stutter even
* though the physics is perfectly smooth - and it is worst exactly when the display rate is not a
* multiple of the tick rate, because then the pattern of which frames got a tick keeps changing.
*/
export function blend(previous: State, current: State, alpha: number): State {
return {
position:
previous.position + (current.position - previous.position) * alpha,
velocity:
previous.velocity + (current.velocity - previous.velocity) * alpha,
};
} Where This Shows Up
Section titled “Where This Shows Up”- Anything that must be reproducible: replays, networked play, physics puzzles that have to solve the same way twice.
- Springs of every kind — suspension, camera arms, grappling hooks, ragdolls — where explicit Euler is not a small error but an eventual crash.
- Cloth, rope and hair, which is where Verlet becomes worth its extra evaluation.
- Stutter that survives a profiler: a solid frame rate with visibly jerky motion is almost always a missing render interpolation.
- Slowdown under load, which is the step clamp doing its job rather than a bug.
- Section 6.3’s tunneling, which a fixed step bounds and a variable one does not.
- The capstone, which runs its character on a fixed tick and interpolates the camera between.