Fixed-Timestep Physics with Render Interpolation in LÖVE
Step LÖVE physics with a constant timestep using an accumulator loop, clamp catch-up to avoid the spiral of death, and interpolate transforms at draw time for smooth rendering at any refresh rate.
08 Apr 2026, 05:39 UTC

If you step a LÖVE physics world with the variable dt from love.update, your simulation becomes frame-rate dependent: a player at 144 Hz gets different collision behavior than one at 30 Hz, and a single frame hitch can tunnel a fast body through a wall. The fix is a fixed-timestep loop with render interpolation — physics always advances in equal slices (say 1/60 s), while drawing blends between the last two physics states so motion still looks smooth on any display.
What you need
- LÖVE 11.x or 12.x (the
love.timerAPI used here is stable across both). - A project using
love.physics.newWorld, or any custom simulation you want to be deterministic. - Entities that expose a position and angle you can snapshot each tick.
The accumulator loop
The core idea: accumulate real elapsed time, then consume it in fixed-size chunks. Physics only ever sees the constant fixedDt.
-- main.lua
local fixedDt = 1 / 60
local accumulator = 0
local MAX_ACCUM = 5 * fixedDt -- clamp to avoid the "spiral of death"
function love.load()
world = love.physics.newWorld(0, 9.81 * 64, true)
-- one falling box: store previous and current state for interpolation
box = {
body = love.physics.newBody(world, 400, 0, "dynamic"),
prevX, prevY, prevA = 400, 0, 0,
curX, curY, curA = 400, 0, 0,
}
box.shape = love.physics.newRectangleShape(40, 40)
box.fixture = love.physics.newFixture(box.body, box.shape, 1)
end
local function fixedUpdate(dt)
-- snapshot current state BEFORE stepping
box.prevX, box.prevY, box.prevA = box.curX, box.curY, box.curA
-- poll input here so response is tied to simulation ticks
if love.keyboard.isDown("right") then box.body:applyForce(2000, 0) end
world:update(dt) -- always the constant fixedDt
box.curX, box.curY = box.body:getPosition()
box.curA = box.body:getAngle()
end
function love.update(dt)
accumulator = accumulator + dt
if accumulator > MAX_ACCUM then accumulator = MAX_ACCUM end -- drop time, don't stall
while accumulator >= fixedDt do
fixedUpdate(fixedDt)
accumulator = accumulator - fixedDt
end
endRun this in your project's main.lua; no special permissions or libraries are needed. The clamp matters: after a breakpoint, a window drag, or a long pause, the raw accumulator could demand hundreds of catch-up steps, each producing more backlog — the classic spiral of death. Capping at five steps means the game slows down briefly instead of freezing.
Interpolating at draw time
After the while loop, the leftover accumulator is a fraction of a step. Use it as the blend factor between the previous and current snapshots. Interpolation is a rendering concern only — never write the interpolated values back into the body or into game logic.
local function lerp(a, b, t) return a + (b - a) * t end
function love.draw()
local alpha = accumulator / fixedDt
local x = lerp(box.prevX, box.curX, alpha)
local y = lerp(box.prevY, box.curY, alpha)
local a = box.prevA + (box.curA - box.prevA) * alpha
love.graphics.push()
love.graphics.translate(x, y)
love.graphics.rotate(a)
love.graphics.rectangle("fill", -20, -20, 40, 40)
love.graphics.pop()
endAngle interpolation via simple lerp is fine for small per-tick rotation; if a body can spin more than half a turn per tick, wrap the angle difference to the shortest path first.
What stays outside the fixed loop
Not everything should be ticked at 60 Hz. Keep these separate:
- Particles, camera shake, UI tweens: update with the variable
dtdirectly. They are cosmetic and don't need determinism. - Game logic that must be reproducible (scoring, triggers, damage): keep it inside
fixedUpdate. Mixing it into draw or variable-dt update breaks determinism. - Discrete input events (key presses, not held keys): at low frame rates a press can fall between ticks. Queue events in
love.keypressedand drain the queue insidefixedUpdateso no press is lost.
Checking that it works
- Determinism: log the box's Y position every fixed step to the console. Two runs at different display refresh rates should print identical sequences.
- Smoothness: draw a second, unfilled rectangle at the raw physics position (
box.curX, box.curY). On a 30 Hz display the filled (interpolated) box should glide while the outline visibly jumps between tick positions. - Hitch recovery: temporarily add
love.timer.sleep(0.1)at the top oflove.draw. The box should keep falling without tunneling through the ground, and the clamp should limit catch-up to five steps per frame. - Step rate: count
fixedUpdatecalls per second and print alongsidelove.timer.getFPS(). You should see roughly 60 ticks/second whether FPS reads 30 or 144.
Limitations
Interpolation adds up to one tick (~16 ms at 60 Hz) of visual latency; competitive games sometimes extrapolate instead, at the cost of snapping. Box2D itself is not perfectly deterministic across platforms, so "deterministic" here means same-machine reproducibility, not lockstep netcode. On mobile, timer resolution can degrade under thermal throttling — verify tick stability on a real device rather than the desktop emulator. If you background-load assets with love.thread, keep message passing non-blocking so the main loop's timing isn't disturbed.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.