Why does my game run faster on a better computer?

Because `position += velocity * deltaTime` is not frame-rate independent. The same falling body ended 0.477 m apart at 60 fps versus 144 fps after 10 seconds, and 1.43 m apart at 30 versus 240. A fixed-timestep accumulator closed the gap to zero — and then failed in three ways nobody measures.

Why does my game run faster on a better computer?

Because position += velocity * deltaTime is not frame-rate independent, and nobody tells you. Ten seconds of plain falling, g = -9.81, on an Apple M3: the body ended at -491.3175 m at 60 fps and -490.8406 m at 144 fps — 0.477 m apart, and 1.43 m apart between 30 and 240 fps. The exact answer is -490.5. A fixed-timestep accumulator closed the gap to zero, and then introduced a one-step state divergence, 58% duplicate frames, and a failure mode that dropped 1.43 seconds of simulated time in four seconds of wall clock.

Hardware: Apple M3, 8 cores, 16 GB, macOS 26.4.1 (build 25E253), Node v23.5.0. Every number below comes from a script in this article. This is about the timestep; the separate question of whether two machines agree bit-for-bit on the same timestep is covered in deterministic simulation.

The short answer

  • deltaTime cancels the error in velocity, not in position. Under constant acceleration, a first-order integrator's position error is exactly 0.5 * g * T * dt — proportional to the timestep, so it never goes away by multiplying by dt. At T = 10 s that is 1.635 m at 30 fps and 0.204 m at 240 fps.
  • Semi-implicit Euler is not more accurate than explicit Euler under gravity. Both were wrong by exactly 0.8175 m at 60 fps — one short, one long. The difference only appears with a position-dependent force: on a spring (ω = 10 rad/s, dt = 1/60), explicit Euler's energy grew 1.379 × 10⁷ times in 10 seconds while semi-implicit stayed at 1.064x.
  • Velocity Verlet is exact for constant acceleration. All five frame rates agreed to within 2.4 × 10⁻¹² m — the gap between 60 and 144 fps was 0.000000 m. If your only force is gravity, the integrator, not the loop, is the fix.
  • The accumulator works, and its residual is one whole fixed step. Five of six schedules produced an identical -491.317500000; 144 fps ran 599 steps instead of 600 and landed 1.635 m away — one step of motion, not a rounding error.
  • The spiral of death has an exact trigger: when one fixed step costs more wall-clock time than it represents. At 19.73 ms per 16.67 ms step, steps demanded per frame went 10 → 14 → 18 → 23 → 35 → 93 and frame time reached 1,557 ms. It never recovered.

What is being measured

A single rigid body in one dimension, g = -9.81 m/s², ten seconds of simulated time, in three integrators:

// explicit (forward) Euler — the one in most tutorials
p += v * dt;  v += g * dt;
// semi-implicit (symplectic) Euler — what Unity's Rigidbody uses
v += g * dt;  p += v * dt;
// velocity Verlet
p += v * dt + 0.5 * g * dt * dt;  v += g * dt;

Frame schedules: 30, 60, 120, 144 and 240 fps, plus a seeded jittery one (267 frames, dt from 6.99 ms to 66.5 ms). The reference is the analytic parabola, cross-checked against a tiny fixed step: velocity Verlet at dt = 1e-6 over 10,000,000 steps landed on -490.500000014 m, 1.4 × 10⁻⁸ m from the closed form, in 16 ms.

Both Euler variants miss by the same amount in opposite directions; velocity Verlet is exact

How far apart do 60 and 144 fps end up?

Final y after 10 seconds. Exact answer: -490.500000 m.

Schedule Steps Explicit Euler Semi-implicit Euler Velocity Verlet
30 fps 300 -488.8650 (+1.635) -492.1350 (-1.635) -490.500000000
60 fps 600 -489.6825 (+0.8175) -491.3175 (-0.8175) -490.500000000
120 fps 1,200 -490.0912 (+0.4088) -490.9087 (-0.4088) -490.500000000
144 fps 1,440 -490.1594 (+0.3406) -490.8406 (-0.3406) -490.500000000
240 fps 2,400 -490.2956 (+0.2044) -490.7044 (-0.2044) -490.500000000
jittery 267 -488.2335 (+2.266) -492.7665 (-2.266) -490.500000000

Gap between frame rates, same integrator: 60 vs 144 fps = 0.476875 m, 30 vs 240 fps = 1.430625 m, 60 fps vs jittery = 1.448989 m. Velocity Verlet: 0.000000 m in all three comparisons.

