Same Ship, Different Flight at 30 and 144 FPS: Integration and Fixed Timesteps
Give the same ship the same thrust and the same spring, run it at 30 FPS and at 144 FPS, and you can get noticeably different paths. A game cannot simulate continuous motion. It samples once per frame and approximates the next position with “this frame’s velocity times this frame’s duration”. Multiplying by deltaTime deals with the obvious part. The order of the updates, the way damping is written and the way time is added up can still let the frame rate leak into the result.
Correction (2 November 2026, 18:45 Taipei): the Dark Souls II durability bug is now described as fixed in a patch after the Scholar of the First Sin release (suggested by Claude and Codex (gpt-6-astra), each checked by the other); the passage on semi-implicit Euler now says that near-constant energy needs a small enough step (suggested by Codex (gpt-6-astra), checked by Claude).
The previous post dealt with where things are. This one is about how they move, so everything below keeps the parameters fixed and changes only the frame rate.
Which velocity does the position use?
Velocity is the rate of change of position, acceleration the rate of change of velocity. Physics textbooks state these as continuous relationships; a game gets one discrete update per frame:
v(t + dt) ≈ v(t) + a·dt
x(t + dt) ≈ x(t) + v·dt
The question is which v goes into the second line. Using the velocity from before the update is explicit Euler. Using the one you have just updated is semi-implicit, or symplectic, Euler. Both are first-order methods with an error proportional to dt; they differ in how they behave over long runs. Glenn Fiedler’s “Integration Basics” compares them on a spring: explicit Euler keeps adding energy until it blows up, while semi-implicit Euler roughly conserves energy on average, and he notes that most commercial game physics engines use it. The Wikipedia article describes it the same way, as a symplectic integrator that nearly conserves energy when the system does not change over time. That property needs a step that is fixed and small enough. For the k=40 spring below, dt·√k has to stay under 2 (dt below about 0.316 seconds); at 2 FPS semi-implicit Euler blows up as well. The 30, 60 and 144 FPS runs here are all inside that range.
Thrust, a spring and drag
Three small experiments, each with fixed parameters and only the frame rate changing. The drag case compares “multiply by 0.9 every frame” with a decay written in terms of time. All run on Python 3.13.16:
import math
import sys
THRUST = 10.0 # m/s^2, constant acceleration for 2 seconds
def thrust_run(fps, method):
dt, x, v = 1.0 / fps, 0.0, 0.0
for _ in range(round(2.0 * fps)):
if method == "explicit": # position uses the old velocity
x += v * dt
v += THRUST * dt
else: # semi-implicit: velocity first
v += THRUST * dt
x += v * dt
return x
print("== 1. constant thrust for 2 s, exact x = 0.5*a*t^2 = 20.000 m ==")
for fps in (30, 60, 144):
print(f"{fps:3d} fps explicit {thrust_run(fps, 'explicit'):7.3f} m"
f" semi-implicit {thrust_run(fps, 'semi'):7.3f} m")
def spring_run(fps, method, seconds=10.0, k=40.0):
"""Camera arm: undamped spring pulling x back to 0. Energy should stay 0.5*k*1^2."""
dt, x, v = 1.0 / fps, 1.0, 0.0
for _ in range(round(seconds * fps)):
a = -k * x
if method == "explicit":
x, v = x + v * dt, v + a * dt
else:
v += a * dt
x += v * dt
return 0.5 * v * v + 0.5 * k * x * x
print("\n== 2. undamped spring, k=40, 10 s, starting energy 20.000 ==")
for fps in (30, 60, 144):
print(f"{fps:3d} fps explicit energy {spring_run(fps, 'explicit'):12.3f}"
f" semi-implicit {spring_run(fps, 'semi'):8.3f}")
def damp_run(fps, rule, seconds=1.0):
dt, v = 1.0 / fps, 100.0
for _ in range(round(seconds * fps)):
if rule == "per-frame":
v *= 0.9 # "lose 10% each frame"
else:
v *= math.exp(-6.0 * dt) # rate written per second
return v
print("\n== 3. drag: speed left after 1 s, starting at 100 ==")
for fps in (30, 60, 144):
print(f"{fps:3d} fps v*=0.9 per frame {damp_run(fps, 'per-frame'):10.6f}"
f" v*=exp(-6*dt) {damp_run(fps, 'exp'):8.4f}")
== 1. constant thrust for 2 s, exact x = 0.5*a*t^2 = 20.000 m ==
30 fps explicit 19.667 m semi-implicit 20.333 m
60 fps explicit 19.833 m semi-implicit 20.167 m
144 fps explicit 19.931 m semi-implicit 20.069 m
== 2. undamped spring, k=40, 10 s, starting energy 20.000 ==
30 fps explicit energy 9260474.577 semi-implicit 18.263
60 fps explicit energy 15148.154 semi-implicit 19.203
144 fps explicit energy 320.805 semi-implicit 19.677
== 3. drag: speed left after 1 s, starting at 100 ==
30 fps v*=0.9 per frame 4.239116 v*=exp(-6*dt) 0.2479
60 fps v*=0.9 per frame 0.179701 v*=exp(-6*dt) 0.2479
144 fps v*=0.9 per frame 0.000026 v*=exp(-6*dt) 0.2479
Under constant acceleration both methods are slightly off, one short and one long, and both get closer to 20 m as the frame rate rises. Nothing here picks a winner.
The spring does. Camera follow is often a spring pulling the camera back towards its target; with damping removed you can watch the energy directly. Explicit Euler at 30 FPS takes it from 20 to more than nine million in ten seconds, so the camera has long since left the screen, and even at 144 FPS it ends up sixteen times too high. Semi-implicit Euler stays between 18 and 20 at all three rates and gets closer to 20 as the frame rate rises. Use semi-implicit Euler for position and velocity updates, and if a follow camera jitters or overshoots at low frame rates, the integrator is a sensible first suspect.
Drag is where most code goes wrong. “Lose 10% of the speed each frame” sounds reasonable, but it ties the decay to the number of frames: after one second there is 4.2 left at 30 FPS and 0.000026 at 144 FPS. Written as v *= exp(-k·dt), the decay depends only on elapsed time and all three rates agree. That expression is the exact solution of dv/dt = -k·v, which is why it holds for any dt.
Fixed timestep and the accumulator
If the result depends on dt, the obvious fix is to always use the same dt. Fiedler’s “Fix Your Timestep!” does this with an accumulator. Each rendered frame adds its real elapsed time; the physics consumes it in fixed slices and leaves any remainder for the next frame. The remainder divided by the step gives alpha, which the renderer uses to blend between the previous and current physics states so that low frame rates still look smooth.
He also describes the failure mode he calls the spiral of death: if a physics step takes longer in real time than the time it simulates, the accumulator grows, each frame has to catch up with more steps, and things get slower still. Real implementations cap the number of catch-up steps per frame.
Continuing in the same file, the physics runs at a fixed 120 Hz while rendering runs at 30, 60 and 144 FPS:
def fixed_step_run(frame_times, step=1.0 / 120):
"""Accumulator: the renderer produces time, physics consumes it in fixed steps."""
acc, x, v, steps = step * 0, 0.0, 0.0, 0 # keep acc in the same type as step
for ft in frame_times:
acc += ft
while acc >= step:
v += THRUST * step
x += v * step
acc -= step
steps += 1
alpha = acc / step # blend factor for rendering
return x, steps, alpha
print("\n== 4. fixed 120 Hz physics under different render rates (2 s of frames) ==")
for fps in (30, 60, 144):
x, steps, alpha = fixed_step_run([1.0 / fps] * round(2.0 * fps))
print(f"render {fps:3d} fps -> physics steps {steps}, x = {x:.6f} m, alpha {alpha:.3f}")
from fractions import Fraction
print("same loop, time kept as exact fractions instead of float seconds:")
for fps in (30, 60, 144):
frames = [Fraction(1, fps)] * (2 * fps)
x, steps, alpha = fixed_step_run(frames, step=Fraction(1, 120))
print(f"render {fps:3d} fps -> physics steps {steps}, x = {float(x):.6f} m, alpha {float(alpha):.3f}")
== 4. fixed 120 Hz physics under different render rates (2 s of frames) ==
render 30 fps -> physics steps 240, x = 20.083333 m, alpha 0.000
render 60 fps -> physics steps 240, x = 20.083333 m, alpha 0.000
render 144 fps -> physics steps 239, x = 19.916667 m, alpha 1.000
same loop, time kept as exact fractions instead of float seconds:
render 30 fps -> physics steps 240, x = 20.083333 m, alpha 0.000
render 60 fps -> physics steps 240, x = 20.083333 m, alpha 0.000
render 144 fps -> physics steps 240, x = 20.083333 m, alpha 0.000
At 30 and 60 FPS the physics takes 240 steps and lands on exactly the same result, which is the whole point. At 144 FPS it takes one fewer, and alpha reads 1.000: the accumulator is a hair short of a full step. None of 1/30, 1/60 or 1/144 can be represented exactly in binary. At 30 and 60 the rounding happens not to cross the boundary; at 144 it does.
With exact fractions all three rates are back to 240 steps. Nobody ships Fraction, but the same idea applies: keep the accumulator in integer time, such as nanoseconds or fixed ticks, which is what the operating system’s clock gives you anyway. A fixed step makes every dt identical, and the number of steps still depends on how you add time up.
Fast objects and thin walls
Sampling once per frame also means missing whatever happens in between. A bullet at 600 m/s flies at a wall 1 m thick, and the only check is whether the bullet’s position on a given frame falls inside the wall (again continuing the same file):
def tunnel(fps, speed=600.0, wall_at=50.0, thickness=1.0, swept=False):
"""A bullet flies along +x. Does any sample land inside the wall?"""
dt, x = 1.0 / fps, 0.0
while x < 200:
nx = x + speed * dt
if swept:
if x <= wall_at + thickness and nx >= wall_at:
return "hit"
elif wall_at <= nx <= wall_at + thickness:
return "hit"
x = nx
return "missed"
print("\n== 5. 600 m/s bullet vs 1 m wall at x=50 ==")
for fps in (30, 60, 144, 600):
print(f"{fps:3d} fps step {600/fps:6.2f} m point test: {tunnel(fps):6}"
f" swept test: {tunnel(fps, swept=True)}")
print(f"\nPython {sys.version.split()[0]}")
== 5. 600 m/s bullet vs 1 m wall at x=50 ==
30 fps step 20.00 m point test: missed swept test: hit
60 fps step 10.00 m point test: hit swept test: hit
144 fps step 4.17 m point test: missed swept test: hit
600 fps step 1.00 m point test: hit swept test: hit
Python 3.13.16
The 144 FPS run samples more often than the 60 FPS one and still goes through. Each step is 4.1667 m, so in theory the twelfth sample lands exactly on 50; floating-point error leaves it at 49.999…, just short of the wall, and the next sample is already beyond it. Whether a point test hits depends on where the samples happen to fall, and hitting does not get steadily more reliable as the frame rate rises. Checking the segment covered by each step instead (a swept test, often called continuous collision detection in engines) hits at every rate. Fast objects need swept checks or raycasts; raising the frame rate does not guarantee a hit.
Jump heights and weapon durability
Frame-rate-dependent physics has shown up in shipped games. The OpenArena wiki explains that its engine inherited frame-rate-dependent physics from Quake III, with PMove() called every time a client renders a frame; with the standard gravity of 800 you can jump slightly higher at 125 FPS than at 90 FPS. Enabling pmove_fixed switches to fixed movement steps.
On PC, Dark Souls II could run at 60 FPS, and weapons wore out faster than at 30 FPS. According to KitGuru on 9 May 2015, the bug stayed in the game for more than a year and was finally fixed in a patch after the Scholar of the First Sin release. The report gives no technical cause, and I won’t guess at one.
Cross-platform determinism is a separate job
A fixed timestep makes results on one machine independent of the rendering rate. Getting bit-identical results across platforms and compilers is a separate problem. Floating-point results can depend on instruction selection, evaluation order, whether fused multiply-add is used and the maths library. Multiplayer games built on lockstep, where only inputs are exchanged and every client simulates, typically use fixed-point arithmetic or tightly control compiler settings and runtime environments. That is beyond this post, and the specifics depend on the engine and platform documentation (to be verified).
Notes
- Multiplying by
deltaTimestill leaves update order, damping and time accumulation able to change the result. - Semi-implicit Euler updates velocity first and then position with the new velocity; on a spring, with a small enough step, its energy stays roughly constant, while explicit Euler keeps gaining energy.
- Write drag as
exp(-k·dt), so that it depends on elapsed time rather than on how many frames passed. - A fixed timestep with an accumulator decouples physics from rendering, with the remainder used for interpolation. Keep the accumulator in integer time; summing float seconds can gain or lose a step.
- Fast objects need swept checks or raycasts, and cross-platform determinism needs separate work on floating-point details.
Further reading
- Glenn Fiedler, Fix Your Timestep!: the accumulator, interpolation and the spiral of death. A widely cited article on game physics.
- Glenn Fiedler, Integration Basics: explicit Euler, semi-implicit Euler and RK4 compared on a spring. From the same series.
- Wikipedia, Semi-implicit Euler method: the update equations and the symplectic property. Useful for checking the formulas.
- OpenArena Wiki, Game physics: frame-rate-dependent physics in Quake III-derived engines and
pmove_fixed. Community-maintained documentation. - KitGuru, That Dark Souls 2 weapon durability bug has finally been fixed: 9 May 2015. A news report covering the symptom and when it was fixed, not the cause.
Ideas and technical judgement by Sheng; drafted with Claude · examples run on Python 3.13.16.