Skip to content

Delta Time and Frame-Rate Independence

A frame is not a unit of time. It is however long the machine happened to take — a thirtieth of a second on a tired tablet, a hundred and forty-fourth on a gaming monitor. So anything you update per frame does a different amount of work per second depending on the hardware.

Two fixes, for two shapes of update. Anything that accumulates gets multiplied by the elapsed time, which is easy and which most people get right. Anything that converges toward a target needs exponential decay, which most people get wrong for years — because the wrong version looks perfect on the machine it was written on.

This Section comes before anything that moves, on purpose. Everything in Parts 4, 5 and 6 runs in an update loop, and this is the Section that makes that loop behave the same for everybody.

p′=p+v Δtp' = p + v\,\Delta t

With velocity in units per second, this is frame-rate independent for free, and the units say so: units per second times seconds is units. That dimensional check is the fastest way to audit an update loop — anything added to a position without a dt beside it is suspect.

Leave the dt out and the distance travelled becomes the frame count. A second at 30 fps covers 30, and at 144 fps covers 144 — the same code moving 4.8 times faster on the better screen. That much is obvious once seen, which is why it is not the interesting half.

Here is the line, and some version of it is in nearly every project:

current = lerp(current, target, 0.1); // every frame

Smooth. Responsive. No dt anywhere, which is exactly what makes it feel safe — there is no time in it, so how could it depend on time?

The fraction is per frame, so it is per unknown amount of time. After nn frames the remaining gap is (1−f)n(1 - f)^n, and that exponent is a frame count. One second of it:

Frame rateGap still remaining
24 fps7.98%7.98\%
30 fps4.24%4.24\%
60 fps0.18%0.18\%
144 fps0.000026%0.000026\%

The 144 Hz screen ends up over 160,000 times closer to the target in the same wall-clock second, on identical code. And notice the direction: it converges faster on better hardware, so it looks tighter and more responsive on the developer’s machine and floaty on a cheap laptop. A bug that improves under the conditions you test in is a bug that ships.

The same follower at 30 fps and at 144 fps, chasing one step
Compare the two curves, then tick the box and compare them again.
The code that draws it src/lib/gamedev/demos/2d/follow.scene.ts
/** Two followers of the same target, one at 30 fps and one at 144, plotted against time. */
import { makeCanvas2D, dot as fillDot, label, line } from "../canvas2d.ts";
// From `controls.ts`, not `ui.ts`: the latter imports Three.js and this track must not.
import { addCheckbox, addReadout, addSlider } from "../controls.ts";
import {
  DURATION,
  RANGE,
  STEP_AT,
  timeToClose,
  traces,
} from "./follow-shared.ts";
import type { MountFn } from "../runner.ts";

const SLOW = "#ff7b72";
const FAST = "#58a6ff";
const TARGET = "#7d8590";
const AGREE = "#7ee787";
const GRID = "#252b33";
const TEXT = "#9198a1";