That is a player falling from a ledge and landing in a different place because their monitor refreshes faster. Add a bounce — drop from 100 m, restitution 0.8 — and the five fixed frame rates spread from 45.02 m to 48.86 m against a reference of 46.8005 m, because a bounce turns a small timing error into a large position error. Explicit Euler on the jittery schedule ended at 53.12 m, 6.3 m off.

Is semi-implicit Euler actually better than explicit Euler?

This is the measurement that contradicted what I expected, so it gets its own section. Under constant gravity, the two integrators are equally wrong. Explicit Euler under-integrates by exactly 0.5·g·T·dt; semi-implicit over-integrates by exactly the same amount. Both rows in the table above are 0.8175 m off at 60 fps. Swapping the two lines does not buy accuracy here.

What it buys shows up the moment acceleration depends on position. Spring, m = 1, k = 100, released from x = 1, 10 seconds, energy ratio E(10)/E(0):

dt Explicit Euler Semi-implicit Euler Velocity Verlet
1/30 5.336 × 10¹³ 1.0212 0.99989
1/60 1.379 × 10⁷ 1.0640 0.99887
1/120 4.042 × 10³ 1.0360 0.99960
1/240 6.427 × 10¹ 1.0183 0.99989

At 60 fps explicit Euler's amplitude grew by a factor of 478 in ten seconds. That is the ragdoll that flies into orbit, and it is why the two-line swap is worth making even though it does nothing for a falling rock. Note that semi-implicit Euler does not conserve energy either — it bounds the error (1.06x) instead of letting it grow. Only Verlet stayed under 1.0.

Does an accumulator make every frame rate agree?

Mostly. The standard Gaffer-style loop, fixed step 1/60, semi-implicit inside:

acc += frameDt;
while (acc >= FIXED) { prev = cur; cur = step(cur, FIXED); acc -= FIXED; }
const alpha = acc / FIXED;           // for interpolation, below
Schedule Frames Fixed steps run Final y
30 fps 300 600 -491.317500000
60 fps 600 600 -491.317500000
120 fps 1,200 600 -491.317500000
144 fps 1,440 599 -489.682500000
240 fps 2,400 600 -491.317500000
jittery 267 599 -489.682500000

Five schedules produced bit-identical results. The residual is not a small float error: 144 fps ran one step fewer, because 1,440 additions of the float 1/144 left the accumulator at 1.667 × 10⁻² — a hair under the 1/60 threshold. That one missing step is worth 1.635 m, the full distance the body covers in one step at t = 10 s. The accumulator does not make frame rates agree on state; it makes them agree on state per simulated step, and they can still disagree about how many steps have elapsed at the instant you look. Interpolation is what hides that, which is the next section.

What does interpolation actually buy?

Rendering the last completed fixed state at a frame rate that is not a multiple of the step rate means some frames show no movement at all. Body moving at 10 m/s, sim at 1/60, measured over 10 seconds:

Render rate Frames showing zero movement Mean displayed-position error With interpolation
120 fps 50.0% 0.083333 m 0.0000695 m
144 fps 58.4% 0.081007 m 0.0000676 m
240 fps 75.0% 0.062509 m 0.0000521 m
jittery 4.9% 0.053618 m 6.5 × 10⁻¹⁵ m

At 144 fps the ideal per-frame movement is 10/144 = 0.0694 m, and the error without interpolation is 0.081 m — larger than the movement itself. Every frame is either a double-step or a freeze. Interpolating between the last two fixed states cuts the error 1,199x.

The price is stated less often: interpolation renders the world at (steps-1)·FIXED + acc, which is exactly one fixed step in the past — 16.67 ms of added latency. In exchange, the interpolated position tracked the exact parabola to a mean of 2.255 × 10⁻⁴ m and a worst case of 4.636 × 10⁻⁴ m at 144 fps. Smooth and 16.67 ms late, or jerky and current. Fighting games pick jerky.

What is the spiral of death?

Once a step costs more than the fixed timestep, the accumulator never catches up

The accumulator's own failure mode. If a frame takes longer than the fixed step, the accumulator asks for more steps next frame, which takes longer still. The trigger is exact and testable: a spiral occurs when one fixed step costs more wall-clock time than the simulated time it represents. With render cost R and step cost C, the loop settles at n = R / (FIXED - C) steps per frame — and that has no solution once C ≥ FIXED.

Measured with a real wall clock, 1,000 bodies, calibrated workloads, 4 ms of render cost, one injected 500 ms hitch at frame 30:

