Learning

Lua + LÖVE: Make a Game · Lesson 06 · 45 min

Spawn a living world

Use timers and table collections to create, update, draw, and retire many game objects.

Learn

Spawn timers and entity lifecycles.

Build

A field of temporary gold signals.

Prove it

Run forever without the table growing forever.

A game world feels alive when objects enter, change, and leave. Each signal in Orbit Runner follows one lifecycle:

spawn → update every frame → draw every frame → expire or get collected → remove

Tables hold the objects; a timer decides when to make more.

Replace one signal with a collection

Remove the single signal table. Add an empty collection and timer near the top of main.lua:

local signals = {}
local signalTimer = 0
local signalInterval = 1.2

Create a constructor:

local function newSignal()
  local margin = 36
  local width, height = love.graphics.getDimensions()

  return {
    x = love.math.random(margin, width - margin),
    y = love.math.random(margin + 34, height - margin),
    radius = 8,
    life = 8,
    phase = love.math.random() * math.pi * 2,
  }
end

Calling love.graphics.getDimensions() returns two values. Lua assigns them to width and height in order.

The phase starts at a random point in its cycle so every signal does not pulse in lockstep.

Turn elapsed time into an event

In love.update, remove the old single-signal update and add:

signalTimer = signalTimer - dt

if signalTimer <= 0 and #signals < 3 then
  table.insert(signals, newSignal())
  signalTimer = signalTimer + signalInterval
end

The timer starts at zero, so a signal appears immediately. It then counts down in real seconds.

Adding the interval rather than assigning it preserves a small amount of overshoot. If one frame ends with signalTimer == -0.01, the next countdown begins at 1.19, keeping the average rhythm close to 1.2 seconds.

The collection cap prevents the screen from filling with signals. It also illustrates a design rule: every source of creation should have a corresponding limit or removal path.

Update and retire every signal

Continue inside love.update:

for index = #signals, 1, -1 do
  local item = signals[index]
  item.life = item.life - dt
  item.phase = item.phase + dt * 4

  if item.life <= 0 then
    table.remove(signals, index)
  end
end

We walk backward because removal changes indexes. This is the collection pattern from Lesson 3 now running every frame.

Draw the collection

Remove the old signal drawing block. In love.draw, after the backdrop and before the player, add:

for _, item in ipairs(signals) do
  local pulse = math.sin(item.phase) * 2

  love.graphics.setColor(1, 0.83, 0.47, 0.18)
  love.graphics.circle(
    "fill",
    item.x,
    item.y,
    item.radius + 9 + pulse
  )

  love.graphics.setColor(1, 0.83, 0.47)
  love.graphics.circle(
    "fill",
    item.x,
    item.y,
    item.radius + pulse * 0.25
  )
end

This loop only reads the collection. It does not age, spawn, or remove anything. That separation makes bugs easier to locate.

Give the timer a visible instrument

Debug values become easier to understand when you draw them. At the bottom of love.draw, temporarily add:

love.graphics.setColor(1, 1, 1)
love.graphics.print("Signals: " .. #signals, 20, 42)
love.graphics.print(
  string.format("Next: %.2f", math.max(0, signalTimer)),
  20,
  66
)

string.format("%.2f", value) formats a number with two digits after the decimal point. Watch the timer count down, reset, and stop spawning while three signals exist.

A timer is just state

There is no special timer object here. A number stores remaining seconds, update subtracts dt, and a condition turns crossing zero into a spawn. The same pattern drives cooldowns, animation, invulnerability, and delayed events.

Choose randomness deliberately

LÖVE’s random generator supports useful forms:

love.math.random()        -- decimal in [0, 1)
love.math.random(10)      -- integer from 1 through 10
love.math.random(20, 40)  -- integer from 20 through 40

Randomness should choose within meaningful constraints. The signal constructor keeps objects away from edges and below the HUD, making every generated position usable.

For repeatable debugging, seed the generator to a fixed value once in love.load:

love.math.setRandomSeed(12345)

Remove that line when you want varied sessions again. Reproducible randomness is valuable when a bug appears only for one sequence of spawns.

Challenge: fade before expiry

Make a signal become transparent during its final two seconds.

  1. Calculate local alpha = math.min(1, item.life / 2) in the draw loop.
  2. Use alpha * 0.18 for the glow.
  3. Use alpha for the core.

This works because life / 2 falls from 1 to 0 during the last two seconds, while math.min caps earlier values at 1.

Checkpoint

Leave the game running for at least 30 seconds. The count should never exceed three, expired signals should disappear, and replacements should continue appearing. If the count only grows, find the missing lifecycle step.