Fixed-Timestep Loops in Love2D: When to Replace love.run()
Love2D's default variable timestep can make Box2D physics jitter. Learn when and how to replace love.run() with a fixed-timestep accumulator loop.
13 Feb 2026, 12:02 UTC

The physics jitter you cannot tune away
You are building a Love2D 11.x game that uses love.physics, the Box2D wrapper. The character controller feels solid at 60 frames per second, but when the display refreshes at 30 Hz or 144 Hz, contacts jitter and fast-moving objects sometimes tunnel through walls. Changing the physics scale or mass rarely fixes it.
The cause is usually the default game loop. Love2D's built-in love.run() passes a variable delta time—dt, the seconds elapsed since the last frame—to love.update(dt). Box2D expects consistent steps for stable joints and contacts, so variable dt introduces instability at low or uneven frame rates.
If your game is physics-heavy, replacing love.run() with a fixed-timestep accumulator loop is a practical engineering decision. It adds state management, but it removes frame-rate-dependent physics jitter.
What Love2D's default loop does
When you do not define love.run(), Love2D uses its own implementation. Each iteration calls love.timer.step() to calculate dt, pumps OS events with love.event.pump(), then runs love.update(dt) and love.draw(). Because dt changes every frame, physics sub-steps run at different intervals.
This variable-step approach is simple and works well for games that do not rely on rigid-body simulation. For a physics-heavy project, it is often the wrong default.
Fixed timestep and the accumulator pattern
A fixed-timestep loop decouples simulation from rendering. You choose a constant step, such as 1 / 60 seconds, and accumulate raw dt until it is large enough to step the simulation. The loop may run multiple physics sub-steps for a single rendered frame.
Rendering still happens every frame. To keep motion smooth, you pass an interpolation factor—often called alpha—to love.draw(). Your drawing code then lerps between the previous and current physics state, so visuals remain fluid even when the simulation runs at a fixed rate.
Worked example: a minimal custom love.run()
Place this in main.lua on a desktop build of Love2D 11.4 or later. It replaces the default loop with an accumulator that steps at 60 Hz and pumps events every iteration so the window stays responsive.
local fixedDt = 1 / 60
local accumulator = 0
local running = true
function love.run()
if love.load then
love.load(love.arg.parseGameArguments(arg), arg)
end
while running do
-- Keep the window responsive to OS events.
love.event.pump()
for name, a, b, c, d, e, f in love.event.poll() do
if name == "quit" then
running = false
end
love.handlers[name](a, b, c, d, e, f)
end
local dt = love.timer.step()
accumulator = accumulator + dt
-- Clamp to avoid a spiral of death after a slow frame.
if accumulator > 0.25 then
accumulator = 0.25
end
while accumulator >= fixedDt do
update(fixedDt)
accumulator = accumulator - fixedDt
end
local alpha = accumulator / fixedDt
draw(alpha)
love.graphics.present()
love.timer.sleep(0.001)
end
end
This example is intentionally minimal. A complete implementation must store the previous transform state before each fixed update and pass alpha into your draw routine so it can interpolate positions. The event loop above assumes Love2D 11.4+, where love.event.pump() and love.event.poll() are separate calls; earlier 11.x releases use a different iterator style.
Where to run: any terminal with the love binary in PATH. Permissions: none. Version check: run love --version and confirm an 11.x release before using this event API. Risk: if update(fixedDt) blocks, the event pump starves and the window freezes.
Trade-offs and limitations
- State complexity: You must manage previous and current state for interpolation, and decide how many sub-steps to allow per frame.
- Sleep granularity:
love.timer.sleep(0.001)behaves differently across operating systems; Windows is coarser than Linux or macOS. Profile on your target platform. - Shader time: A fixed render alpha does not advance shader time automatically. Pass an explicit time uniform if your shaders depend on it.
- Mobile: iOS and Android ports may impose their own run loop and ignore a custom
love.run(). Test on device before relying on it. - Network determinism: Fixed stepping is necessary for lockstep multiplayer, but not sufficient. You still need deterministic random numbers, input ordering, and float behavior across architectures.
How to check the result
- Run
love --versionto confirm you are using Love2D 11.x. - Add a Box2D body to a test project and compare movement at 30 FPS and 144 FPS with and without the custom loop.
- Watch
love.timer.getFPS()and CPU usage while adjustinglove.timer.sleep(). - Click the window close button or send SIGTERM and confirm the loop exits without hanging.
Rollback is trivial: remove the custom love.run() from main.lua and Love2D falls back to its default variable-timestep loop.
Bottom line
Start with Love2D's default loop. Add a fixed-timestep accumulator only when you see physics jitter or need deterministic simulation. Measure frame-time behavior on your target hardware before shipping the change.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.