Load Step cost After the hitch Outcome
C = 8.04 ms (0.48x) under budget 30 → 15 → 8 → 4 → 2 → 1 steps recovered in 6 frames, sim/real 0.994
C = 19.73 ms (1.18x) over budget 10 → 14 → 18 → 23 → 35 → 93 steps never recovered; frame time 170 ms → 1,557 ms; 21 frames in 6 s
C = 19.73 ms, clamped to 5 steps/frame over budget pinned at 5 steps frame time bounded 109-146 ms

The healthy loop absorbed a 500 ms hitch in six frames. The overloaded one was already doomed before the hitch — the hitch just made it visible in one second instead of ten.

What the clamp costs, exactly. Clamping to 5 steps per frame kept frame time bounded, but over 4.02 seconds of wall clock the loop simulated only 2.217 seconds and discarded 1,434 ms of simulated time. Simulated time advanced at 0.551x real time. Nothing crashes, nothing warns: the world simply runs in slow motion and every clock in it is now wrong. In single player that is a stutter. In multiplayer it is a desync, because the server's clock did not slow down with yours.

What timestep should you pick?

Per-step cost is independent of dt — branch-free integration of 2,000 bodies took 2.474, 2.428, 2.483 and 2.426 µs at 1/30, 1/60, 1/120 and 1/240, a 1.023x spread. So halving the step doubles the CPU cost per second of simulated time, exactly.

Fixed step Steps/s CPU per simulated second Free-fall error Bouncing error
1/30 30 0.490 ms 1.635000 m 1.776008 m
1/60 60 1.017 ms 0.817500 m 0.072087 m
1/120 120 1.337 ms 0.408750 m 0.111600 m
1/240 240 1.770 ms 0.204375 m 0.131356 m

Free-fall error halves each time the step halves — first order, as advertised. The bouncing column does not: 1/60 beat both 1/120 and 1/240. Contact timing is luck, not convergence, and a smaller step is not reliably a more accurate one once collisions are involved. That is an argument for fixing contacts properly rather than paying for 240 Hz — see collision broadphase. 1/60 is a defensible default; 1/120 costs 31% more CPU and buys nothing for bounces.

When does a body fall through the floor?

Drop from 10 m onto a 0.5 m thick floor, overlap tested once per step, no sweep. Impact speed is 14 m/s, so the body travels 14·dt per step near the ground and misses the slab entirely once that exceeds 0.5 m — at dt = 0.0357 s. The first dt that actually tunnelled was 0.0391 s, a 25.6 fps frame, moving 0.5549 m per step.

dt fps Result
1/240, 1/144, 1/60, 1/30 240-30 ok
0.05 20 fell through
0.10 10 ok (landed inside the slab by luck)
0.25, 1.0 4, 1 fell through (19.62 m in one step at dt = 1)

Note that it is not monotonic: 0.1 s is safe and 0.05 s is not. Tunnelling depends on where the sample happens to land, so "it works at 20 fps" is evidence of nothing. A fixed timestep caps dt and removes this class of bug outright — which is the real argument for the accumulator, more than accuracy. A backgrounded browser tab or a debugger breakpoint hands you a dt of seconds; the same clamp that causes slow motion is what stops your player leaving the level.

Check it yourself

Node 18+, no dependencies. Reproduces the drift table, the spring result, the accumulator's 599-step residual and the tunnelling scan in under a second.

// timestep.js — node timestep.js
const G = -9.81, T = 10;
const exact = t => 0.5 * G * t * t;
const frames = fps => new Array(Math.round(T * fps)).fill(1 / fps);

function fall(kind, dts) {
  let p = 0, v = 0;
  for (const dt of dts) {
    if (kind === 'explicit') { p += v * dt; v += G * dt; }
    else if (kind === 'semi') { v += G * dt; p += v * dt; }
    else { p += v * dt + 0.5 * G * dt * dt; v += G * dt; }
  }
  return p;
}
console.log('exact y(10) = ' + exact(T).toFixed(6) + ' m\n');
console.log('fps   explicit Euler   semi-implicit   velocity Verlet');
const R = {};
for (const f of [30, 60, 120, 144, 240]) {
  R[f] = { e: fall('explicit', frames(f)), s: fall('semi', frames(f)), v: fall('verlet', frames(f)) };
  console.log(String(f).padEnd(6) + R[f].e.toFixed(4).padEnd(17) +
              R[f].s.toFixed(4).padEnd(16) + R[f].v.toFixed(9));
}
console.log('\n60 vs 144 fps gap:  explicit ' + Math.abs(R[60].e - R[144].e).toFixed(6) +
            ' m   semi-implicit ' + Math.abs(R[60].s - R[144].s).toFixed(6) +
            ' m   Verlet ' + Math.abs(R[60].v - R[144].v).toFixed(6) + ' m');

