Learning

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.

Learn

Circle collision and safe mutation.

Build

Collectible signals and falling meteors.

Prove it

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:

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.