Lua + LÖVE: Make a Game · Lesson 10 · 60 min
Assemble the capstone
Split responsibilities into Lua modules and study the complete, runnable Orbit Runner project.
Modules, ownership, and dependency direction.
The complete five-file Orbit Runner project.
Add one feature in the correct module.
A single main.lua was useful while the game was small. Now its responsibilities compete for attention: boot callbacks, state transitions, player movement, spawning, rules, drawing, effects, and sound.
Modules make those boundaries visible. They do not automatically create good architecture; each file still needs a clear reason to change.
The finished structure
Create or download this structure:
You can compare your version with the complete source:
The archive is runnable with LÖVE 11.5. The source files are intentionally small enough to read in one sitting.
A module returns its public table
player.lua begins by creating a local table and ends by returning it:
local Player = {}
function Player.new()
return {
x = love.graphics.getWidth() / 2,
y = love.graphics.getHeight() * 0.72,
radius = 14,
speed = 260,
invulnerable = 0,
}
end
-- Player.update and Player.draw are defined here.
return Player
Another file loads that returned table with require:
local Player = require("player")
local player = Player.new()
Player.update(player, dt)
require("player") looks for player.lua, runs it the first time, caches its returned value, and reuses that value on later calls.
For a nested path such as lib/collision.lua, write:
local Collision = require("lib.collision")
Use dots in the module name and omit .lua.
Avoid the old module(...) style
Some older Lua 5.1 tutorials use the global module function. Returning an explicit local table is clearer, avoids hidden globals, and works across modern Lua versions.
Keep the callbacks thin
The finished main.lua is a bridge between LÖVE and the game module:
local Game = require("game")
function love.load()
love.graphics.setDefaultFilter("nearest", "nearest")
Game.load()
end
function love.update(dt)
Game.update(math.min(dt, 1 / 15))
end
function love.draw()
Game.draw()
end
function love.keypressed(key)
Game.keypressed(key)
end
The dt clamp protects the simple simulation from a giant leap after a debugger pause or window drag. It is not a substitute for a fixed-timestep physics loop, but it is a practical guard for this arcade game.
main.lua knows LÖVE’s callback names and the Game interface. It knows nothing about score, meteors, or particles.
Assign one owner to each decision
The module relationships stay simple:
main.lua
│
▼
game.lua ─────► player.lua
│
└──────────► spawner.lua
game.luaowns global run state and collision consequences.player.luaowns player construction, movement math, and appearance.spawner.luaowns valid initial data for spawned objects.- Neither
player.luanorspawner.luarequiresgame.lua.
That last point prevents a circular dependency. Lower-level modules provide capabilities; the game module composes them into rules.
Read game.lua in passes
Do not try to absorb the whole file top to bottom at once. Read by concern.
Pass 1: persistent state
At the top, the Game table holds values that must survive between callback calls:
local Game = {
state = "title",
score = 0,
highScore = 0,
lives = 3,
stars = {},
meteors = {},
particles = {},
-- timers and resources continue...
}
Helper functions remain local unless another module needs them. circlesOverlap, makeTone, burst, and resetGame are implementation details.
Pass 2: lifecycle callbacks
Game.loadcreates fonts, tones, save data, and backdrop points once.Game.updateadvances effects, returns early outside play, then updates the run.Game.drawlayers backdrop, transformed world, stable HUD, and overlays.Game.keypressedowns discrete state transitions.
Pass 3: event sites
Find the two calls to circlesOverlap. Follow each branch through state change and feedback:
signal overlap → score +1 → burst → tone → remove signal
meteor overlap → life -1 → invulnerability → shake → burst → tone → remove meteor
This is the shortest path through the game’s design.
Dot calls versus colon calls
The project uses both forms:
Player.update(Game.player, dt) -- dot: pass player explicitly
source:play() -- colon: pass source as hidden first argument
Colon syntax is shorthand:
source:play()
-- means approximately:
source.play(source)
Define a colon method with a colon too:
function ship:damage(amount)
self.lives = self.lives - amount
end
This course keeps entity data and module functions separate, so Player.update(player, dt) clearly shows which table is mutated. LÖVE objects already expose methods, so source:play() follows their API.
Run the complete project
If you downloaded the individual files, put them together in one folder. From its parent directory:
love orbit-runner
Or run the archive directly:
love orbit-runner.love
Controls:
- WASD or arrows move;
- Enter starts and restarts;
- Escape quits.
First verify the original behavior. Then compare one module at a time with your monolithic version. Notice what moved without changing what it does.
Capstone modification
Add a repair signal with these rules:
- it is cyan, has
kind = "repair", and appears about one time in six; - collecting it adds one life up to a maximum of three;
- ordinary gold signals still add score;
- its particles use cyan and it plays the collect tone;
- it expires like any other signal.
Decide module ownership before coding:
spawner.luadecides which valid signal table to construct.game.luadecides the consequence of collecting each kind.game.luaalso draws the different signal color for now.
Implementation outline
In Spawner.star, choose a kind and color:
local repair = love.math.random(1, 6) == 1
return {
-- existing fields...
kind = repair and "repair" or "score",
color = repair and {0.33, 0.91, 0.84} or {1, 0.83, 0.47},
}
When overlap occurs:
if star.kind == "repair" then
Game.lives = math.min(3, Game.lives + 1)
else
Game.score = Game.score + 1
end
Use star.color in drawing and in burst.
Checkpoint
You have completed the capstone when the original game still works, the repair signal follows all five rules, and you can justify why each code change belongs in its chosen module.