gamedev.
gamedev6 min read

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:

  1. 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 with world:addResponse.
  2. cols[i].normal gives 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 so normal.y < 0 is "I landed on something". This trips up newcomers; the bump.lua README is explicit but easy to skim past.
  3. Reset on_ground before iterating collisions and set it from the collision data, not from vy == 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" with world:addResponse that snaps the player's y to the slope surface based on x. 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:move call costs roughly 3-5\u00d7 a world:check call, because it iterates collisions until the goal is reached. If you only need "what would I hit if I moved here," use check.
  • Re-adding an item every frame (instead of using world:update) is around 10\u00d7 slower in profiling on busy worlds. Use update for moving platforms and add/remove only 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: