Lua + LÖVE: Make a Game · Lesson 03 · 45 min
Build with functions, tables, and loops
Group behavior into functions and model collections of game objects with Lua tables.
Functions, table fields, arrays, and loops.
A tiny signal-collection simulation.
Remove expired objects safely.
Lua has one general-purpose data structure: the table. A table can act like a list, a dictionary, a record, an object, or some mixture of them. Combined with functions and loops, it is enough to model most of a small game.
Functions turn a recipe into a tool
Define a function with parameters, then return a result:
local function distance(x1, y1, x2, y2)
local dx = x2 - x1
local dy = y2 - y1
return math.sqrt(dx * dx + dy * dy)
end
local gap = distance(10, 20, 13, 24)
print(gap) -- 5
Parameters and variables declared inside the function are local to that call. A function stops at return and can return more than one value:
local function direction(fromX, fromY, toX, toY)
return toX - fromX, toY - fromY
end
local dx, dy = direction(10, 20, 16, 17)
print(dx, dy) -- 6 -3
Functions are values in Lua. This syntax:
local function greet(name)
return "Hello, " .. name
end
is convenient shorthand for assigning an anonymous function:
local greet = function(name)
return "Hello, " .. name
end
Tables as records
Group related player values in one table:
local player = {
x = 480,
y = 270,
radius = 14,
speed = 260,
lives = 3,
}
Read and change named fields with dot syntax:
print(player.x) -- 480
player.x = player.x + 20
player.lives = player.lives - 1
player.x is shorthand for player["x"]. Bracket syntax lets you use a field name stored in a variable:
local field = "lives"
print(player[field]) -- 2
A constructor function makes fresh tables with the same shape:
local function newSignal(x, y)
return {
x = x,
y = y,
radius = 8,
life = 5,
}
end
local first = newSignal(120, 90)
local second = newSignal(640, 320)
first and second refer to different tables. Changing first.life does not change second.life.
Tables as arrays
An array-style table stores an ordered collection:
local signals = {
newSignal(120, 90),
newSignal(640, 320),
}
Lua arrays conventionally start at 1, not 0:
print(signals[1].x) -- 120
print(#signals) -- 2, the array length
Add and remove values with the table library:
table.insert(signals, newSignal(300, 200))
table.remove(signals, 2)
Assigning nil to a field removes that field. Avoid leaving holes in the middle of array-style tables; # and ipairs are designed for a contiguous sequence.
Repeat with loops
A numeric for loop knows its index:
for index = 1, 5 do
print(index)
end
Use ipairs when order matters and the table is an array:
for index, signal in ipairs(signals) do
print(index, signal.x, signal.y)
end
Use pairs for named fields where order is not meaningful:
for key, value in pairs(player) do
print(key, value)
end
A while loop repeats as long as its condition remains true:
local countdown = 3
while countdown > 0 do
print(countdown)
countdown = countdown - 1
end
Make sure a while loop changes something relevant to its condition, or it can run forever.
Update a collection
Game objects often have a remaining lifetime. An update function can subtract elapsed time from each one:
local function updateSignals(signals, dt)
for _, signal in ipairs(signals) do
signal.life = signal.life - dt
end
end
The underscore is an ordinary variable name used by convention to mean “I will not use this value.” Here we ignore the index.
Removing items while walking forward through an array is dangerous. Removal shifts later items left, so the loop can skip one:
-- Fragile: a removal changes the indexes still ahead of us.
for index, signal in ipairs(signals) do
if signal.life <= 0 then
table.remove(signals, index)
end
end
Walk backward when removal is possible:
local function removeExpired(signals)
for index = #signals, 1, -1 do
if signals[index].life <= 0 then
table.remove(signals, index)
end
end
end
Removing index 4 cannot change indexes 1 through 3, so the unvisited part stays stable.
The collection pattern
Small LÖVE games use this constantly: store similar entities in an array, insert new tables when they spawn, update or draw every item in a loop, and loop backward when deleting.
Build a tiny simulation
This program uses no LÖVE features, so it can run with lua practice.lua:
local function newSignal(name, life)
return {name = name, life = life}
end
local signals = {
newSignal("alpha", 1.0),
newSignal("beta", 2.5),
newSignal("gamma", 0.5),
}
local function step(seconds)
print("Advancing " .. seconds .. " seconds")
for index = #signals, 1, -1 do
local signal = signals[index]
signal.life = signal.life - seconds
if signal.life <= 0 then
print(" expired: " .. signal.name)
table.remove(signals, index)
else
print(" active: " .. signal.name .. " (" .. signal.life .. ")")
end
end
end
step(0.75)
step(0.75)
print("Signals left: " .. #signals)
Predict which signals remain after each step, then run it.
Tables are references
Variables hold references to tables rather than copying their contents:
local a = {score = 0}
local b = a
b.score = 10
print(a.score) -- 10
Both names point to the same table. This is useful when a function receives a player and changes it:
local function heal(entity)
entity.lives = entity.lives + 1
end
heal(player)
The function does not need to return the table because it mutates the shared object. Mutation is convenient, but make it obvious through names and structure.
Challenge: cap the collection
Change the simulation so a spawnSignal(name, life) function inserts a signal only when fewer than five are active. Return true when it spawns and false when the collection is full.
One solution
local function spawnSignal(name, life)
if #signals >= 5 then
return false
end
table.insert(signals, newSignal(name, life))
return true
end
Checkpoint
You are ready when you can create a table with named fields, add it to an array, loop over the array, and explain why removal loops usually run backward.
Keep the Lua quick reference nearby. In the next lesson, these language pieces become a real-time scene.