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.
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 / 2endThis 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.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.< 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.
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 endendBoth 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.
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.
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.
Same file. This is the one that makes it a game.
overlaps function above love.load.-- main.lua
local shiplocal player = { x = 400, y = 500, size = 40, speed = 300 }local bullets = {}local fireCooldown = 0local enemies = {}local spawnTimer = 0local spawnEvery = 0.8local 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 * dtend
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 / 2end
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 endend
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-- main.lua
local shiplocal player = { x = 400, y = 500, size = 40, speed = 300 }local bullets = {}local fireCooldown = 0local enemies = {}local spawnTimer = 0local spawnEvery = 0.8local 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 * dtend
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 / 2end
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 endend
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)endhp field of 2.-- main.lua
local shiplocal player = { x = 400, y = 500, size = 40, speed = 300 }local bullets = {}local fireCooldown = 0local enemies = {}local spawnTimer = 0local spawnEvery = 0.8local 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 * dtend
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 / 2end
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 endend
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)endEnemy:draw its own setColor based on self.hp, and delete the one in love.draw. More knowledge moves into the class.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.
Seven kills in. Enemies take two hits, which is why the score climbs a good deal slower than you fire.
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.