Back

Activity 2.7: Collision Detection

divider

The Idea

You have bullets and you have enemies, and so far they politely ignore each other. Making them interact is one function and one nested loop — and this is the activity that turns a screensaver into a game.

Every object in the game carries an x, a y and a size, with x and y at its center. That was a deliberate choice back in Activity 2.3, and here is the payoff: one function works for any two of them.

main.lua
local function overlaps(a, b)
return a.x - a.size / 2 < b.x + b.size / 2
and a.x + a.size / 2 > b.x - b.size / 2
and a.y - a.size / 2 < b.y + b.size / 2
and a.y + a.size / 2 > b.y - b.size / 2
end

This is an axis-aligned bounding box test, and the logic is easier read backwards: two boxes overlap unless one is entirely to the left, right, above, or below the other. Four ways to miss; fail all four and you have a hit.

  • a.x - a.size / 2 is a's left edge, and b.x + b.size / 2 is b's right edge. If a's left is past b's right, they miss.
  • The four conditions are joined with and, so Lua stops at the first false — most pairs are rejected on the first comparison, which is why this is cheap enough to run on every pair every frame.
  • Strict < rather than <= means exactly touching edges do not count as a hit. Either convention is defensible; this one avoids objects sticking together when they are merely adjacent.

It knows nothing about bullets or enemies. It takes any two tables with those three fields, which is why it will also test the player against an enemy without a single change. Activity 1.7's "write the general thing" instinct, applied.

Real games use circles for round things and it is often a better fit — two circles collide when the distance between centers is less than the sum of the radii, which is one line with math.sqrt. Boxes are used here because the objects are boxes.

Checking every pair

main.lua
for i = #enemies, 1, -1 do
for j = #bullets, 1, -1 do
if overlaps(enemies[i], bullets[j]) then
table.remove(bullets, j)
table.remove(enemies, i)
score = score + 1
break
end
end
end

Both loops run backwards, because both lists can lose an item. This is Activity 1.9's rule twice over, and getting one of them wrong produces a bug that only shows up when two things die on the same frame — which is exactly when you are least likely to be watching carefully.

The break matters more than it looks. Once enemies[i] has been removed, that index no longer refers to the enemy you were testing. Carrying on would compare the next bullet against a different enemy, or against nothing at all. Leaving immediately keeps the loop honest.

It also means one bullet kills one enemy, which is what a player expects. Without the break a single bullet could clear a whole column.

How expensive is this?

Every enemy is compared with every bullet, so twenty enemies and thirty bullets is six hundred checks a frame. That is completely fine — each check is four comparisons, and modern machines do millions.

It stops being fine somewhere in the thousands of objects, and the fix then is to stop comparing things that are nowhere near each other (a grid, or sorting by y). Do not build that now. It is worth knowing the ceiling exists and worth knowing you are a long way under it.

When the player is hit

The same function, with the player passed in instead of a bullet. What happens on a hit is a design question, and for now the simplest honest answer is to wipe the board and reset the score.

Assigning enemies = {} replaces the list rather than emptying it, and the old one is simply forgotten — Lua cleans it up. That works here because nothing else is holding a reference to it. It is the same reference-versus-copy question from Activity 1.9's challenge, and this is the case where it does not bite you.

Do the player check before the bullet check. If both happen on one frame, you want the reset to win rather than to score a point on the frame you died.

divider

Build

Same file. This is the one that makes it a game.


Task 1: Shoot things down

  • Add a score variable and the overlaps function above love.load.
  • Add the nested backwards loop at the end of update. Remove both objects, add a point, and break.
  • Show the score instead of the enemy count.
  • Then delete the break and hold space. You will see a stream of errors or enemies vanishing in groups. Put it back and reread the paragraph about why.
main.lua
-- main.lua
local ship
local player = { x = 400, y = 500, size = 40, speed = 300 }
local bullets = {}
local fireCooldown = 0
local enemies = {}
local spawnTimer = 0
local spawnEvery = 0.8
local score = 0
local Enemy = {}
Enemy.__index = Enemy
function Enemy.new(x)
return setmetatable({ x = x, y = -20, size = 30, speed = 120 }, Enemy)
end
function Enemy:update(dt)
self.y = self.y + self.speed * dt
end
function Enemy:draw()
love.graphics.rectangle("fill", self.x - self.size / 2, self.y - self.size / 2, self.size, self.size)
end
local function overlaps(a, b)
return a.x - a.size / 2 < b.x + b.size / 2
and a.x + a.size / 2 > b.x - b.size / 2
and a.y - a.size / 2 < b.y + b.size / 2
and a.y + a.size / 2 > b.y - b.size / 2
end
function love.load()
love.graphics.setBackgroundColor(0.05, 0.05, 0.1)
math.randomseed(os.time())
ship = love.graphics.newImage("ship.png")
end
function love.update(dt)
if love.keyboard.isDown("left", "a") then
player.x = player.x - player.speed * dt
end
if love.keyboard.isDown("right", "d") then
player.x = player.x + player.speed * dt
end
local half = player.size / 2
player.x = math.max(half, math.min(love.graphics.getWidth() - half, player.x))
fireCooldown = fireCooldown - dt
if love.keyboard.isDown("space") and fireCooldown <= 0 then
table.insert(bullets, { x = player.x, y = player.y - 30, size = 6, speed = 500 })
fireCooldown = 0.15
end
for i = #bullets, 1, -1 do
local b = bullets[i]
b.y = b.y - b.speed * dt
if b.y < -20 then
table.remove(bullets, i)
end
end
spawnTimer = spawnTimer - dt
if spawnTimer <= 0 then
table.insert(enemies, Enemy.new(math.random(40, love.graphics.getWidth() - 40)))
spawnTimer = spawnEvery
end
for i = #enemies, 1, -1 do
local e = enemies[i]
e:update(dt)
if e.y > love.graphics.getHeight() + 20 then
table.remove(enemies, i)
end
end
for i = #enemies, 1, -1 do
for j = #bullets, 1, -1 do
if overlaps(enemies[i], bullets[j]) then
table.remove(bullets, j)
table.remove(enemies, i)
score = score + 1
break
end
end
end
end
function love.draw()
love.graphics.setColor(1, 1, 1)
love.graphics.draw(ship, player.x, player.y, 0, 2, 2, ship:getWidth() / 2, ship:getHeight() / 2)
love.graphics.setColor(1, 0.9, 0.3)
for _, b in ipairs(bullets) do
love.graphics.rectangle("fill", b.x - 2, b.y - 8, 4, 16)
end
love.graphics.setColor(1, 0.3, 0.4)
for _, e in ipairs(enemies) do
e:draw()
end
love.graphics.setColor(1, 1, 1)
love.graphics.print("Score: " .. score, 10, 10)
end

Task 2: Let the player be hit

  • Add a second loop, before the bullet-versus-enemy one, that tests each enemy against the player.
  • On a hit, clear both lists and reset the score to zero.
  • Stop moving and let an enemy reach you. Everything should vanish and the score should return to 0.
main.lua
-- main.lua
local ship
local player = { x = 400, y = 500, size = 40, speed = 300 }
local bullets = {}
local fireCooldown = 0
local enemies = {}
local spawnTimer = 0
local spawnEvery = 0.8
local score = 0
local Enemy = {}
Enemy.__index = Enemy
function Enemy.new(x)
return setmetatable({ x = x, y = -20, size = 30, speed = 120 }, Enemy)
end
function Enemy:update(dt)
self.y = self.y + self.speed * dt
end
function Enemy:draw()
love.graphics.rectangle("fill", self.x - self.size / 2, self.y - self.size / 2, self.size, self.size)
end
local function overlaps(a, b)
return a.x - a.size / 2 < b.x + b.size / 2
and a.x + a.size / 2 > b.x - b.size / 2
and a.y - a.size / 2 < b.y + b.size / 2
and a.y + a.size / 2 > b.y - b.size / 2
end
function love.load()
love.graphics.setBackgroundColor(0.05, 0.05, 0.1)
math.randomseed(os.time())
ship = love.graphics.newImage("ship.png")
end
function love.update(dt)
if love.keyboard.isDown("left", "a") then
player.x = player.x - player.speed * dt
end
if love.keyboard.isDown("right", "d") then
player.x = player.x + player.speed * dt
end
local half = player.size / 2
player.x = math.max(half, math.min(love.graphics.getWidth() - half, player.x))
fireCooldown = fireCooldown - dt
if love.keyboard.isDown("space") and fireCooldown <= 0 then
table.insert(bullets, { x = player.x, y = player.y - 30, size = 6, speed = 500 })
fireCooldown = 0.15
end
for i = #bullets, 1, -1 do
local b = bullets[i]
b.y = b.y - b.speed * dt
if b.y < -20 then
table.remove(bullets, i)
end
end
spawnTimer = spawnTimer - dt
if spawnTimer <= 0 then
table.insert(enemies, Enemy.new(math.random(40, love.graphics.getWidth() - 40)))
spawnTimer = spawnEvery
end
for i = #enemies, 1, -1 do
local e = enemies[i]
e:update(dt)
if e.y > love.graphics.getHeight() + 20 then
table.remove(enemies, i)
end
end
for i = #enemies, 1, -1 do
if overlaps(enemies[i], player) then
enemies = {}
bullets = {}
score = 0
break
end
end
for i = #enemies, 1, -1 do
for j = #bullets, 1, -1 do
if overlaps(enemies[i], bullets[j]) then
table.remove(bullets, j)
table.remove(enemies, i)
score = score + 1
break
end
end
end
end
function love.draw()
love.graphics.setColor(1, 1, 1)
love.graphics.draw(ship, player.x, player.y, 0, 2, 2, ship:getWidth() / 2, ship:getHeight() / 2)
love.graphics.setColor(1, 0.9, 0.3)
for _, b in ipairs(bullets) do
love.graphics.rectangle("fill", b.x - 2, b.y - 8, 4, 16)
end
love.graphics.setColor(1, 0.3, 0.4)
for _, e in ipairs(enemies) do
e:draw()
end
love.graphics.setColor(1, 1, 1)
love.graphics.print("Score: " .. score, 10, 10)
end

Task 3: Tougher enemies

  • Give Enemy.new an hp field of 2.
  • On a hit, remove the bullet and subtract one hp. Only remove the enemy and score when hp reaches zero.
  • Note where that change went — the hp lives on the enemy, set by its constructor. Making enemies take five hits is now a single number in one place, which is what the class in Activity 2.6 bought you.
main.lua
-- main.lua
local ship
local player = { x = 400, y = 500, size = 40, speed = 300 }
local bullets = {}
local fireCooldown = 0
local enemies = {}
local spawnTimer = 0
local spawnEvery = 0.8
local score = 0
local Enemy = {}
Enemy.__index = Enemy
function Enemy.new(x)
return setmetatable({ x = x, y = -20, size = 30, speed = 120, hp = 2 }, Enemy)
end
function Enemy:update(dt)
self.y = self.y + self.speed * dt
end
function Enemy:draw()
love.graphics.rectangle("fill", self.x - self.size / 2, self.y - self.size / 2, self.size, self.size)
end
local function overlaps(a, b)
return a.x - a.size / 2 < b.x + b.size / 2
and a.x + a.size / 2 > b.x - b.size / 2
and a.y - a.size / 2 < b.y + b.size / 2
and a.y + a.size / 2 > b.y - b.size / 2
end
function love.load()
love.graphics.setBackgroundColor(0.05, 0.05, 0.1)
math.randomseed(os.time())
ship = love.graphics.newImage("ship.png")
end
function love.update(dt)
if love.keyboard.isDown("left", "a") then
player.x = player.x - player.speed * dt
end
if love.keyboard.isDown("right", "d") then
player.x = player.x + player.speed * dt
end
local half = player.size / 2
player.x = math.max(half, math.min(love.graphics.getWidth() - half, player.x))
fireCooldown = fireCooldown - dt
if love.keyboard.isDown("space") and fireCooldown <= 0 then
table.insert(bullets, { x = player.x, y = player.y - 30, size = 6, speed = 500 })
fireCooldown = 0.15
end
for i = #bullets, 1, -1 do
local b = bullets[i]
b.y = b.y - b.speed * dt
if b.y < -20 then
table.remove(bullets, i)
end
end
spawnTimer = spawnTimer - dt
if spawnTimer <= 0 then
table.insert(enemies, Enemy.new(math.random(40, love.graphics.getWidth() - 40)))
spawnTimer = spawnEvery
end
for i = #enemies, 1, -1 do
local e = enemies[i]
e:update(dt)
if e.y > love.graphics.getHeight() + 20 then
table.remove(enemies, i)
end
end
for i = #enemies, 1, -1 do
if overlaps(enemies[i], player) then
enemies = {}
bullets = {}
score = 0
break
end
end
for i = #enemies, 1, -1 do
for j = #bullets, 1, -1 do
if overlaps(enemies[i], bullets[j]) then
table.remove(bullets, j)
enemies[i].hp = enemies[i].hp - 1
if enemies[i].hp <= 0 then
table.remove(enemies, i)
score = score + 1
end
break
end
end
end
end
function love.draw()
love.graphics.setColor(1, 1, 1)
love.graphics.draw(ship, player.x, player.y, 0, 2, 2, ship:getWidth() / 2, ship:getHeight() / 2)
love.graphics.setColor(1, 0.9, 0.3)
for _, b in ipairs(bullets) do
love.graphics.rectangle("fill", b.x - 2, b.y - 8, 4, 16)
end
love.graphics.setColor(1, 0.3, 0.4)
for _, e in ipairs(enemies) do
e:draw()
end
love.graphics.setColor(1, 1, 1)
love.graphics.print("Score: " .. score, 10, 10)
end

Challenge (Optional)

  • Make a damaged enemy look damaged. Give Enemy:draw its own setColor based on self.hp, and delete the one in love.draw. More knowledge moves into the class.
  • Replace the box test with a circle test and compare how the two feel. Circles are more forgiving at the corners, which players generally prefer.
  • Track a best score that survives the reset. One variable, and it is the first thing in the game that outlives a life.
  • Count how many overlaps calls happen per frame and print it. Watching that number grow with the square of the object count is the whole of the performance lesson.
divider

Check Your Work

Gameplay with a score of 7, four enemies falling and three bullets rising

Seven kills in. Enemies take two hits, which is why the score climbs a good deal slower than you fire.

  • Bullets destroy enemies, and both disappear.
  • Each enemy takes two hits.
  • The score climbs by one per kill, not per hit.
  • Letting an enemy reach the ship clears the screen and resets the score.

If a bullet clears several enemies at once, the break is missing. If the game crashes with an index error after a hit, one of the loops runs forwards. If nothing ever collides, check that bullets have a size field — nil arithmetic would error, but a bullet created before you added the field will simply never match.

You now have a playable game. What is left is everything that makes it feel like one: lives and a real HUD, a menu and a game-over screen, sound. And before any of that, Activity 2.8 splits this file up — it is past 90 lines and about to get worse.