const mount: MountFn = (el) => {
  const { ctx, width, height, clear } = makeCanvas2D(el, 320);

  const show = addReadout(el);
  const note = addReadout(el);
  const factor = addSlider(
    el,
    "per-frame fraction",
    RANGE.factor.min,
    RANGE.factor.max,
    0.1,
    draw,
    "",
    0.01,
  );
  const halfLife = addSlider(
    el,
    "half-life",
    RANGE.halfLife.min,
    RANGE.halfLife.max,
    0.12,
    draw,
    " s",
    0.01,
  );
  const useDecay = addCheckbox(
    el,
    "use exponential decay (uncheck for the per-frame lerp)",
    false,
    draw,
  );

  function params() {
    return {
      factor: factor(),
      halfLife: halfLife(),
      useDecay: useDecay(),
    };
  }

  function draw() {
    clear();
    const p = params();
    // A plot: time across, value up. Margins leave room for the axis labels.
    const left = 46;
    const right = width - 14;
    const top = 26;
    const bottom = height - 34;
    const at = (t: number, value: number) => ({
      x: left + (t / DURATION) * (right - left),
      // Value 0 sits at the bottom of the plot and 1 near the top. Y up, drawn downward.
      y: bottom - value * (bottom - top),
    });

    // The frame of the plot, and the two values worth naming.
    line(ctx, { x: left, y: bottom }, { x: right, y: bottom }, GRID, {
      width: 1,
    });
    line(ctx, { x: left, y: top }, { x: left, y: bottom }, GRID, { width: 1 });
    label(ctx, "1", left - 10, top + 4, TEXT, "right");
    label(ctx, "0", left - 10, bottom + 4, TEXT, "right");
    label(ctx, "time \u2192", right - 40, bottom + 20, TEXT);

    // The target: flat, then a step. Drawn as the thing both followers are chasing.
    const stepX = at(STEP_AT, 0).x;
    line(ctx, at(0, 0), { x: stepX, y: at(0, 0).y }, TARGET, {
      dashed: true,
      width: 1.5,
    });
    line(
      ctx,
      { x: stepX, y: at(0, 0).y },
      { x: stepX, y: at(0, 1).y },
      TARGET,
      { dashed: true, width: 1.5 },
    );
    line(ctx, { x: stepX, y: at(0, 1).y }, at(DURATION, 1), TARGET, {
      dashed: true,
      width: 1.5,
    });
    label(ctx, "target", stepX + 6, at(0, 1).y - 6, TARGET);

    /* The discriminator is how long each takes to arrive, not whether the curves overlap pixel for
       pixel. A step target guarantees a one-frame transient at the jump - see follow-shared.ts - so
       "do they agree instant by instant" is the wrong question and this is the right one. */
    const slowClose = timeToClose(p, 30);
    const fastClose = timeToClose(p, 144);
    const together =
      slowClose !== null &&
      fastClose !== null &&
      Math.abs(slowClose - fastClose) <= 1 / 30 + 1e-9;

    // Each follower's curve. Same code, same parameter, different number of calls per second.
    traces(p).forEach(({ fps, points }, index) => {
      const colour = together ? AGREE : index === 0 ? SLOW : FAST;
      ctx.save();
      ctx.strokeStyle = colour;
      ctx.lineWidth = 2;
      ctx.beginPath();
      points.forEach((point, i) => {
        const q = at(point.t, point.value);
        if (i === 0) ctx.moveTo(q.x, q.y);
        else ctx.lineTo(q.x, q.y);
      });
      ctx.stroke();
      ctx.restore();

      const last = points[points.length - 1];
      const end = at(last.t, last.value);
      fillDot(ctx, end.x, end.y, 3.5, colour);
      label(
        ctx,
        `${fps} fps`,
        end.x - 8,
        end.y + (index === 0 ? 16 : -8),
        colour,
        "right",
      );
    });

    const asTime = (seconds: number | null) =>
      seconds === null ? `over ${DURATION} s` : `${seconds.toFixed(3)} s`;
    show(
      `${p.useDecay ? `exponential decay, half-life ${p.halfLife.toFixed(2)} s` : `lerp(current, target, ${p.factor.toFixed(2)}) once per frame`} \u00B7 ` +
        `95% of the way in ${asTime(slowClose)} at 30 fps, ${asTime(fastClose)} at 144 fps`,
    );
    note(
      together
        ? "the two arrive within one 30 fps frame of each other \u2014 the frame rate has stopped mattering"
        : slowClose === null || fastClose === null
          ? "the slow screen does not even arrive within the run, while the fast one is long finished \u2014 " +
            "the same code, the same number"
          : `the faster screen arrives ${(slowClose / fastClose).toFixed(1)}\u00D7 sooner, which is the ratio of the ` +
            "two frame rates \u2014 the same code, the same number",
    );
  }

  draw();

  return () => {};
};

export default mount;

Both followers run the same code with the same number. The only difference is how often it is called. Watch the two curves: the 30 fps follower needs 0.9330.933 s to close 95% of the gap and the 144 fps one needs 0.1940.194 s — and that ratio is exactly 4.8, the ratio of the frame rates, because the lerp takes the same number of frames whatever a frame is worth.

Now tick the box.

Put the time back in the exponent:

p′=target+(p−target) e−λΔtp' = \text{target} + (p - \text{target})\,e^{-\lambda \Delta t}

Now a hundred small steps and one big step covering the same span give the same answer. That property is frame-rate independence, and it holds for a specific reason:

