bump.lua Collision Patterns for LÖVE Platformers
Practical bump.lua patterns for 2D platformer collision in LÖVE: world setup, response filters, slopes, and one-way platforms.
bump.lua Collision Patterns for L\u00d6VE Platformers
Writing platformer collision from scratch in L\u00d6VE teaches you a lot about swept AABBs, but it also eats weeks. bump.lua skips that detour. It is a small, AABB-only collision library by Enrique Garc\u00eda Cota that handles broad-phase queries, swept movement, and a flexible filter callback that decides what each collision means. For most indie 2D platformers, that combination hits the sweet spot between rolling your own and reaching for Box2D.
This article walks through the patterns that actually matter once your player starts moving: world setup, response types, ground detection, one-way platforms, slopes, and the gotchas that ship games hit before they ship.
When bump.lua over a physics engine
L\u00d6VE ships with love.physics, a Box2D binding. Box2D is the right call for a game with rigid body dynamics, joints, motors, ragdolls, or anything where impulse-driven physics is the point. Platformers are usually the opposite. Mario does not bounce off goombas with conservation of momentum. He moves at a fixed velocity, jumps with a tuned arc, and stops dead when he hits a wall.
bump.lua wins for that style because:
- All bodies are AABBs. No rotation, no compound shapes, no joints. Simpler mental model.
- Movement is kinematic. You set position, the library tells you what got in the way.
- The filter function lets you classify collisions per-pair, so the same call handles both solid walls and one-way platforms.
- It is one file, around 600 lines, MIT licensed.
Box2D is roughly 50\u00d7 the code surface and forces you to think in forces and impulses even when you do not want to. For a Celeste-style platformer, that is friction, not features.
Setting up the world
Install bump.lua by dropping bump.lua into your project from github.com/kikito/bump.lua. The world is the spatial hash that holds every collidable item:
local bump = require("bump")
function love.load()
world = bump.newWorld(64)
player = { x = 100, y = 100, w = 14, h = 24, vx = 0, vy = 0, on_ground = false }
world:add(player, player.x, player.y, player.w, player.h)
for _, t in ipairs(level.tiles) do
world:add(t, t.x, t.y, t.w, t.h)
end
end
The 64 is the cell size of the spatial hash, expressed in world units. Pick a value close to your average AABB size. Too small and the broad phase wastes memory on sparse cells; too large and each query checks too many candidates. For a platformer with 16-pixel tiles and a player around 14\u00d724, a cell size of 64 is a reasonable default.
Movement and the filter callback
Every frame, you compute desired velocity, then ask the world to move the player. bump returns the actual position after collisions plus a list of what was hit:
local function player_filter(item, other)
if other.is_one_way then return "cross" end
if other.is_pickup then return "cross" end
return "slide"
end
function update_player(dt)
player.vy = player.vy + GRAVITY * dt
local goal_x = player.x + player.vx * dt
local goal_y = player.y + player.vy * dt
local actual_x, actual_y, cols = world:move(player, goal_x, goal_y, player_filter)
player.x, player.y = actual_x, actual_y
player.on_ground = false
for _, col in ipairs(cols) do
if col.normal.y < 0 then
player.on_ground = true
player.vy = 0
elseif col.normal.y > 0 then
player.vy = 0
end
if col.normal.x ~= 0 then
player.vx = 0
end
end
end
Three things deserve attention:
- The filter returns a response type, not a boolean.
"slide"makes the player slide along the surface (the platformer default)."cross"lets the player pass through but still reports the collision so you can trigger pickups or one-way logic."touch"stops dead."bounce"reflects velocity. You can register custom responses withworld:addResponse. cols[i].normalgives the collision normal in world space. For a player landing on a floor, the floor's normal points up, which means the player hit it from below relative to the normal \u2014 sonormal.y < 0is "I landed on something". This trips up newcomers; the bump.lua README is explicit but easy to skim past.- Reset
on_groundbefore iterating collisions and set it from the collision data, not fromvy == 0. Vertical velocity can be zero at the apex of a jump. Coyote-time and jump-buffering both depend on knowing the actual ground state.
One-way platforms without breaking your collision model
One-way platforms \u2014 the kind you can jump up through but stand on \u2014 are the canonical test for any collision system. With bump.lua you do not need a separate code path. You return "cross" from the filter and also store enough state on the platform to decide whether the cross should turn into a stand:
local function player_filter(item, other)
if other.is_one_way then
if item.vy < 0 then return "cross" end
if item.y + item.h > other.y + 1 then return "cross" end
return "slide"
end
return "slide"
end
Read it as: if the player is moving up, pass through. If the player's bottom edge is already inside the platform, pass through (otherwise you teleport on top of it from below). Otherwise behave like a normal floor.
The + 1 tolerance is the kind of magic number you will see in every shipping platformer. Sub-pixel floating-point error around an integer boundary produces flicker; a single-pixel buffer absorbs it.
Slopes are not free
bump.lua is AABB-only. Slopes are not native. There are three ways to handle them, in increasing order of pain:
- Stair-step approximation. Build slopes out of small AABBs of decreasing height. Cheap to author in Tiled, looks acceptable at 16-pixel tile sizes, breaks down at higher resolutions where the steps become visible.
- Custom response. Register a response like
"slope"withworld:addResponsethat snaps the player'syto the slope surface based onx. You compute the slope analytically from the tile coordinate and gradient. This is what most L\u00d6VE platformers using bump end up doing. - Switch libraries for slopes only. Use bump for axis-aligned collisions and a small per-tile raycast for slope detection. About 80% of the slope problem dissolves if you only need to handle one slope angle (45\u00b0) and treat steep ramps as walls.
If your platformer is grid-based with 16-pixel tiles and visual slopes are decorative, stair-stepping is fine. If your character moves at variable speeds and the camera reads the slope, write the custom response. The work is one afternoon.
Performance notes that ship games care about
bump.lua's spatial hash is fast for the AABB counts a 2D platformer cares about \u2014 under 1000 active items, queries run in well under 1ms on modest hardware. Two practical numbers to remember:
- A
world:movecall costs roughly 3-5\u00d7 aworld:checkcall, because it iterates collisions until the goal is reached. If you only need "what would I hit if I moved here," usecheck. - Re-adding an item every frame (instead of using
world:update) is around 10\u00d7 slower in profiling on busy worlds. Useupdatefor moving platforms andadd/removeonly for entities entering or leaving the level.
For a single-player platformer with a few dozen enemies, neither matters. For a bullet-hell or a swarm-style game, both do.
What to skip
You do not need to wrap bump.lua in a class hierarchy. The world object is already the only state container you need. Resist the urge to add a Body class with :move() methods that proxy to world:move \u2014 every layer of indirection between you and the filter callback is a layer where slope, one-way, and pickup logic gets harder to reason about. The bump README gets this right by keeping all examples flat.
References: