Back

Activity 2.8: Splitting the Game into Modules

divider

The Idea

Your main.lua is past a hundred lines and every activity from here adds more. This is the moment to split it up — before it hurts, and while the seams are still obvious.

Nothing new is coming. Activity 1.12 already taught the whole mechanism: a module is a table a file returns, and require runs that file once and hands you the table.

skynest/
main.lua
conf.lua
enemy.lua
collision.lua
ship.png
main.lua
local Enemy = require("enemy")
local collision = require("collision")

No paths, no extensions, no build step. LÖVE looks in your game folder, so a file sitting next to main.lua is found by its name alone.

What makes a good seam

The test is not "how many lines" but how much a piece needs to know about everything else. Two things in your file barely know anything:

  • overlaps takes two tables and returns a boolean. It has never heard of bullets, enemies, or the player. That is a perfect module — it could be dropped into any other game unchanged.
  • Enemy knows its own position, speed and hp, and nothing about the score or the player. It only needs love.graphics, which is global anyway.

The update loop, by contrast, touches everything at once, and that is why it stays in main.lua. Do not split something just to make the file shorter — a module that needs six things passed into it has moved the mess rather than removed it.

Two shapes of module

collision.lua returns a table of plain functions — a toolbox. You call collision.overlaps(a, b) with a dot.

enemy.lua returns a class table — a thing you make instances from. You call Enemy.new(x) with a dot and e:update(dt) with a colon, exactly as before.

Both are just tables being returned. The difference is entirely in what you put in them, which is the point Activity 1.10 kept making.

The name is yours

local Enemy = require("enemy") — the file is lowercase, the variable is capitalized, and nothing enforces either. That is a convention worth following: lowercase filenames, capitalized class variables, so a reader can tell a class from a toolbox at the point of use.

Remember that require runs the file once. If two files both require enemy.lua, they get the same table — which is exactly right for a class, and something to watch for if a module ever holds mutable state of its own.

A refactor should change nothing

This activity is the first that adds no features. When you finish it the game will look and play identically, and that is the success condition. If behavior changed, something went wrong in the move.

So make one change at a time and run after each. Moving two things at once and finding the game broken leaves you guessing which move did it — and a refactor is the one situation where you have a perfect reference to compare against.

divider

Build

Run the game first and watch it for a few seconds. That is your reference. Everything below should leave it unchanged.


Task 1: Extract collision.lua

  • Make collision.lua beside main.lua. It creates a table, puts overlaps in it, and returns it.
  • Delete the local overlaps from main.lua and require the module at the top instead.
  • Update all three call sites to collision.overlaps(...).
  • Miss one and you get a clear error naming a nil value, which is one of the few loud failures Lua gives you. Run it.
collision.lua
-- collision.lua
local collision = {}
function collision.overlaps(a, b)
return a.x - a.size / 2 < b.x + b.size / 2
and a.x + a.size / 2 > b.x - b.size / 2
and a.y - a.size / 2 < b.y + b.size / 2
and a.y + a.size / 2 > b.y - b.size / 2
end
return collision
main.lua
-- main.lua
local collision = require("collision")
local ship
local player = { x = 400, y = 500, size = 40, speed = 300 }
local bullets = {}
local fireCooldown = 0
local enemies = {}
local spawnTimer = 0
local spawnEvery = 0.8
local score = 0
local Enemy = {}
Enemy.__index = Enemy
function Enemy.new(x)
return setmetatable({ x = x, y = -20, size = 30, speed = 120, hp = 2 }, Enemy)
end
function Enemy:update(dt)
self.y = self.y + self.speed * dt
end
function Enemy:draw()
love.graphics.rectangle("fill", self.x - self.size / 2, self.y - self.size / 2, self.size, self.size)
end
function love.load()
love.graphics.setBackgroundColor(0.05, 0.05, 0.1)
math.randomseed(os.time())
ship = love.graphics.newImage("ship.png")
end
function love.update(dt)
if love.keyboard.isDown("left", "a") then
player.x = player.x - player.speed * dt
end
if love.keyboard.isDown("right", "d") then
player.x = player.x + player.speed * dt
end
local half = player.size / 2
player.x = math.max(half, math.min(love.graphics.getWidth() - half, player.x))
fireCooldown = fireCooldown - dt
if love.keyboard.isDown("space") and fireCooldown <= 0 then
table.insert(bullets, { x = player.x, y = player.y - 30, size = 6, speed = 500 })
fireCooldown = 0.15
end
for i = #bullets, 1, -1 do
local b = bullets[i]
b.y = b.y - b.speed * dt
if b.y < -20 then
table.remove(bullets, i)
end
end
spawnTimer = spawnTimer - dt
if spawnTimer <= 0 then
table.insert(enemies, Enemy.new(math.random(40, love.graphics.getWidth() - 40)))
spawnTimer = spawnEvery
end
for i = #enemies, 1, -1 do
local e = enemies[i]
e:update(dt)
if e.y > love.graphics.getHeight() + 20 then
table.remove(enemies, i)
end
end
for i = #enemies, 1, -1 do
if collision.overlaps(enemies[i], player) then
enemies = {}
bullets = {}
score = 0
break
end
end
for i = #enemies, 1, -1 do
for j = #bullets, 1, -1 do
if collision.overlaps(enemies[i], bullets[j]) then
table.remove(bullets, j)
enemies[i].hp = enemies[i].hp - 1
if enemies[i].hp <= 0 then
table.remove(enemies, i)
score = score + 1
end
break
end
end
end
end
function love.draw()
love.graphics.setColor(1, 1, 1)
love.graphics.draw(ship, player.x, player.y, 0, 2, 2, ship:getWidth() / 2, ship:getHeight() / 2)
love.graphics.setColor(1, 0.9, 0.3)
for _, b in ipairs(bullets) do
love.graphics.rectangle("fill", b.x - 2, b.y - 8, 4, 16)
end
love.graphics.setColor(1, 0.3, 0.4)
for _, e in ipairs(enemies) do
e:draw()
end
love.graphics.setColor(1, 1, 1)
love.graphics.print("Score: " .. score, 10, 10)
end

