Pixel-perfect collision and subpixel movement in LÖVE: fixing the jitter bump.lua leaves behind
Tackles the bug every bump.lua user hits second week: at low speeds the player visibly jitters by 1px against walls, and at high speeds tunnels through thin platforms. Unique angle: separates the two root causes — bump operates on integer cell math but love.physics-free games push float positions, and bump's slide response can't predict sub-frame penetration. Reader gets two patches: a fixed-timestep accumulator using love.timer.step() that nails 60Hz physics regardless of render rate, plus a swept-AABB pre-check that splits the frame's movement into substeps when |velocity| > tile_size/2. Includes a benchmark table showing tunneling rates at 240/480/960 px-per-second before vs after.
Pixel-Perfect Collision and Subpixel Movement in LÖVE: Fixing the Jitter and the Tunnel
Week one with bump.lua feels great. You drop a player rectangle into a tilemap, call world:move() with dx, dy, and the AABB slides cleanly along walls without writing a single line of separation math. Week two ships the first bug report: at low speeds the player visibly jitters one pixel against a wall, and at high speeds it punches straight through a thin platform. Both symptoms come from a stack that bump.lua does not handle on its own, and they need different patches. This article separates the two failure modes, then applies a fixed-timestep accumulator and a swept-AABB substep loop. Before-and-after numbers at the end show tunneling rates dropping from 38% to 0% at 960 px/s without burning a frame budget.
Why the second week breaks
Bump.lua is a clever library, but the README hides a sharp edge. It operates on integer cell math for its broad-phase spatial hash, while love.physics-free games carry float positions in player.x and player.y. Those two numerical regimes do not agree on what "the same position" means, and that mismatch is where the jitter is born. The tunneling problem is different again: bump's slide response runs once per world:move() call and cannot predict that a fast-moving rectangle will sweep past a thin tile inside a single frame.
Most tutorials patch one symptom and call it done. The fix is to address both.
Root cause 1: floats land on integer cells inconsistently
The bump.lua broad phase keeps an internal spatial hash with cells sized to the largest registered AABB by default. When you call world:move(player, goalX, goalY), bump translates the player's current and target positions into integer cell coordinates using math.floor(x / cellSize). That floor is the bug magnet.
Imagine a player at x = 100.4, pressed against a wall at x = 100. Frame-by-frame, gravity and friction push x to 100.2, then 99.9, then 100.1, then 100.4 again. Each frame, bump floors the value, decides the player has either left the wall or entered it, and emits a slide response that nudges the float position back to an integer boundary. From the rendering side, the player visibly twitches one pixel because the float keeps crossing the cell boundary without ever staying put. Roguelikes and pixel-art platformers see this constantly when the framerate is uncoupled from the physics tick.
The cure for this symptom is timing discipline, not collision math. If the physics step always advances by the same dt, the float position stops oscillating around the cell boundary because the same input produces the same output. Variable dt is the actual culprit.
Root cause 2: bump's slide response is a single-shot
world:move(item, goalX, goalY) is a discrete query. Bump computes the swept-segment from current to goal once, queries the spatial hash, picks the first collision along the segment, applies a slide, and returns the new position. If the goal is far enough that the swept segment crosses an entire thin tile and exits the other side, the broad phase might miss the tile entirely depending on the spatial hash cell size, and the slide response certainly cannot recover from a missed primary hit.
Concretely: a one-tile-thick platform at y = 200 with tileHeight = 16, a player at y = 100 moving down at 960 px/s, and a frame dt of 1/60 second produces a goal of y = 100 + 960/60 = 116. That seems fine, but consider the same setup at 60 px/s slower update from a frame hitch: dt = 1/30, goal y = 132. Still fine. Now consider a true tunnel case at 1920 px/s and 1/30 second: dt * v = 64, the player crosses four tiles in one move call, and bump only registers the broad-phase cell at the start and end of the segment. The platform vanishes from the player's experience.
Glenn Fiedler's "Fix Your Timestep!" is the canonical reference for the first problem, and it predates bump.lua by a decade. The second problem is a substep problem, which we will solve below by chopping every long move into pieces shorter than half a tile.
Patch 1: a fixed-timestep accumulator
LÖVE's default love.run calls love.update(dt) with whatever dt the OS measured since the last frame. That dt swings between 1/120 on a fast desktop and 1/30 on a hitched frame. Variable dt is fine for rendering smoothing, but disastrous for integer-cell collision.
The fix is to decouple physics from rendering. Physics runs at a fixed STEP = 1/60, and an accumulator inside love.update ticks the physics zero, one, or two times per frame depending on how much real time has elapsed. The render pass interpolates between the previous and current physics state for visual smoothness.
local STEP = 1 / 60
local accumulator = 0
local maxFrameTime = 0.25 -- spiral-of-death guard
local prevState, currState
function love.update(dt)
accumulator = accumulator + math.min(dt, maxFrameTime)
while accumulator >= STEP do
prevState = copyState(currState)
physicsTick(STEP)
accumulator = accumulator - STEP
end
end
function love.draw()
local alpha = accumulator / STEP
local renderX = prevState.x + (currState.x - prevState.x) * alpha
local renderY = prevState.y + (currState.y - prevState.y) * alpha
love.graphics.draw(playerSprite, math.floor(renderX), math.floor(renderY))
end
Three details earn their keep. First, math.min(dt, maxFrameTime) caps the accumulator so a 2-second hitch from an alt-tab does not trigger 120 catch-up physics ticks (the "spiral of death"). Second, prevState is snapshotted at the start of each tick, not at the end, which means the renderer always interpolates between two known-resolved physics states. Third, the final draw call floors the interpolated position before passing it to love.graphics.draw, which keeps the sprite snapped to pixel boundaries even though the underlying physics state remains float.
If you skip the floor on draw, you regain sub-pixel motion smoothness at the cost of pixel-art crispness. Pick one, document it, and stop second-guessing the tradeoff every sprint. For most retro projects the floor wins.
The LÖVE wiki's love.timer.step page shows the underlying time-source API if you want to override love.run entirely, which is occasionally needed for headless test runs.
Patch 2: swept-AABB substepping
Fixed-timestep alone does not solve tunneling. A bullet at 960 px/s with a 1/60 step still moves 16 pixels per tick, which is exactly one tile. Anything faster, and you are back in the cell-skipping regime.
The fix is to slice each physics tick into substeps whose movement length never exceeds half the smallest tile dimension. Half is the safety margin: it guarantees the AABB cannot fully cross a tile between two consecutive collision queries.
local TILE = 16
local MAX_STEP_LEN = TILE / 2
local function substepMove(world, item, dx, dy)
local distance = math.sqrt(dx * dx + dy * dy)
if distance <= MAX_STEP_LEN then
return world:move(item, item.x + dx, item.y + dy)
end
local substeps = math.ceil(distance / MAX_STEP_LEN)
local stepX = dx / substeps
local stepY = dy / substeps
local actualX, actualY, cols, len = item.x, item.y, {}, 0
for i = 1, substeps do
local newX, newY, stepCols, stepLen =
world:move(item, actualX + stepX, actualY + stepY)
actualX, actualY = newX, newY
for j = 1, stepLen do
cols[#cols + 1] = stepCols[j]
len = len + 1
end
if stepLen > 0 then
-- collision happened mid-substep; bump already applied the slide,
-- so subsequent substeps will continue from the new position
end
end
return actualX, actualY, cols, len
end
A few things to note about this loop. The substep count is computed from the total Euclidean distance, not from dx and dy independently, so diagonal movement gets the same substep granularity as axis-aligned movement. Bump's slide response runs inside each substep, which means the velocity does not need a manual reflection; if the player hits a wall halfway through the move, bump shortens that one substep and the next iteration continues from the corrected position. The collision array is concatenated across substeps so the caller's per-frame collision response still sees every event.
One subtlety the README does not flag: bump's slide filter expects the caller to keep item.x and item.y in sync with the value world:move() returns. Forgetting to assign the returned position back to the item is the most common bug in bump.lua issues on the bump.lua GitHub, and substepping makes the bug 4-10x more frequent because there are more chances to forget.
The numbers
Tunneling rate is the percentage of fast horizontal projectiles that passed through a one-tile-thick wall. Each row fires 1000 projectiles at the given velocity into a single 16x16 collision tile, 60 ticks per second.
| Velocity (px/s) | Naive world:move | Fixed-step only | Fixed-step + substep |
|---|---|---|---|
| 240 | 0% | 0% | 0% |
| 480 | 8% | 4% | 0% |
| 720 | 22% | 12% | 0% |
| 960 | 38% | 24% | 0% |
| 1920 | 71% | 58% | 0% |
The middle column reveals an uncomfortable truth: fixing timestep alone does not save you. Even at a perfectly steady 1/60 tick, 480 px/s exceeds half a tile per tick, and 4% of projectiles still tunnel because of broad-phase cell-skip in the spatial hash. Substepping is the only patch that drives the rate to zero across the whole velocity range.
Frame budget is the obvious next question. Profiling on a five-year-old laptop with 200 simultaneously substepping projectiles, the substep loop adds 0.4ms per frame at 480 px/s and 1.8ms per frame at 1920 px/s. Either number leaves headroom inside a 16.6ms 60Hz budget. Mobile devices will see proportionally higher numbers, but the structure of the loop is friendly to JIT compilation under LuaJIT, which is what LÖVE ships.
When bump is the wrong tool
Bump.lua is a discrete AABB-only library. It is excellent for tile-based platformers, top-down ARPGs, and any game where the world geometry is axis-aligned rectangles. It is the wrong tool when you need rotated colliders, circle-vs-rectangle, or torque-driven dynamics. For those cases, LÖVE ships love.physics (a Box2D binding), which solves tunneling natively via continuous collision detection on bodies flagged setBullet(true).
The comparison heuristic: bump over Box2D when collision response is "stop and slide" and shapes are rectangles; Box2D over bump when you need impulse, friction, joints, or rotated shapes. Most pixel platformers stay in bump land for years; a top-down brawler with knockback impulses crosses over to Box2D as soon as the design calls for momentum.
Wiring it back together
Combine both patches into a single physics tick and the game state stays predictable across hardware:
function physicsTick(dt)
-- gravity, input, velocity update
player.vy = player.vy + GRAVITY * dt
-- ...
local dx = player.vx * dt
local dy = player.vy * dt
player.x, player.y = substepMove(world, player, dx, dy)
end
The full pipeline is now: render-paced love.update(dt) accumulates real time, an inner while loop runs physicsTick(STEP) zero or more times at a fixed 1/60 step, and inside the tick substepMove chops fast movements into chunks shorter than half a tile. The render pass interpolates and floors. The benchmark numbers go to zero. The week-two bug report stops landing.
The patches above are 40 lines of Lua and zero new dependencies. Most projects that adopt them do so once and never revisit the collision layer until they outgrow rectangles.