luaguides

Collision Detection Techniques

Prerequisites

This tutorial assumes you have a working LÖVE2D project with a main.lua file and basic familiarity with the LOVE game loop callbacks. You should also be comfortable creating and drawing shapes with love.graphics. If you need to set up a project first, start with the Getting Started with LOVE 2D tutorial.

Collision detection techniques in LÖVE2D

Collision detection is one of those problems that starts simple and gets complicated fast. A rectangle overlaps another rectangle: easy. But rotated rectangles, fast-moving objects that tunnel through walls, and hundreds of objects checking each other every frame are where it gets interesting.

AABB collision detection

The simplest collision detection method is AABB (axis-aligned bounding box). This works with rectangles that don’t rotate. Two rectangles overlap when they overlap on both the X and Y axes simultaneously.

function checkAABB(a, b)
    return a.x < b.x + b.w
       and a.x + a.w > b.x
       and a.y < b.y + b.h
       and a.y + a.h > b.y
end

Each rectangle in the collision function is a plain Lua table with an x and y position for its top-left corner, plus w for width and h for height. The function compares the four edges independently rather than computing a single overlap value, which makes it straightforward to debug when collisions fail. Here is how you would use the function in a complete LÖVE2D game with player movement:

local player = {x = 100, y = 100, w = 32, h = 32}
local enemy = {x = 150, y = 120, w = 32, h = 32}

function love.update(dt)
    -- Move player with arrow keys
    local speed = 200 * dt
    if love.keyboard.isDown("right") then player.x = player.x + speed end
    if love.keyboard.isDown("left") then player.x = player.x - speed end
    if love.keyboard.isDown("down") then player.y = player.y + speed end
    if love.keyboard.isDown("up") then player.y = player.y - speed end
end

function love.draw()
    -- Draw player in blue, enemy in red
    love.graphics.setColor(0, 0, 1)
    love.graphics.rectangle("fill", player.x, player.y, player.w, player.h)
    love.graphics.setColor(1, 0, 0)
    love.graphics.rectangle("fill", enemy.x, enemy.y, enemy.w, enemy.h)

    -- Draw collision debug
    if checkAABB(player, enemy) then
        love.graphics.setColor(0, 1, 0)
        love.graphics.print("COLLISION!", 10, 10)
    end
end

The main limitation of AABB is that it does not account for rotation. If your game has rotated objects, you will need a different approach like separating axis theorem or the physics module covered later in this tutorial. For most 2D games, however, axis-aligned boxes cover the vast majority of collision needs and run fast enough to check hundreds of objects per frame.

Circle-circle collision

Circle collision is often simpler and faster than rectangle collision. Two circles collide when the distance between their centers is less than the sum of their radii.

function checkCircleCollision(c1, c2)
    local dx = c1.x - c2.x
    local dy = c1.y - c2.y
    local dist = math.sqrt(dx * dx + dy * dy)
    return dist < (c1.radius + c2.radius)
end

The function uses the Pythagorean theorem to compute the straight-line distance between two circle centres. Avoiding math.sqrt by comparing squared distances is a common optimisation, but the version above is fine for most games with fewer than a few hundred objects. Here is a complete working example that combines circle collision with a basic coin collection mechanic:

local player = {x = 400, y = 300, radius = 16}
local coins = {
    {x = 100, y = 100, radius = 10, collected = false},
    {x = 700, y = 500, radius = 10, collected = false},
    {x = 400, y = 100, radius = 10, collected = false},
}
local score = 0

function love.update(dt)
    local speed = 200 * dt
    if love.keyboard.isDown("right") then player.x = player.x + speed end
    if love.keyboard.isDown("left") then player.x = player.x - speed end
    if love.keyboard.isDown("down") then player.y = player.y + speed end
    if love.keyboard.isDown("up") then player.y = player.y - speed end

    -- Check coin collection
    for _, coin in ipairs(coins) do
        if not coin.collected and checkCircleCollision(player, coin) then
            coin.collected = true
            score = score + 1
        end
    end
end

function love.draw()
    love.graphics.setColor(1, 1, 1)
    love.graphics.print("Score: " .. score, 10, 10)

    -- Draw coins
    for _, coin in ipairs(coins) do
        if not coin.collected then
            love.graphics.setColor(1, 0.84, 0)
            love.graphics.circle("fill", coin.x, coin.y, coin.radius)
        end
    end

    -- Draw player
    love.graphics.setColor(0, 0, 1)
    love.graphics.circle("fill", player.x, player.y, player.radius)
