Fixed Timestep Game Loops in LÖVE Without the Spiral of Death
Decouple simulation from rendering in LÖVE using a fixed timestep accumulator with a spiral-of-death cap.
05 Jan 2026, 23:57 UTC

The problem: your player moves faster on a 144 Hz monitor
LÖVE calls love.update(dt) once per frame, passing dt as the elapsed seconds since the last frame. On a 60 Hz display that is roughly 0.0167 seconds; on a 144 Hz display it is roughly 0.0069 seconds. If you move an object by speed * dt, the object travels the same distance per second regardless of frame rate, but the simulation itself still advances in uneven chunks. Frame-time jitter can make physics feel inconsistent, and deterministic or networked simulations can diverge between clients.
The useful takeaway is to decouple the simulation clock from the display clock: advance game logic in fixed increments, then render using the leftover fraction between steps.
How a fixed timestep accumulator works
A fixed timestep keeps an accumulator variable. Each frame, add the variable dt to it. While the accumulator holds at least one step size—commonly 1 / 60 seconds—run one simulation update and subtract the step size. Whatever remains is the interpolation alpha: a value from 0 to 1 that tells the renderer how far to blend between the previous and current game states.
Interpolation is not extrapolation. Rendering between known states avoids inventing future positions, which can cause objects to visually overshoot collisions.
Worked example: fixed update with interpolated rendering
Place this in main.lua of a LÖVE 11.x project. No special permissions are required; run it with the love command or by dragging the project folder onto the LÖVE application.
local STEP = 1 / 60
local MAX_ACCUMULATOR = 5 * STEP -- prevents spiral of death
local player = { x = 100, y = 100, vx = 120 }
local previous = { x = 100, y = 100 }
local current = { x = 100, y = 100 }
local accumulator = 0
function love.load()
-- No fixed-timestep state is needed here beyond the tables above.
end
function love.update(dt)
-- dt is variable frame time in seconds.
accumulator = accumulator + dt
-- Cap the accumulator so a slow frame cannot trigger
-- an unbounded number of simulation steps.
if accumulator > MAX_ACCUMULATOR then
accumulator = MAX_ACCUMULATOR
end
while accumulator >= STEP do
previous.x = current.x
previous.y = current.y
-- Mutate simulation state only here, never during rendering.
current.x = current.x + player.vx * STEP
current.y = current.y -- gravity or other logic would go here
accumulator = accumulator - STEP
end
end
function love.draw()
local alpha = accumulator / STEP -- 0 to 1
-- Interpolate for display only. Do not write back to current state.
local renderX = previous.x + (current.x - previous.x) * alpha
local renderY = previous.y + (current.y - previous.y) * alpha
love.graphics.rectangle("fill", renderX, renderY, 16, 16)
end
STEP is the simulation interval in seconds. MAX_ACCUMULATOR is the safety cap: if the game lags, the simulator runs at most five steps per frame instead of trying to catch up forever. alpha is the leftover fraction used only for visual interpolation.
Trade-offs and limitations
- Spiral of death: without
MAX_ACCUMULATOR, a frame that takes longer than one step can cause the next frame to simulate even more steps, consuming more time and worsening the lag. The cap trades perfect catch-up for bounded frame time. - Input latency: because rendering interpolates between the previous and current states, the most recent input may not appear visually until the next completed step. For fast-twitch games, consider whether fixed simulation or variable rendering better fits your input model.
- Box2D interaction: LÖVE's
love.physicsmodule steps Box2D independently. Mixing a manual fixed timestep with Box2D's world step requires consistent handling; do not step Box2D inside bothlove.updateand a custom physics loop without accounting for double integration.
How to verify the result
- Add
print(dt)insidelove.update, then resize or unfocus the window. You should see variable frame times, confirmingdtis not constant. - Move an object at a fixed pixels-per-second rate using raw
dtfor one run, then repeat with the fixed timestep above over identical wall-clock durations. Compare final positions; the fixed timestep should be more reproducible across frame rates. - Watch for visual stutter when the display refresh rate differs from 60 Hz. Interpolation should smooth motion, but input may feel slightly delayed relative to uncapped rendering.
Closing
A fixed timestep does not make your game run faster; it makes simulation behavior stable across hardware. Start with 1 / 60 seconds per step, cap the accumulator, mutate state only inside the step loop, and interpolate only for drawing. If your game uses Box2D, test the combined step behavior before shipping.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.