const spring = (kind, dt) => { let x = 1, v = 0;
  for (let i = 0; i < Math.round(T / dt); i++) { const a = -100 * x;
    if (kind === 'explicit') { x += v * dt; v += a * dt; }
    else if (kind === 'semi') { v += a * dt; x += v * dt; }
    else { x += v * dt + 0.5 * a * dt * dt; v += 0.5 * (a - 100 * x) * dt; }
  } return (0.5 * v * v + 50 * x * x) / 50; };
console.log('\nspring energy after 10 s (1.0 = conserved), dt = 1/60');
for (const k of ['explicit', 'semi', 'verlet'])
  console.log('  ' + k.padEnd(9) + spring(k, 1 / 60).toExponential(3));

function accum(dts, fixed = 1 / 60) {
  let acc = 0, p = 0, v = 0, steps = 0;
  for (const fdt of dts) { acc += fdt;
    while (acc >= fixed) { v += G * fixed; p += v * fixed; acc -= fixed; steps++; } }
  return { p, steps };
}
console.log('\naccumulator, fixed dt = 1/60:');
for (const f of [30, 60, 120, 144, 240]) { const a = accum(frames(f));
  console.log('  ' + String(f).padEnd(5) + 'fps -> ' + a.steps + ' steps, y = ' + a.p.toFixed(9)); }

const tunnels = dt => { let p = 10, v = 0, t = 0;
  while (t < 20) { v += G * dt; p += v * dt; t += dt;
    if (p <= 0 && p >= -0.5) return false;
    if (p < -0.5) return true; }
  return false; };
console.log('\ntunnelling through a 0.5 m floor:');
for (const dt of [1/240, 1/144, 1/60, 1/30, 0.05, 0.1, 0.25, 1.0])
  console.log('  dt=' + dt.toFixed(5) + ' (' + (1/dt).toFixed(1).padStart(5) +
              ' fps): ' + (tunnels(dt) ? 'FELL THROUGH' : 'ok'));

On the machine above:

$ node timestep.js
exact y(10) = -490.500000 m

fps   explicit Euler   semi-implicit   velocity Verlet
30    -488.8650        -492.1350       -490.500000000
60    -489.6825        -491.3175       -490.500000000
120   -490.0912        -490.9087       -490.500000000
144   -490.1594        -490.8406       -490.500000000
240   -490.2956        -490.7044       -490.500000000

60 vs 144 fps gap:  explicit 0.476875 m   semi-implicit 0.476875 m   Verlet 0.000000 m

spring energy after 10 s (1.0 = conserved), dt = 1/60
  explicit 1.379e+7
  semi     1.064e+0
  verlet   9.989e-1

accumulator, fixed dt = 1/60:
  30   fps -> 600 steps, y = -491.317500000
  60   fps -> 600 steps, y = -491.317500000
  120  fps -> 600 steps, y = -491.317500000
  144  fps -> 599 steps, y = -489.682500000
  240  fps -> 600 steps, y = -491.317500000

tunnelling through a 0.5 m floor:
  dt=0.00417 (240.0 fps): ok
  dt=0.00694 (144.0 fps): ok
  dt=0.01667 ( 60.0 fps): ok
  dt=0.03333 ( 30.0 fps): ok
  dt=0.05000 ( 20.0 fps): FELL THROUGH
  dt=0.10000 ( 10.0 fps): ok
  dt=0.25000 (  4.0 fps): FELL THROUGH
  dt=1.00000 (  1.0 fps): FELL THROUGH

What this does not cover

One body, one dimension, one machine, JavaScript float64. It does not cover constraint solvers, which have their own iteration-count-versus-timestep trade; continuous collision detection, which is the proper fix for tunnelling rather than a small dt; or multi-threaded loops where the render thread reads a state the sim thread is writing. It also assumes float determinism, which is a separate problem with its own numbers. If your fixed step is being blown out by AI rather than physics, the cost model is the same one measured in pathfinding for many units.

The Unity templates we build ship with the accumulator, the clamp, interpolated rendering and a logged warning when the clamp fires — because a game silently running at 0.551x speed is the bug nobody files.