e−λt1 e−λt2=e−λ(t1+t2)e^{-\lambda t_1}\,e^{-\lambda t_2} = e^{-\lambda(t_1 + t_2)}

The exponential composes with itself. That is why this particular curve is the right one rather than a convenient one — no other shape of decay has it. Cutting a second into 24, 30, 60, 144, 240 or 1000 steps all leave 0.9843%0.9843\% of the gap, identically, and the build sweeps exactly that. It also checks ten deliberately uneven steps against one step of their total, because real frames are never uniform.

You do not have to throw away an afternoon of someone’s tuning. A factor ff at a known frame rate converts to a rate exactly:

λ=− fps ln⁡(1−f)\lambda = -\,\text{fps}\,\ln(1 - f)

So a factor of 0.150.15 that felt right at 60 fps becomes a rate of 9.7519.751, or a half-life of 0.0710.071 s. It is exact at 60 fps — the build asserts one frame of each lands in the same place — and now behaves identically everywhere else too.

What a second of smoothing leaves behind, at two frame rates
The code src/lib/gamedev/demos/2d/timestep.ts
/** What a second of smoothing leaves behind, at two frame rates, done the wrong way and the right way. */
import {
  decayAfterOneSecond,
  halfLifeFromRate,
  lerpAfterOneSecond,
  rateFromHalfLife,
  rateFromLerpFactor,
  smooth,
} from "../../../gamedev2d/time2d.ts";
import type { Demo } from "../runner.ts";

const pct = (x: number) => `${(x * 100).toFixed(4)}%`;

/** Thousands separators, done by hand. `toLocaleString` depends on the ICU build, and this output is
 *  committed to the repository, so it has to be identical everywhere. */
const grouped = (n: number) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");

const demo: Demo = (log) => {
  log(
    "lerp(current, target, 0.1) every frame, for one second at 30 fps",
    pct(lerpAfterOneSecond(0.1, 30)),
    "of the gap is still there",
  );
  log(
    "the same code, one second at 144 fps",
    pct(lerpAfterOneSecond(0.1, 144)),
    // Computed, not typed: a ratio written by hand here would be wrong within one edit.
    `the better screen ends up ${grouped(
      Math.round(lerpAfterOneSecond(0.1, 30) / lerpAfterOneSecond(0.1, 144)),
    )} times closer, on identical code`,
  );

  // The fix. The frame rate argument is passed and makes no difference, which is the point.
  const rate = rateFromHalfLife(0.15);
  log(
    "exponential decay with a 0.15 s half-life, one second at 30 fps",
    pct(decayAfterOneSecond(rate, 30)),
    `rate ${rate.toFixed(3)} per second`,
  );
  log(
    "the same, one second at 144 fps",
    pct(decayAfterOneSecond(rate, 144)),
    "the same answer, because the exponent is time rather than a frame count",
  );

  // Half-life is exact by construction, which is what makes it a good thing to expose to a designer.
  log(
    "after exactly one half-life, the gap left is",
    smooth(0, 1, 0.15, 0.15).toFixed(6),
    "halfway, to the last decimal, whatever the step size was",
  );
  log(
    "and 0.15 s as a decay rate, converted back and forth",
    `${rate.toFixed(4)} then ${halfLifeFromRate(rate).toFixed(4)} s`,
    "the two parameterisations are the same curve",
  );

  // Porting existing code that somebody already tuned by feel.
  log(
    "a factor of 0.15 tuned at 60 fps, as a frame-rate independent rate",
    `${rateFromLerpFactor(0.15, 60).toFixed(3)} per second, a ${halfLifeFromRate(rateFromLerpFactor(0.15, 60)).toFixed(3)} s half-life`,
    "same feel at 60 fps, and now the same feel everywhere else too",
  );
};

export default demo;
lerp(current, target, 0.1) every frame, for one second at 30 fps → 4.2391% // of the gap is still there
the same code, one second at 144 fps → 0.0000% // the better screen ends up 164,571 times closer, on identical code
exponential decay with a 0.15 s half-life, one second at 30 fps → 0.9843% // rate 4.621 per second
the same, one second at 144 fps → 0.9843% // the same answer, because the exponent is time rather than a frame count
after exactly one half-life, the gap left is → 0.500000 // halfway, to the last decimal, whatever the step size was
and 0.15 s as a decay rate, converted back and forth → 4.6210 then 0.1500 s // the two parameterisations are the same curve
a factor of 0.15 tuned at 60 fps, as a frame-rate independent rate → 9.751 per second, a 0.071 s half-life // same feel at 60 fps, and now the same feel everywhere else too