Task 2: Extract enemy.lua

  • Move the whole Enemy class into enemy.lua and return it.
  • Delete it from main.lua and require it at the top.
  • Nothing else changes. Enemy.new and e:update(dt) read exactly as they did, because the class was always just a table and now the table arrives from somewhere else.
enemy.lua
-- enemy.lua
local Enemy = {}
Enemy.__index = Enemy
function Enemy.new(x)
return setmetatable({ x = x, y = -20, size = 30, speed = 120, hp = 2 }, Enemy)
end
function Enemy:update(dt)
self.y = self.y + self.speed * dt
end
function Enemy:draw()
love.graphics.setColor(1, 0.3, 0.4)
love.graphics.rectangle("fill", self.x - self.size / 2, self.y - self.size / 2, self.size, self.size)
end
return Enemy
main.lua
-- main.lua
local Enemy = require("enemy")
local collision = require("collision")
local ship
local player = { x = 400, y = 500, size = 40, speed = 300 }
local bullets = {}
local fireCooldown = 0
local enemies = {}
local spawnTimer = 0
local spawnEvery = 0.8
local score = 0
function love.load()
love.graphics.setBackgroundColor(0.05, 0.05, 0.1)
math.randomseed(os.time())
ship = love.graphics.newImage("ship.png")
end
function love.update(dt)
if love.keyboard.isDown("left", "a") then
player.x = player.x - player.speed * dt
end
if love.keyboard.isDown("right", "d") then
player.x = player.x + player.speed * dt
end
local half = player.size / 2
player.x = math.max(half, math.min(love.graphics.getWidth() - half, player.x))
fireCooldown = fireCooldown - dt
if love.keyboard.isDown("space") and fireCooldown <= 0 then
table.insert(bullets, { x = player.x, y = player.y - 30, size = 6, speed = 500 })
fireCooldown = 0.15
end
for i = #bullets, 1, -1 do
local b = bullets[i]
b.y = b.y - b.speed * dt
if b.y < -20 then
table.remove(bullets, i)
end
end
spawnTimer = spawnTimer - dt
if spawnTimer <= 0 then
table.insert(enemies, Enemy.new(math.random(40, love.graphics.getWidth() - 40)))
spawnTimer = spawnEvery
end
for i = #enemies, 1, -1 do
local e = enemies[i]
e:update(dt)
if e.y > love.graphics.getHeight() + 20 then
table.remove(enemies, i)
end
end
for i = #enemies, 1, -1 do
if collision.overlaps(enemies[i], player) then
enemies = {}
bullets = {}
score = 0
break
end
end
for i = #enemies, 1, -1 do
for j = #bullets, 1, -1 do
if collision.overlaps(enemies[i], bullets[j]) then
table.remove(bullets, j)
enemies[i].hp = enemies[i].hp - 1
if enemies[i].hp <= 0 then
table.remove(enemies, i)
score = score + 1
end
break
end
end
end
end
function love.draw()
love.graphics.setColor(1, 1, 1)
love.graphics.draw(ship, player.x, player.y, 0, 2, 2, ship:getWidth() / 2, ship:getHeight() / 2)
love.graphics.setColor(1, 0.9, 0.3)
for _, b in ipairs(bullets) do
love.graphics.rectangle("fill", b.x - 2, b.y - 8, 4, 16)
end
love.graphics.setColor(1, 0.3, 0.4)
for _, e in ipairs(enemies) do
e:draw()
end
love.graphics.setColor(1, 1, 1)
love.graphics.print("Score: " .. score, 10, 10)
end

