Learning

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.

Learn

Modules, ownership, and dependency direction.

Build

The complete five-file Orbit Runner project.

Prove it

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:

orbit-runner/ ├── conf.lua window and save identity ├── main.lua LÖVE callback bridge ├── game.lua run state, rules, effects, UI ├── player.lua player construction, movement, drawing └── spawner.lua signal and meteor construction

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

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

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:

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:

Decide module ownership before coding:

  1. spawner.lua decides which valid signal table to construct.
  2. game.lua decides the consequence of collecting each kind.
  3. game.lua also 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.