Lua Tables, Functions, and Metatables
Lua's flexible tables and first-class functions express records, sequences, callbacks, methods, and lookup behavior.
Cheatsheet for Lesson 11.3
A one-page summary of “Lua Tables, Functions, and Metatables” you can keep beside your notes. Plain Markdown.
Lesson 11.3 — Lua Tables, Functions, and Metatables: cheatsheet
- Remember: Check tables again when they cross back into the host, because a script can change fields after an earlier check.
- Also useful: A closure carries a private notebook with its function: later calls read and update the remembered counter without making it global.
Key terms
ipairs: The length operator andipairsare easiest to reason about when the script preserves that shape.- metatable: A metatable is a table you attach to another table to say what should happen when an ordinary operation on it does not work out — a key that is missing, an addition, a comparison.
__index: What does the work is the__indexkey inside that metatable: when a lookup on the original table fails, Lua checks the metatable for__index, and if it finds a table there, repeats the lookup in that table.
Review
- Q: Rust vector element
0becomes which conventional Lua sequence key? A: 1 - Q: Some scripting languages use one built-in structure for lists, records and maps. What is the idea behind that design? A: A single key-to-value mapping is flexible enough to play all three roles
Lua tables represent arrays, records, and maps
Section titled “Lua tables represent arrays, records, and maps”A Lua table maps keys to values. These are all tables:
local list = { "sword", "shield", "potion" }local position = { x = 10.0, y = 5.5, z = 2.0 }local by_id = { [7] = "archer", [12] = "mage" }The first table uses integer keys 1, 2, and 3. Lua sequences conventionally start at one, not zero.
1 Every entry is a key and a value
Key → value1"sword"2"shield""gold"25Lua gave the listed items the keys 1 and 2. "gold" is just another key.
2 ipairs and # use only the keys 1, 2, 3, …
Key1visited2visited3missing: stop"gold"skippedipairs stops at the missing key 3 and never sees "gold"; #bag is 2.
One table, three key–value pairs.
A table is not “an array that sometimes has names.” It is one mapping abstraction that accepts several kinds of keys. Sequence operations add conventions on top: positive integer keys beginning at one, usually without holes. The length operator and ipairs are easiest to reason about when the script preserves that shape.
Before crossing the host/Lua boundary, name the promised shape:
entity sequence: keys 1..n, every value is an entity tableentity record: named keys id, alive, and positionlookup map: arbitrary entity IDs mapped to records; order is not promisedThe same Lua table implementation can represent all three, but host validation and the choice of iteration method differ.
print(list[1]) -- sword ✅print(list[0]) -- nilWhen the host turns a Vec into a Lua sequence, it must add one to the zero-based index. The implementation in lua-labs/src/main.rs does exactly that.
How a map finds a key
Section titled “How a map finds a key”Inside, a Lua table has two parts: an array part for the keys 1, 2, 3, … of a sequence, and a hash part for other keys. The hash part is a hash table. It turns a key into a number, its hash, to choose a starting slot. A lookup checks the key there and, if another key collided with it, follows the collision link until it finds the requested key or proves it absent. Hashing usually narrows a lookup to a small part of the table; it does not promise one comparison every time.
Suppose the hash part has 4 slots, numbered 0 to 3, and the string "y" hashes
to 22. Lua keeps the remainder after dividing by the slot count: 22 ÷ 4 is 5
remainder 2, so y goes in slot 2. Slot counts are powers of two, so that
remainder is simply the hash’s lowest bits: 22 is 10110 in binary, and its
last two bits, 10, are 2.
position with 4 slots, assuming the key y hashes to 22. A real collision can place a key in another slot and link it from its starting slot.Two things follow. The Lua language promises no useful order from pairs; it
does not reproduce the order in which the script wrote fields. Lua 5.4’s
implementation traverses its storage, and a table resize can change that
order. Two keys can also hash to the same starting slot, a collision; Lua
links the displaced key so lookups still work, though long collision chains
cost extra comparisons. Lesson 3.10
shows what the same idea looks like in a compiled C++ program.
Iterate according to the shape
Section titled “Iterate according to the shape”Use ipairs for a dense integer sequence:
for index, item in ipairs(list) do print(index, item)endipairs starts at key 1 and stops at the first missing key. A hole, a
missing key in the middle of a sequence, therefore ends the loop early:
list[2] = nil -- { "sword", "shield", "potion" } now has a hole at key 2ipairsipairs stops herelist[2] = nil, ipairs never sees potion. The length operator is no safer: #list may return 1 or 3, because each is a border, a key whose value is present while the next key's value is missing, and Lua promises only that # returns some border.Use pairs when keys are not a simple sequence:
for key, value in pairs(position) do print(key, value)endDo not rely on a meaningful order from pairs: it follows the hash part’s slot order, as the previous section showed. If order matters, store an explicit sequence or sort the keys.
A sequence hole ends ipairs early. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
items = { [1]="A", [2]="B", [3]="C" }
if selected: items[2] = nil
index = 1
while items[index] exists: visit it
index = index + 1; repeat
stop at the first missing indexResult: visited A, B, C. The next missing key is 4.
Final values: hole = present; visited = A, B, C; index = 4.
Read every step of the lesson’s example
Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.
hole = present.
Step 2, line 2. Setting a sequence entry to nil removes that key.
hole = present; visited = ; index = 1.
Memory: 1 = A; 2 = B; 3 = C.
Step 3, line 4. Visit key1: A.
hole = present; visited = A; index = 1.
Memory: 1 = A; 2 = B; 3 = C.
Step 4, line 5. Advance to the next numeric key.
hole = present; visited = A; index = 2.
Memory: 1 = A; 2 = B; 3 = C.
Step 5, line 4. Visit key2: B.
hole = present; visited = A, B; index = 2.
Memory: 1 = A; 2 = B; 3 = C.
Step 6, line 5. Advance to the next numeric key.
hole = present; visited = A, B; index = 3.
Memory: 1 = A; 2 = B; 3 = C.
Step 7, line 4. Visit key3: C.
hole = present; visited = A, B, C; index = 3.
Memory: 1 = A; 2 = B; 3 = C.
Step 8, line 5. Advance to the next numeric key.
hole = present; visited = A, B, C; index = 4.
Memory: 1 = A; 2 = B; 3 = C.
Step 9, line 6. Result: visited A, B, C. The next missing key is 4.
hole = present; visited = A, B, C; index = 4.
Memory: 1 = A; 2 = B; 3 = C.
Functions are values
Section titled “Functions are values”Suppose a script must rank targets without choosing one yet. The two input
tables below are positions. The function subtracts their coordinates and
returns squared distance, which preserves the nearest-to-farthest order without
calculating a square root. Assigning the function to scoring_rule copies a
callable value, so the function can later be passed to other code.
local function squared_distance(a, b) local dx = b.x - a.x local dy = b.y - a.y return dx * dx + dy * dyend
local scoring_rule = squared_distanceprint(scoring_rule({ x = 0, y = 0 }, { x = 3, y = 4 })) -- 25The points (0, 0) and (3, 4) differ by 3 and 4; 3² + 4² is 25, the printed
result. A function can also be stored in a table, passed to another function,
and returned. This is why a game can register callbacks such as
on_turn_started.
Closures remember surrounding values
Section titled “Closures remember surrounding values”An action may need a cooldown that survives between calls without becoming a
global variable. make_cooldown takes the wait length and returns a function
that remembers its own remaining counter:
local function make_cooldown(required_ticks) local remaining = 0
return function(triggered) if remaining > 0 then remaining = remaining - 1 return false end if triggered then remaining = required_ticks return true end return false endend
local ready_once = make_cooldown(3)The returned function keeps access to remaining after make_cooldown has
returned. That remembered environment is a closure.
The practical payoff is that each call to make_cooldown produces its own
private remaining. Two cooldowns created this way count down independently,
with no shared global and no counter passed around by hand:
local attack_ready = make_cooldown(3)local heal_ready = make_cooldown(10) -- separate remaining, separate countdownFollowing attack_ready(true) through five calls in a row shows the private
remaining at work. The first call finds remaining at 0, so it fires and
sets remaining to 3; each later call finds it above 0, subtracts 1, and
refuses, until it is back at 0 and the next call fires again:
attack_ready(true) call and the value remaining holds afterward. heal_ready owns a different remaining, so none of these calls moves its countdown.That is why closures suit bot code so well: each piece of timed behavior can own its own state without the rest of the script being able to reach in and disturb it.
The colon supplies self
Section titled “The colon supplies self”Lua uses : to pass the table on the left as an extra first argument. The
first call below passes bot explicitly, while the second passes Bot because
that is the table before the colon. Watch which table receives state = "plan".
local Bot = {}
function Bot:set_state(next_state) self.state = next_stateend
local bot = { state = "observe" }Bot.set_state(bot, "plan") -- dot form: pass bot explicitlyBot:set_state("plan") -- colon form: Bot becomes selfColon syntax is convenient, but the last line above changes the Bot table itself, not bot. To share methods through a prototype, Lua code commonly uses a metatable.
Metatables change fallback behavior
Section titled “Metatables change fallback behavior”The previous Bot table had a method, but a separate bot record did not
inherit it. Here Bot:new attaches Bot as the new record’s metatable. The
__index fallback then lets scout:set_state find the method on Bot, leaving
scout.state as "plan".
local Bot = {}Bot.__index = Bot
function Bot:new(name) local instance = { name = name, state = "observe" } return setmetatable(instance, self)end
function Bot:set_state(next_state) self.state = next_stateend
local scout = Bot:new("scout")scout:set_state("plan")The line that confuses nearly everyone is Bot.__index = Bot. Two separate
things are happening there, and it helps to state them apart.
A metatable is a table you attach to another table to say what should
happen when an ordinary operation on it does not work out — a key that is
missing, an addition, a comparison. Lua looks inside it for specially named
keys such as __index or __add, and uses whatever it finds there.
Attaching one by itself changes nothing about how keys are looked up. What does the work is the
__index key inside that metatable: when a lookup on the original table
fails, Lua checks the metatable for __index, and if it finds a table there,
repeats the lookup in that table.
So setmetatable(instance, Bot) says “if a key is missing from this instance,
ask Bot what to do,” and Bot.__index = Bot says “what to do is: look inside
Bot itself.” Compressed into one line it reads as circular; separated into
two steps it is ordinary. Forgetting to set __index is the classic beginner
mistake — the metatable is attached, every method lookup still returns nil,
and nothing in the error explains why.
Drawn out, the whole arrangement is two references. scout’s metatable is
Bot, and Bot.__index points back at Bot itself:
set_state appears only in the right-hand box. A lookup that misses in scout
follows the metatable arrow, finds __index, and follows the loop to search
Bot, where the key exists.
When scout has no set_state key, its metatable’s __index points to Bot, so Lua looks there. This resembles method lookup, but it is not a compiled C++ vtable. It is lookup behavior that scripts can inspect and change.
Read the expression scout:set_state("plan") in two stages. Colon syntax first supplies scout as the hidden first argument. Normal table lookup then searches scout for set_state and follows the __index reference to Bot when the key is absent. Keeping argument passing separate from lookup makes metatable behavior much less mysterious.
The left side of the process chooses the first argument. The right side finds the function value. They cooperate, but they are separate steps.
Validate host-shaped tables
Section titled “Validate host-shaped tables”Dynamic types do not remove the need for contracts:
local function require_entity(value) assert(type(value) == "table", "entity must be a table") assert(type(value.id) == "number", "entity.id must be a number") assert(type(value.position) == "table", "entity.position must be a table") assert(type(value.alive) == "boolean", "entity.alive must be a boolean") return valueendValidate at the boundary, then let internal code use the checked shape. The host must still validate every value coming back from Lua; a script can mutate its table after validation.
Avoid clever metatable surprises
Section titled “Avoid clever metatable surprises”Metamethods such as __index, __newindex, __add, and __call can make expressive APIs. They can also hide control flow.
For beginner-friendly game scripts:
- prefer ordinary fields for data;
- use a small prototype pattern only when shared methods help;
- avoid changing global metatables;
- do not run arbitrary behavior during simple field reads;
- validate tables received from scripts at the host boundary.
Plain tables and plain functions are enough for most observers, mod logic, and state machines. Learn the mechanism, then use only as much magic as the reader can still explain.
Translate the index
Rust vector element 0 becomes which conventional Lua sequence key?
Rust collections are zero-indexed while Lua sequences conventionally start at one, so the host adds one during conversion.
Finished Lesson 11.3?
Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.
Plain Markdown, saved in this browser.
This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.