Task 3: Let the enemy own its color

  • Move the setColor(1, 0.3, 0.4) out of love.draw and into Enemy:draw.
  • Now main.lua does not know what color an enemy is, and could not tell you if you asked. Making a damaged enemy flash is a change to one file.
  • This is the real prize of the activity. The split was mechanical; moving knowledge across the seam is what makes the split worth having.
  • Watch out for the state machine from Activity 2.2 — the enemy now leaves the color set to red when it finishes, so the score text needs its setColor(1, 1, 1) more than ever.
main.lua
-- main.lua
local Enemy = require("enemy")
local collision = require("collision")
local ship
local player = { x = 400, y = 500, size = 40, speed = 300 }
local bullets = {}
local fireCooldown = 0
local enemies = {}
local spawnTimer = 0
local spawnEvery = 0.8
local score = 0
function love.load()
love.graphics.setBackgroundColor(0.05, 0.05, 0.1)
math.randomseed(os.time())
ship = love.graphics.newImage("ship.png")
end
function love.update(dt)
if love.keyboard.isDown("left", "a") then
player.x = player.x - player.speed * dt
end
if love.keyboard.isDown("right", "d") then
player.x = player.x + player.speed * dt
end
local half = player.size / 2
player.x = math.max(half, math.min(love.graphics.getWidth() - half, player.x))
fireCooldown = fireCooldown - dt
if love.keyboard.isDown("space") and fireCooldown <= 0 then
table.insert(bullets, { x = player.x, y = player.y - 30, size = 6, speed = 500 })
fireCooldown = 0.15
end
for i = #bullets, 1, -1 do
local b = bullets[i]
b.y = b.y - b.speed * dt
if b.y < -20 then
table.remove(bullets, i)
end
end
spawnTimer = spawnTimer - dt
if spawnTimer <= 0 then
table.insert(enemies, Enemy.new(math.random(40, love.graphics.getWidth() - 40)))
spawnTimer = spawnEvery
end
for i = #enemies, 1, -1 do
local e = enemies[i]
e:update(dt)
if e.y > love.graphics.getHeight() + 20 then
table.remove(enemies, i)
end
end
for i = #enemies, 1, -1 do
if collision.overlaps(enemies[i], player) then
enemies = {}
bullets = {}
score = 0
break
end
end
for i = #enemies, 1, -1 do
for j = #bullets, 1, -1 do
if collision.overlaps(enemies[i], bullets[j]) then
table.remove(bullets, j)
enemies[i].hp = enemies[i].hp - 1
if enemies[i].hp <= 0 then
table.remove(enemies, i)
score = score + 1
end
break
end
end
end
end
function love.draw()
love.graphics.setColor(1, 1, 1)
love.graphics.draw(ship, player.x, player.y, 0, 2, 2, ship:getWidth() / 2, ship:getHeight() / 2)
love.graphics.setColor(1, 0.9, 0.3)
for _, b in ipairs(bullets) do
love.graphics.rectangle("fill", b.x - 2, b.y - 8, 4, 16)
end
for _, e in ipairs(enemies) do
e:draw()
end
love.graphics.setColor(1, 1, 1)
love.graphics.print("Score: " .. score, 10, 10)
end

Challenge (Optional)

  • Extract bullet.lua the same way. Bullets are plain tables, so decide whether they deserve a class or just a bullet.new(x, y) factory function — both are defensible and the reasoning matters more than the answer.
  • Add collision.circles(a, b) alongside overlaps and switch between them by changing one word at the call sites. That is the payoff of a toolbox module.
  • Give enemy.lua a second class that inherits from Enemy, using the bonus inheritance page, and return both from the file in one table. Then require it as local enemies = require('enemy') and use enemies.Fast.new(x).
divider

Check Your Work

The game plays exactly as it did before. Same speed, same spawn rate, same two hits per enemy, same reset. Nothing about it should feel different.

What has changed is the shape of the project:

  • Five files instead of three.
  • main.lua holds the game loop and the state it coordinates, and nothing else.
  • collision.lua could be copied into a completely different game and would work.

If you get an error naming a nil value, a call site still uses the old bare name. If enemies vanish, check that enemy.lua ends with return Enemy — a module that returns nothing gives you a nil with no complaint at the point of require.