Lua + LÖVE: Make a Game · Lesson 07 · 50 min
Turn overlap into game rules
Detect circle collisions and convert contact into scoring, damage, difficulty, and recovery time.
Circle collision and safe mutation.
Collectible signals and falling meteors.
Score, take damage, and recover fairly.
Until now, the player and signals have ignored one another. A game emerges when spatial relationships cause rules: touching gold raises the score; touching red costs a life.
Circle against circle
Two circles overlap when the distance between their centers is smaller than their combined radii.
center distance < radius A + radius B
Distance normally needs a square root. Both sides are nonnegative, so comparing the squared values gives the same answer with less work:
local function circlesOverlap(a, b)
local dx = a.x - b.x
local dy = a.y - b.y
local radii = a.radius + b.radius
return dx * dx + dy * dy < radii * radii
end
This function works for any two tables with x, y, and radius fields. It does not need to know whether they are players, signals, or meteors.
Separate detection from response
circlesOverlap answers one geometric question and changes nothing. The caller decides whether overlap means collect, damage, bounce, or something else. Reusable math stays separate from game-specific rules.
Collect signals
Add score state near the top of main.lua:
local score = 0
In the backward signal update loop, handle collection before expiry:
for index = #signals, 1, -1 do
local item = signals[index]
item.life = item.life - dt
item.phase = item.phase + dt * 4
if circlesOverlap(player, item) then
score = score + 1
table.remove(signals, index)
elseif item.life <= 0 then
table.remove(signals, index)
end
end
The elseif matters. Once an item has been removed for collection, we must not try to remove the same index again for expiry.
Draw the score in love.draw:
love.graphics.setColor(1, 1, 1)
love.graphics.print("Score " .. score, 20, 18)
Run directly through a gold signal. The table shrinks and the score rises.
Add meteors
Meteors use the same collection lifecycle with different behavior. Add state:
local meteors = {}
local meteorTimer = 0.7
local lives = 3
Add a constructor:
local function newMeteor()
local radius = love.math.random(11, 22)
return {
x = love.math.random(radius, love.graphics.getWidth() - radius),
y = -radius,
radius = radius,
speed = love.math.random(135, 205) + score * 1.5,
drift = love.math.random(-32, 32),
}
end
The meteor begins just above the screen and falls downward. Score makes later meteors a little faster.
Spawn and update them inside love.update:
meteorTimer = meteorTimer - dt
if meteorTimer <= 0 then
table.insert(meteors, newMeteor())
meteorTimer = meteorTimer + math.max(0.3, 1.05 - score * 0.018)
end
for index = #meteors, 1, -1 do
local meteor = meteors[index]
meteor.x = meteor.x + meteor.drift * dt
meteor.y = meteor.y + meteor.speed * dt
if meteor.y - meteor.radius > love.graphics.getHeight() then
table.remove(meteors, index)
end
end
There are now two difficulty levers: meteors move faster and the interval becomes shorter. math.max(0.3, ...) keeps the interval from reaching zero or becoming negative.
Draw meteors before the player:
for _, meteor in ipairs(meteors) do
love.graphics.setColor(0.98, 0.31, 0.44)
love.graphics.circle("fill", meteor.x, meteor.y, meteor.radius)
love.graphics.setColor(0.42, 0.08, 0.14)
love.graphics.circle(
"fill",
meteor.x - meteor.radius * 0.25,
meteor.y - meteor.radius * 0.2,
meteor.radius * 0.28
)
end
Make damage readable
If overlap removed one life every frame, a single meteor could erase all three lives almost instantly. Give the player a short invulnerability timer.
Add the field:
local player = {
-- existing fields...
invulnerable = 0,
}
Count it down once per update:
player.invulnerable = math.max(0, player.invulnerable - dt)
Expand the meteor loop:
for index = #meteors, 1, -1 do
local meteor = meteors[index]
meteor.x = meteor.x + meteor.drift * dt
meteor.y = meteor.y + meteor.speed * dt
if meteor.y - meteor.radius > love.graphics.getHeight() then
table.remove(meteors, index)
elseif player.invulnerable <= 0 and circlesOverlap(player, meteor) then
lives = lives - 1
player.invulnerable = 1.15
table.remove(meteors, index)
end
end
Visual feedback must communicate the temporary rule. Wrap the player’s draw calls in this condition:
local playerVisible =
player.invulnerable <= 0 or
math.floor(player.invulnerable * 12) % 2 == 1
if playerVisible then
-- existing player drawing calls
end
The modulo operator % alternates between even and odd values as the timer falls, producing a blink.
Draw lives on the right:
love.graphics.setColor(1, 1, 1)
love.graphics.printf(
"Lives " .. lives,
0,
18,
love.graphics.getWidth() - 20,
"right"
)
Collision shape versus visual shape
Your player has an outer decorative ring, but its collision radius is only player.radius. This slightly forgiving hitbox usually feels better than pixel-perfect contact.
Collision values are design tools:
- make dangerous hitboxes a little smaller than their art;
- make collectible hitboxes a little larger;
- draw temporary hitbox outlines while tuning.
Add a debug flag near the top:
local debugHitboxes = false
At the end of love.draw:
if debugHitboxes then
love.graphics.setColor(0.2, 1, 0.4)
love.graphics.circle("line", player.x, player.y, player.radius)
for _, meteor in ipairs(meteors) do
love.graphics.circle("line", meteor.x, meteor.y, meteor.radius)
end
end
Toggle the variable manually while tuning. In the next lesson you will toggle behavior with key events.
Your turn: close call
Make meteors use a collision radius 20% smaller than their drawn radius without changing the reusable collision function. Hint: give each meteor a separate hitRadius, then decide how to adapt the object passed to the function—or generalize the field name.
A simple solution
Store the smaller value as collisionRadius and let the collision function prefer it:
local function circlesOverlap(a, b)
local dx, dy = a.x - b.x, a.y - b.y
local radiusA = a.collisionRadius or a.radius
local radiusB = b.collisionRadius or b.radius
local radii = radiusA + radiusB
return dx * dx + dy * dy < radii * radii
end
Then add collisionRadius = radius * 0.8 in newMeteor.
Checkpoint
Collecting gold must add exactly one point. A meteor hit must remove exactly one life, remove that meteor, and visibly blink the player during recovery. Off-screen meteors must leave the table.