Collision Detection Techniques

· 9 min read · Updated March 20, 2026 · intermediate
collision game-development love2d physics

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 — that’s where it gets interesting.

This tutorial covers the collision detection techniques you’ll actually use in LÖVE2D games, from basic AABB to tile-based systems and physics libraries.

Prerequisites

This tutorial assumes you have a working LÖVE2D project. If you need to set one up first, see the Getting Started with LOVE 2D tutorial.

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 has an x, y position (top-left corner) plus w (width) and h (height). Here’s how you’d use it in a LÖVE2D game:

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 doesn’t account for rotation. If your game has rotated objects, you’ll need a different approach.

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

Here’s a complete example with a player and a collectible coin:

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.

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, tile-based collision is the standard approach. The world is divided into a grid, and you check which tiles the player overlaps with.

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 — bouncing, jointed objects, 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’s slower than custom collision code for simple games.

Using bump.lua

For simpler games that need better performance than physics, bump.lua is a popular lightweight collision library.

luarocks install bump
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 — 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.

See Also