Delta Time and Frame Rate Independence
What You’ll Learn
Section titled “What You’ll Learn”That the single most commonly written smoothing line in game code is wrong, and wrong in a way that makes your game feel different on different computers. Why multiplying by delta time does not fix it. What exponential decay is and why it is the actual answer. And half-life, which turns an unreadable tuning constant into a sentence a designer can say out loud.
This Section comes before anything else in Part 4 because everything that moves depends on it.
The Line Everybody Writes
Section titled “The Line Everybody Writes”Something needs to follow something else smoothly - a camera trailing the player, a turret easing onto a target, a health bar sliding down. The obvious code is:
// Every frame:current = lerp(current, target, 0.1);Ten percent of the remaining distance, every frame. It works. It looks good on your machine. It is also a bug, and here is the shape of it.
That line closes a fixed fraction per frame. So the number of frames it needs is fixed, which means the number of seconds it needs is whatever your hardware decides:
Twenty-two frames, always. On a 30 fps laptop that is nearly three quarters of a second. On a 144 Hz monitor it is a sixth of a second.
src/lib/gamedev/demos/dtmath.ts /** Why a fixed blend factor ties feel to hardware, and why decay does not. */
import { remainingAfter } from "../interpolation.ts";
import {
START,
TARGET,
framesToClose,
simulateDamped,
} from "./framerate-shared.ts";
import type { Demo } from "./runner.ts";
const FACTOR = 0.1;
const HALF_LIFE = 0.25;
const SECONDS = 1;
/** How much of the gap a simulation left unclosed. */
const leftover = (x: number) => (TARGET - x) / (TARGET - START);
const demo: Demo = (log) => {
const frames = framesToClose(FACTOR, 0.9);
log("frames for lerp(..., 0.1) to close 90%", frames.toFixed(1));
log("so at 30 fps that takes", `${(frames / 30).toFixed(2)} s`);
log(
"and at 144 fps",
`${(frames / 144).toFixed(2)} s`,
"same code, 4.8x quicker on better hardware",
);
log(
"decay, half-life 0.25 s, left after 1 s at 30 fps",
leftover(simulateDamped(30, SECONDS, HALF_LIFE)).toFixed(6),
);
log(
"the same at 144 fps",
leftover(simulateDamped(144, SECONDS, HALF_LIFE)).toFixed(6),
"identical, as it has to be",
);
log(
"and in one single enormous frame",
leftover(simulateDamped(1, SECONDS, HALF_LIFE)).toFixed(6),
`closed form 0.5^(1/0.25) = ${remainingAfter(HALF_LIFE, SECONDS)}`,
);
};
export default demo; 4.8 times quicker, from identical code. The camera that felt weighty and cinematic while you were building it feels twitchy and instant to somebody with a better monitor - or the reverse, which is worse, because sluggish controls read as broken.
What dt Is
Section titled “What dt Is”Everything from here on is written in terms of dt, so it is worth being exact about what that
is before leaning on it.
dt is how long the last frame took, in seconds. The name is short for delta time, and
“delta” is the standard word for a change in a quantity - the Greek letter is used the
same way in maths, so and dt mean the same thing: a change in time, an interval.
The important part is that it is measured, not chosen. A game loop runs as fast as it can and each pass round takes however long it takes, so at the top of every frame the engine looks at the clock, subtracts the last reading, and hands you the difference:
let previous = now();
function frame() { const current = now(); const dt = (current - previous) / 1000; // seconds, not milliseconds previous = current;
update(dt); // your code gets told how much time passed render(); requestAnimationFrame(frame);}So dt is a small number, and which small number depends entirely on the player’s machine:
| Frame rate | dt |
|---|---|
| 30 fps | s |
| 60 fps | s |
| 120 fps | s |
| 144 fps | s |
| a bad frame | s or worse |
That last row matters. dt is not a constant - it changes every single frame, and
occasionally it changes a lot, because something had to be loaded or the operating system went
away for a moment. Any code that assumes a steady frame time is making a promise the hardware
never gave.
Delta Time Alone Does Not Fix It
Section titled “Delta Time Alone Does Not Fix It”The standard first attempt is to reach for dt:
current = lerp(current, target, 0.1 * dt); // still not rightThis is better, and it is still wrong. The reason is that is not a rate - it is a proportion, and proportions do not scale by multiplication the way distances do. Closing 10% twice does not close 20%, it closes 19%.
So the velocity * dt reasoning does not transfer. It is right in the limit of very small frames
and drifts away as frames get longer, because the errors compound: each frame’s blend is applied
to the result of the last one, so what accumulates is a product, not a sum.
Multiplying by dt treats the approach as if it were linear. It is not - it is a fraction of a
fraction of a fraction, which is exponential. And there is a second problem: with a long enough
frame, 0.1 * dt can exceed 1, and the value shoots straight past the target and oscillates.
So the fix is not a patch on the factor. It is computing the factor correctly in the first place.
Exponential Decay
Section titled “Exponential Decay”Ask the right question. Not “what fraction should I close this frame” but “what fraction should be left after this much time”.
If a fixed proportion of the gap survives each second, the amount left after time is an exponential:
What is
Section titled “What kkk is”is the decay rate: one number saying how aggressively the gap closes. Bigger means faster. It is the same constant that appears in radioactive decay, a capacitor discharging and a cup of coffee cooling - this is the standard shape those all share, and is the standard letter for the knob on it.
Its units are per second, and there is a tidy reason why. An exponent has to be a plain number - is meaningless - so if is in seconds then must be in for the product to cancel down to nothing. Any time you are unsure whether a rate belongs on the top or the bottom, that check settles it.
Two ways to get a feel for a particular :
- After seconds, the amount left is , so 63% of the gap has closed. That duration is called the time constant.
- means the object never moves at all. Large means it snaps to the target almost immediately. Nothing in between is special.
The trouble is the direction of the relationship. is a rate sitting inside an exponent, so its value is inversely related to the duration you actually care about, and reading a felt duration off it requires dividing:
| Time constant | Half-life | Feels like | |
|---|---|---|---|
| s | s | snappy | |
| s | s | a weighted camera | |
| s | s | sluggish |
Which is exactly why the section after next hands you a different knob for the same dial.
Whatever you pick, the fraction to close in a frame of length is whatever is not left:
And that is the whole fix. One line, in decayFactor below.
Why it works is worth a sentence, because it is the reason this is exactly right rather than merely better. The amount left after a step is . Two steps multiply, and multiplying exponentials adds the exponents:
So the result depends only on the total time, never on how that time was divided into frames. Sixty frames or six or one enormous one - same answer. The value list above shows exactly that: left after one second, identically at 30 fps, 144 fps, and in a single step.
See It Fail, Then Not Fail
Section titled “See It Fail, Then Not Fail”Both rows below are the same chase from left to right. Orange is 30 fps, blue is 144 fps, and the grey line joins them so you can see whether they agree - vertical means they agree, slanted means they do not.
The top row uses the fixed factor. The bottom row computes the factor from the timestep.
src/lib/gamedev/demos/framerate.scene.ts /**
* The same chase at 30 and 144 fps, done with a fixed factor above and with decay below.
*/
import * as THREE from "three";
import {
START,
TARGET,
simulateDamped,
simulateNaive,
} from "./framerate-shared.ts";
import { makeCanvas, addSlider, addReadout } from "./ui.ts";
import type { MountFn } from "./runner.ts";
const SLOW = 30;
const FAST = 144;
const FACTOR = 0.1;
const HALF_LIFE = 0.15;
const SLOW_COLOR = 0xf0883e;
const FAST_COLOR = 0x58a6ff;
const mount: MountFn = (el) => {
const { renderer, width, height, background } = makeCanvas(el, 250);
const scene = new THREE.Scene();
scene.background = background;
const camera = new THREE.PerspectiveCamera(38, width / height, 0.1, 100);
camera.position.set(0, 0, 7.2);
camera.lookAt(0, 0, 0);
const line = (pts: THREE.Vector3[], color: number) => {
const l = new THREE.Line(
new THREE.BufferGeometry().setFromPoints(pts),
new THREE.LineBasicMaterial({ color }),
);
scene.add(l);
return l;
};
/** One row: a track, a start tick, a target tick, two dots and a connector between them. */
function row(y: number) {
line(
[new THREE.Vector3(START, y, 0), new THREE.Vector3(TARGET, y, 0)],
0x30363d,
);
line(
[
new THREE.Vector3(START, y - 0.28, 0),
new THREE.Vector3(START, y + 0.28, 0),
],
0x545d68,
);
line(
[
new THREE.Vector3(TARGET, y - 0.34, 0),
new THREE.Vector3(TARGET, y + 0.34, 0),
],
0x39d3c3,
);
const dot = (color: number) => {
const m = new THREE.Mesh(
new THREE.SphereGeometry(0.15, 14, 10),
new THREE.MeshBasicMaterial({ color }),
);
scene.add(m);
return m;
};
const slow = dot(SLOW_COLOR);
const fast = dot(FAST_COLOR);
// Vertical means the two frame rates agree. Slanted means they do not.
const link = line([new THREE.Vector3(), new THREE.Vector3()], 0x7d8590);
return (slowX: number, fastX: number) => {
slow.position.set(slowX, y + 0.16, 0);
fast.position.set(fastX, y - 0.16, 0);
link.geometry.setFromPoints([
new THREE.Vector3(slowX, y + 0.16, 0),
new THREE.Vector3(fastX, y - 0.16, 0),
]);
};
}
const naiveRow = row(0.95);
const dampedRow = row(-0.95);
const show = addReadout(el);
const time = addSlider(
el,
"seconds since the target moved",
0,
1.2,
0.3,
draw,
" s",
0.01,
);
function draw() {
const t = time();
const nSlow = simulateNaive(SLOW, t, FACTOR);
const nFast = simulateNaive(FAST, t, FACTOR);
const dSlow = simulateDamped(SLOW, t, HALF_LIFE);
const dFast = simulateDamped(FAST, t, HALF_LIFE);
naiveRow(nSlow, nFast);
dampedRow(dSlow, dFast);
show(
`fixed factor: ${Math.abs(nSlow - nFast).toFixed(2)} apart \u00B7 ` +
`from the timestep: ${Math.abs(dSlow - dFast).toFixed(2)} apart`,
);
renderer.render(scene, camera);
}
draw();
return () => renderer.dispose();
};
export default mount; Scrub the slider. The top pair pulls apart immediately and at one point sits over three units apart on a six-unit track - the 144 Hz follower has essentially arrived while the 30 fps one is still halfway. The bottom pair stays vertical the whole way.
Not approximately vertical. Across eight frame rates from 24 to 240 and forty different durations, the build check measures the worst spread at , which is floating point noise rather than disagreement.
source The fix, and the small functions it is built from
/**
* Blending values, and blending them at a rate that does not depend on the frame rate.
*
* The first three functions are the small ones everything else is built from. The last group is
* the point of Section 4.1: a blend factor computed **from the timestep** rather than picked as
* a constant, so the same code feels the same on a 30 Hz laptop and a 144 Hz monitor.
*
* Section 4.2 adds the easing family to this file.
*/
/** Start at `a`, go a fraction `t` of the way towards `b`. */
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
/** Hold a value inside a range. */
export function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v;
}
/** The common case: keep a blend factor inside [0, 1]. */
export function clamp01(v: number): number {
return clamp(v, 0, 1);
}
/**
* Lerp run backwards: given a value, what `t` would have produced it?
*
* Answers "how far through this range am I", which is how a raw quantity becomes a fraction you
* can drive something else with. Deliberately not clamped, so a value outside the range reports
* honestly - `inverseLerp(0, 10, 15)` is `1.5`, not `1`.
*
* The guard matters. A zero-width range divides by zero and produces `NaN`, which then spreads
* silently through everything downstream, so it returns 0 instead.
*/
export function inverseLerp(a: number, b: number, v: number): number {
if (a === b) return 0;
return (v - a) / (b - a);
}
/**
* Carry a value from one range into another - the workhorse of any code touching a UI.
*
* It is just `inverseLerp` followed by `lerp`: work out how far through the input range the value
* sits, then go that far through the output range.
*/
export function remap(
v: number,
inMin: number,
inMax: number,
outMin: number,
outMax: number,
): number {
return lerp(outMin, outMax, inverseLerp(inMin, inMax, v));
}
// ---- Frame-rate independence -------------------------------------------------------------
/**
* The blend factor to use this frame, given a decay `rate` and however long the frame took.
*
* This is the whole fix. A constant factor closes a fixed **fraction per frame**, so more
* frames means faster convergence and the feel of the game changes with the hardware. This
* closes a fixed fraction **per second** instead, by asking how much time actually passed.
*
* The reason it works is that the leftover distance after a step is `exp(-rate * dt)`, and
* multiplying those together over a series of steps adds the exponents - so the total only
* depends on the total time, never on how it was chopped up.
*/
export function decayFactor(rate: number, dt: number): number {
return 1 - Math.exp(-rate * dt);
}
/**
* Convert a **half-life** into a decay rate.
*
* Half-life is the number to expose to whoever is tuning the feel, because it means something
* out loud: "the camera closes half the remaining distance every 0.15 seconds". A raw rate
* means nothing to anybody.
*/
export function rateFromHalfLife(halfLife: number): number {
return Math.LN2 / halfLife;
}
/** Back the other way, for reading a rate someone else picked. */
export function halfLifeFromRate(rate: number): number {
return Math.LN2 / rate;
}
/**
* One frame of frame-rate-independent smoothing towards a target.
*
* The drop-in replacement for `lerp(current, target, 0.1)`, and the only difference is that it
* is told how long the frame took.
*/
export function damp(
current: number,
target: number,
rate: number,
dt: number,
): number {
return lerp(current, target, decayFactor(rate, dt));
}
/** How much of the gap is still left after `seconds`, given a half-life. */
export function remainingAfter(halfLife: number, seconds: number): number {
return Math.pow(0.5, seconds / halfLife);
} Half-Life Is the Number to Expose
Section titled “Half-Life Is the Number to Expose”There is still a in there, and as the table above showed, is a poor thing to hand somebody. “Set the camera rate to per second” is not a sentence anybody can act on - you have to compute or before it means a duration.
So do not expose it. Keep as the thing the maths uses, and expose the half-life instead: the time to close half the remaining distance.
Now the tuning parameter is a sentence. “The camera closes half the distance every 0.15 seconds.” That is something you can discuss, compare between two cameras, and put in a config file that still makes sense in a year.
It also makes the behaviour easy to predict in your head, since the remaining fraction is just repeated halving:
| Time elapsed | Half-lives | Gap remaining |
|---|---|---|
| 1 | ||
| 2 | ||
| 3 | ||
| 4 |
With a half-life of s, one second is four half-lives, so a sixteenth of the gap is left - the from the value list, which is not a coincidence but the closed form.
Rough guide for picking one: around s feels snappy and responsive, s reads as a weighted camera, and past s starts to feel like the controls are not connected to anything.
Where This Shows Up
Section titled “Where This Shows Up”- Camera follow, the most visible case, and the one players describe as the game feeling “floaty” or “stiff” without being able to say why.
- Turning towards a target, which is Section 3.3’s slerp with this Section’s blend factor.
The two combine directly: compute the factor from
dt, hand it to slerp. - Health bars, ammo counters and any UI that eases, where the bug is harmless but the fix costs nothing.
- Audio fades and volume ducking, which are exactly this maths with a different unit.
- Any tuning constant a designer touches, which should be a half-life rather than a rate, so a conversation about it can happen in seconds rather than in .