end

Circle collision works well for character controllers, collectibles, and any game objects that are naturally circular. It also has the nice property of being rotation-invariant: it does not matter which way a circular object is facing when you check for overlap. For mixed shapes, the next section covers combining circles with rectangles.

Circle-rectangle collision

Sometimes you need to check collision between a circle and a rectangle. This comes up when you have a circular player hitting axis-aligned platforms or obstacles.

The algorithm finds the closest point on the rectangle to the circle’s center, then checks if that point is within the circle’s radius:

function checkCircleRectCollision(circle, rect)
    local closestX = math.max(rect.x, math.min(circle.x, rect.x + rect.w))
    local closestY = math.max(rect.y, math.min(circle.y, rect.y + rect.h))
    local dx = circle.x - closestX
    local dy = circle.y - closestY
    return (dx * dx + dy * dy) < (circle.radius * circle.radius)
end

Tile-based collision

For platformers and top-down grid games, tile-based collision is the standard approach. The world is divided into a grid of uniform cells, and you check which tiles the player overlaps with during each frame. This method is predictable, efficient, and easy to debug because every solid surface aligns to the same grid.

local tileSize = 32
local player = {x = 64, y = 64, w = 28, h = 28, vx = 0, vy = 0}

-- 1 = solid tile, 0 = empty
local level = {
    {1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
    {1, 0, 0, 0, 0, 0, 0, 0, 0, 1},
    {1, 0, 0, 0, 0, 0, 0, 0, 0, 1},
    {1, 0, 0, 0, 1, 1, 0, 0, 0, 1},
    {1, 0, 0, 0, 0, 0, 0, 0, 0, 1},
    {1, 1, 1, 1, 1, 1, 1, 1, 1, 1},
}

function isSolid(tileX, tileY)
    if tileY < 1 or tileY > #level then return false end
    if tileX < 1 or tileX > #level[1] then return false end
    return level[tileY][tileX] == 1
end

function checkTileCollision(obj)
    -- Get the tiles the object overlaps
    local left = math.floor(obj.x / tileSize) + 1
    local right = math.floor((obj.x + obj.w - 1) / tileSize) + 1
    local top = math.floor(obj.y / tileSize) + 1
    local bottom = math.floor((obj.y + obj.h - 1) / tileSize) + 1

    for ty = top, bottom do
        for tx = left, right do
            if isSolid(tx, ty) then
                return true, tx, ty
            end
        end
    end
    return false
end

function love.update(dt)
    -- Simple gravity and movement
    player.vy = player.vy + 800 * dt  -- gravity
    player.vx = player.vx * 0.9      -- friction

    if love.keyboard.isDown("left") then player.vx = player.vx - 400 * dt end
    if love.keyboard.isDown("right") then player.vx = player.vx + 400 * dt end
    if love.keyboard.isDown("up") then player.vy = -300 end

    player.x = player.x + player.vx * dt
    player.y = player.y + player.vy * dt

    -- Check collision and stop at walls
    local hit = checkTileCollision(player)
    if hit then
        player.vy = 0
        player.vx = 0
    end
end

Tile-based collision is predictable, efficient, and easy to debug. It’s the right choice for platformers and top-down grid-based games.

Using LÖVE2D’s physics module

For games that need realistic physics like bouncing, jointed objects, and forces, LÖVE2D includes a built-in physics module powered by Box2D.

local world

function love.load()
    love.physics.setMeter(64)  -- 64 pixels = 1 meter
    world = love.physics.newWorld(0, 9.81 * 64)  -- gravity

    -- Create a static ground
    local groundBody = love.physics.newBody(world, 0, 580, "static")
    local groundShape = love.physics.newRectangleShape(800, 40)
    love.physics.newFixture(groundBody, groundShape)

    -- Create a dynamic player
    player = {}
    player.body = love.physics.newBody(world, 400, 100, "dynamic")
    player.shape = love.physics.newRectangleShape(0, 0, 32, 32)
    player.fixture = love.physics.newFixture(player.body, player.shape)
    player.body:setUserData({onCollide = false})

    -- Collision callbacks
    world:setCallbacks(
        function(f1, f2, contact)
            local data1 = f1:getBody():getUserData()
            local data2 = f2:getBody():getUserData()
            if data1 then data1.onCollide = true end
            if data2 then data2.onCollide = true end
        end
    )
end

function love.update(dt)
    world:update(dt)

    -- Reset collision state
    if player.body:getUserData() then
        player.body:getUserData().onCollide = false
    end

    -- Keyboard input
    local force = 500
    if love.keyboard.isDown("left") then
        player.body:applyForce(-force, 0)
    end
    if love.keyboard.isDown("right") then
        player.body:applyForce(force, 0)
    end
    if love.keyboard.isDown("up") then
        player.body:applyLinearImpulse(0, -200)
    end
end

function love.draw()
    love.graphics.setColor(0.5, 0.5, 0.5)
    love.graphics.rectangle("fill", 0, 580, 800, 40)

    local onCollide = player.body:getUserData() and player.body:getUserData().onCollide
    if onCollide then
        love.graphics.setColor(0, 1, 0)
    else
        love.graphics.setColor(0, 0, 1)
    end
    love.graphics.rectangle("fill", player.body:getX() - 16, player.body:getY() - 16, 32, 32)
end

The physics module handles complex collision detection automatically, including rotation and irregular shapes. The tradeoff is overhead: it is slower than custom collision code for simple games.

Using bump.lua

For simpler games that need better performance than the full physics module, bump.lua is a popular lightweight collision library. It provides axis-aligned collision detection and resolution without the overhead of a physics simulation. Install it through LuaRocks and then require it in your project:

luarocks install bump

Once installed, you create a bump world with a configurable cell size and then add your game objects to it. The world tracks each object’s position and resolves collisions transparently when you call the move method. This means objects stop at solid barriers instead of overlapping them, and you get a list of collision events back for free.

local bump = require("bump")

local world = bump.newWorld(32)  -- 32-pixel grid cell size
local player = {x = 100, y = 100, w = 32, h = 32}
local boxes = {
    {x = 300, y = 200, w = 64, h = 64},
    {x = 500, y = 300, w = 64, h = 64},
}

function love.load()
    -- Add player to world
    world:add(player, player.x, player.y, player.w, player.h)

    -- Add boxes
    for _, box in ipairs(boxes) do
        world:add(box, box.x, box.y, box.w, box.h)
    end
end

function love.update(dt)
    local speed = 200 * dt
    local dx, dy = 0, 0

    if love.keyboard.isDown("right") then dx = dx + speed end
    if love.keyboard.isDown("left") then dx = dx - speed end
    if love.keyboard.isDown("down") then dy = dy + speed end
    if love.keyboard.isDown("up") then dy = dy - speed end

    -- Move with collision
    local actualX, actualY, cols = world:move(player, player.x + dx, player.y + dy)
    player.x, player.y = actualX, actualY

    -- Handle collisions
    for _, col in ipairs(cols) do
        print("Collided with: " .. col.other.x .. ", " .. col.other.y)
    end
end

function love.draw()
    -- Draw boxes
    love.graphics.setColor(1, 0, 0)
    for _, box in ipairs(boxes) do
        love.graphics.rectangle("fill", box.x, box.y, box.w, box.h)
    end

    -- Draw player
    love.graphics.setColor(0, 0, 1)
    love.graphics.rectangle("fill", player.x, player.y, player.w, player.h)
end

bump.lua handles collision resolution automatically: the player stops at walls instead of passing through them. It also supports sliding along walls and other response behaviors.

Common gotchas

Collision detection and collision response are different things. Detecting a collision tells you that objects overlap. Responding to a collision means deciding what happens: the player stops, bounces, takes damage, or collects an item.

Fast objects tunnel through walls. If an object moves more than its own width in a single frame, it can skip over thin walls entirely. Use continuous collision detection (CCD) or cap maximum velocity to fix this.

O(n²) doesn’t scale. Checking every object against every other object works for 10 objects but breaks down at 100. Use spatial partitioning with grids, quadtrees, or spatial hashes to reduce checks.

Float precision causes jitter. In tile-based games, use integer positions for grid alignment. Small float errors accumulate and cause objects to vibrate or drift.

Next steps

Now that you can detect when objects collide, the next skill to build is making those collisions feel right. Head to Drawing and Animation in LOVE to learn how to give your game objects visual feedback with sprites and frame-by-frame animation. If you are building a platformer, the Tilemaps in LOVE 2D tutorial will show you how to combine tile-based collision with efficient rendering.

See also