Two honest caveats, both discovered by the checks rather than reasoned out in advance.

A step target leaves a one-frame transient, and that is not a bug. Both followers observe the step at the same instant, but then integrate the new target over frames of different lengths — 33 ms against 7 ms — so the slow one has closed more of the gap by the time its frame ends. It is bounded by exactly one frame of catch-up, and then it decays away at the follower’s own half-life: each sixth of a second shrinks it by precisely 2−1/6h2^{-1/6h}, which is asserted as an identity rather than as “the gap is small”.

A slow frame rate still cannot draw a fast curve. With a half-life shorter than a frame, the 30 fps follower closes most of the gap in one step and its curve looks coarse next to the smooth one. Its arrival time is still correct to within one frame. That is sampling, not frame-rate dependence, and it is why the scene’s half-life slider starts above two frames at 30 fps.

source Delta time, the lerp that fails, and the decay that does not src/lib/gamedev2d/time2d.ts 178 lines
/**
 * Time, and why almost every first attempt at smooth movement is secretly tied to a frame rate.
 *
 * A frame is not a unit of time. It is however long the machine happened to take, which on one screen
 * is $1/30$ of a second and on another $1/144$. So any update written **per frame** does a different
 * amount of work per second depending on the hardware, and a game tuned on one monitor behaves
 * differently on another - faster, not just smoother.
 *
 * Two fixes, for two different shapes of update. Anything that accumulates gets multiplied by the
 * elapsed time. Anything that **converges** toward a target needs exponential decay, which is the part
 * people get wrong for years, because the wrong version looks completely fine on the machine it was
 * written on.
 */

/** Seconds per frame at a given rate. Named because `1 / 144` in the middle of a formula reads as noise. */
export function secondsPerFrame(fps: number): number {
  return 1 / fps;
}

/**
 * Move at a velocity, correctly: multiply by the time that actually passed.
 *
 * $$p' = p + v\,\Delta t$$
 *
 * With `velocity` in units **per second**, this is frame-rate independent for free, and the units tell
 * you so: units per second times seconds is units. That dimensional check is the quickest way to audit
 * an update loop - anything added to a position without a `dt` beside it is suspect.
 */
export function step(position: number, velocity: number, dt: number): number {
  return position + velocity * dt;
}

/** The same thing with the `dt` left out, which is the bug. Kept so the build can price it. */
export function stepWithoutDt(position: number, velocity: number): number {
  return position + velocity;
}

/** Straight-line interpolation. `t` of 0 gives `a`, 1 gives `b`, and it is not clamped. */
export function lerp(a: number, b: number, t: number): number {
  return a + (b - a) * t;
}

/**
 * The seductive one-liner: move a fraction of the remaining distance, every frame.
 *
 * ```js
 * current = lerp(current, target, 0.1);
 * ```
 *
 * It looks frame-rate independent because there is no time in it, and that is exactly the problem.
 * **The fraction is per frame, so it is per unknown amount of time.** A 144 Hz screen applies it 144
 * times a second and a 30 Hz screen 30 times, so the same code converges at wildly different speeds -
 * and it converges *faster* on better hardware, which is why it survives testing on a fast machine.
 */
export function lerpPerFrame(
  current: number,
  target: number,
  factor: number,
): number {
  return current + (target - current) * factor;
}

/**
 * How much of the gap is still left after a number of frames of per-frame lerp.
 *
 * $$\text{remaining} = (1 - f)^{n}$$
 *
 * Which is the whole problem in one expression: the exponent is a **frame count**, so the answer
 * depends on the machine rather than on the clock.
 */
export function remainingAfterFrames(factor: number, frames: number): number {
  return Math.pow(1 - factor, frames);
}

