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.
Spawn timers and entity lifecycles.
A field of temporary gold signals.
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.
- Calculate
local alpha = math.min(1, item.life / 2)in the draw loop. - Use
alpha * 0.18for the glow. - Use
alphafor 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.