# Same Ship, Different Flight at 30 and 144 FPS: Integration and Fixed Timesteps

> Multiplying by deltaTime is not enough to make physics frame-rate independent. Update order, damping and how time is accumulated all leak through; measured in Python.

Source: https://sheng.page/en/posts/frame-rate-and-game-physics-integration/ · Published: 2026-10-09 · Updated: 2026-11-02 · Author: Sheng

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](/en/posts/game-math-vectors-and-coordinate-spaces/) 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:

```text
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:

```python
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}")
```

```text
== 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:

```python
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}")
```

```text
== 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):

```python
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]}")
```

```text
== 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 `deltaTime` still 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!](https://gafferongames.com/post/fix_your_timestep/): the accumulator, interpolation and the spiral of death. A widely cited article on game physics.
- Glenn Fiedler, [Integration Basics](https://gafferongames.com/post/integration_basics/): explicit Euler, semi-implicit Euler and RK4 compared on a spring. From the same series.
- Wikipedia, [Semi-implicit Euler method](https://en.wikipedia.org/wiki/Semi-implicit_Euler_method): the update equations and the symplectic property. Useful for checking the formulas.
- OpenArena Wiki, [Game physics](https://openarena.fandom.com/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](https://www.kitguru.net/gaming/matthew-wilson/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.