/**
 * The fix: exponential decay, where the exponent is **time** rather than frames.
 *
 * $$p' = \text{target} + (p - \text{target})\,e^{-\lambda \Delta t}$$
 *
 * Now the elapsed time is in the formula, so a hundred small steps and one big step covering the same
 * span give the same answer. That property is what frame-rate independence *means*, and it holds
 * because $e^{-\lambda t_1}e^{-\lambda t_2} = e^{-\lambda(t_1 + t_2)}$ - the exponential composes with
 * itself, which is the reason this particular curve is the right one rather than a convenient one.
 */
export function decay(
  current: number,
  target: number,
  rate: number,
  dt: number,
): number {
  return target + (current - target) * Math.exp(-rate * dt);
}

/** How much of the gap survives a given number of seconds of decay. No frame count anywhere. */
export function remainingAfterSeconds(rate: number, seconds: number): number {
  return Math.exp(-rate * seconds);
}

/**
 * The same decay, specified as a **half-life**: the time for half the gap to close.
 *
 * $$p' = \text{target} + (p - \text{target})\,2^{-\Delta t / h}$$
 *
 * This is the form worth exposing to whoever is tuning the feel, because the parameter is a duration
 * with an obvious meaning. "Catch up halfway in a tenth of a second" is a sentence a designer can hold
 * in their head; "lambda equals 6.93" is not. The two are the same curve.
 */
export function smooth(
  current: number,
  target: number,
  halfLife: number,
  dt: number,
): number {
  return target + (current - target) * Math.pow(2, -dt / halfLife);
}

/** Half-life to decay rate. $\lambda = \ln 2 / h$. */
export function rateFromHalfLife(halfLife: number): number {
  return Math.LN2 / halfLife;
}

/** And back, so either parameterisation can be handed to the other. */
export function halfLifeFromRate(rate: number): number {
  return Math.LN2 / rate;
}

/**
 * The decay rate that matches an existing per-frame lerp factor at one specific frame rate.
 *
 * $$\lambda = -\,\text{fps}\,\ln(1 - f)$$
 *
 * For porting code that is already tuned. Someone spent an afternoon settling on `0.15` at 60 fps and
 * likes how it feels; this reproduces that feel while making it independent of the frame rate. The
 * conversion is exact at that rate and the behaviour is now identical at every other one.
 */
export function rateFromLerpFactor(factor: number, fps: number): number {
  return -fps * Math.log(1 - factor);
}

/**
 * Run a whole second of per-frame lerp at a given frame rate, and report what is left.
 *
 * Here so the difference between two frame rates is a number the build can assert rather than a claim
 * in a caption.
 */
export function lerpAfterOneSecond(factor: number, fps: number): number {
  let remaining = 1;
  for (let i = 0; i < fps; i += 1) remaining *= 1 - factor;
  return remaining;
}

/**
 * Run a whole second of decay in `fps` steps, and report what is left.
 *
 * The point of this function is that the frame rate argument makes **no difference** to the result,
 * which is the opposite of the one above and is the assertion the Section rests on.
 */
export function decayAfterOneSecond(rate: number, fps: number): number {
  let remaining = 1;
  const dt = secondsPerFrame(fps);
  for (let i = 0; i < fps; i += 1) remaining *= Math.exp(-rate * dt);
  return remaining;
}

/**
 * A frame time clamped to something a simulation can survive.
 *
 * A tab left in the background, a garbage collection pause or a breakpoint can produce a `dt` of
 * several seconds, and every formula here will faithfully apply all of it - teleporting a character
 * through a wall. Clamping loses real time on purpose, which is the right trade for one bad frame.
 *
 * **Never skip the frame instead.** Returning early on a large `dt` makes the simulation drift out of
 * step with the clock permanently, and it is not necessary: decay handles any span correctly, so a
 * clamped step is still a well-behaved one.
 */
export function clampDt(dt: number, maximum = 0.1): number {
  return Math.min(Math.max(dt, 0), maximum);
}
  • Every camera that follows anything. Section 3.3’s camera becomes a follower here.
  • Any value that eases toward another: health bars, aim assist, turret tracking, UI panels.
  • Section 4.2, which is this idea with easing curves on top.
  • Section 4.3, where a sprite travels a path at a speed rather than a fraction per frame.
  • Part 5’s collision response, which moves things by a velocity and needs the dt to be right.
  • Section 6.1 and the capstone, where a fixed timestep gives physics a dt that never varies.