Skip to content

Delta Time and Frame Rate Independence

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.

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:

frames to close 90%=ln(0.1)ln(10.1)21.9\text{frames to close } 90\% = \frac{\ln(0.1)}{\ln(1 - 0.1)} \approx 21.9

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.

Settling time against frame rate, both ways
The code 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;
frames for lerp(..., 0.1) to close 90% 21.9
so at 30 fps that takes 0.73 s
and at 144 fps 0.15 s // same code, 4.8x quicker on better hardware
decay, half-life 0.25 s, left after 1 s at 30 fps 0.062500
the same at 144 fps 0.062500 // identical, as it has to be
and in one single enormous frame 0.062500 // closed form 0.5^(1/0.25) = 0.0625

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.

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 Δ\Delta is used the same way in maths, so Δt\Delta t 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 ratedt
30 fps0.03330.0333 s
60 fps0.01670.0167 s
120 fps0.00830.0083 s
144 fps0.00690.0069 s
a bad frame0.250.25 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.

The standard first attempt is to reach for dt:

current = lerp(current, target, 0.1 * dt); // still not right

This is better, and it is still wrong. The reason is that 0.10.1 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.

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 tt is an exponential:

remaining(t)=ekt\text{remaining}(t) = e^{-k t}

kk is the decay rate: one number saying how aggressively the gap closes. Bigger kk 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 kk 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 - e2 secondse^{\text{2 seconds}} is meaningless - so if tt is in seconds then kk must be in 1/seconds1/\text{seconds} for the product ktk t 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 kk:

  • After 1/k1/k seconds, the amount left is e10.368e^{-1} \approx 0.368, so 63% of the gap has closed. That duration 1/k1/k is called the time constant.
  • k=0k = 0 means the object never moves at all. Large kk means it snaps to the target almost immediately. Nothing in between is special.

The trouble is the direction of the relationship. kk 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:

kkTime constant 1/k1/kHalf-lifeFeels like
13.913.90.070.07 s0.050.05 ssnappy
4.624.620.220.22 s0.150.15 sa weighted camera
1.391.390.720.72 s0.500.50 ssluggish

Which is exactly why the section after next hands you a different knob for the same dial.

Whatever kk you pick, the fraction to close in a frame of length dtdt is whatever is not left:

tblend=1ekdtt_{\text{blend}} = 1 - e^{-k \cdot dt}

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 ekdte^{-k \cdot dt}. Two steps multiply, and multiplying exponentials adds the exponents:

ekdt1ekdt2=ek(dt1+dt2)e^{-k \cdot dt_1} \cdot e^{-k \cdot dt_2} = e^{-k(dt_1 + dt_2)}

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: 0.06250.0625 left after one second, identically at 30 fps, 144 fps, and in a single step.

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.

The same chase at two frame rates, done twice
The code that draws it 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 8×10158 \times 10^{-15}, which is floating point noise rather than disagreement.

source The fix, and the small functions it is built from src/lib/gamedev/interpolation.ts 108 lines
/**
 * 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);
}

There is still a kk in there, and as the table above showed, kk is a poor thing to hand somebody. “Set the camera rate to 4.624.62 per second” is not a sentence anybody can act on - you have to compute 1/k1/k or ln2/k\ln 2 / k before it means a duration.

So do not expose it. Keep kk as the thing the maths uses, and expose the half-life instead: the time to close half the remaining distance.

k=ln2hk = \frac{\ln 2}{h}

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 elapsedHalf-livesGap remaining
hh11/21/2
2h2h21/41/4
3h3h31/81/8
4h4h41/161/16

With a half-life of 0.250.25 s, one second is four half-lives, so a sixteenth of the gap is left - the 0.06250.0625 from the value list, which is not a coincidence but the closed form.

Rough guide for picking one: around 0.050.05 s feels snappy and responsive, 0.150.15 s reads as a weighted camera, and past 0.50.5 s starts to feel like the controls are not connected to anything.

  • 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 kk.