Skip to content
Lesson 11.3NPC Policy, Lua, and Script Execution

Lua Tables, Functions, and Metatables

Lua's flexible tables and first-class functions express records, sequences, callbacks, methods, and lookup behavior.

Lesson 11.3
Read or listenPrint chapter or book
About 30 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 11.3Key ideas, reference and questions

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 and ipairs are 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 __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.

Review

  • Q: Rust vector element 0 becomes 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
Download cheatsheet (.md)

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.

local bag = { "sword", "shield", gold = 25 }One table, three key–value pairs.
  1. 1 Every entry is a key and a value

    Key → value
    1"sword"
    2"shield"
    "gold"25

    Lua gave the listed items the keys 1 and 2. "gold" is just another key.

  2. 2 ipairs and # use only the keys 1, 2, 3, …

    Key
    1visited
    2visited
    3missing: stop
    "gold"skipped

    ipairs 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 table
entity record: named keys id, alive, and position
lookup map: arbitrary entity IDs mapped to records; order is not promised

The 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]) -- nil

When 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.

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.

slot 0z · 2.0
slot 1empty
slot 2y · 5.5
slot 3x · 10.0
22 ÷ 4 leaves 2
A collision-free illustration of the hash part of 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.

Use ipairs for a dense integer sequence:

for index, item in ipairs(list) do
print(index, item)
end

ipairs 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 2
1sword
2nil
3potion
4nil
visited by ipairs
ipairs stops here
never reached
After list[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)
end

Do 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 index

Result: 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
  1. Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.

    hole = present.

  2. 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.

  3. Step 3, line 4. Visit key1: A.

    hole = present; visited = A; index = 1.

    Memory: 1 = A; 2 = B; 3 = C.

  4. Step 4, line 5. Advance to the next numeric key.

    hole = present; visited = A; index = 2.

    Memory: 1 = A; 2 = B; 3 = C.

  5. Step 5, line 4. Visit key2: B.

    hole = present; visited = A, B; index = 2.

    Memory: 1 = A; 2 = B; 3 = C.

  6. Step 6, line 5. Advance to the next numeric key.

    hole = present; visited = A, B; index = 3.

    Memory: 1 = A; 2 = B; 3 = C.

  7. Step 7, line 4. Visit key3: C.

    hole = present; visited = A, B, C; index = 3.

    Memory: 1 = A; 2 = B; 3 = C.

  8. 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.

  9. 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.

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 * dy
end
local scoring_rule = squared_distance
print(scoring_rule({ x = 0, y = 0 }, { x = 3, y = 4 })) -- 25

The 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.

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
end
end
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 countdown

Following 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:

call 1fires, 3
call 2blocked, 2
call 3blocked, 1
call 4blocked, 0
call 5fires, 3
The result of each 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.

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_state
end
local bot = { state = "observe" }
Bot.set_state(bot, "plan") -- dot form: pass bot explicitly
Bot:set_state("plan") -- colon form: Bot becomes self

Colon 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.

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_state
end
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:

metatable
__index
scout
name = "scout"
state = "plan"
Bot
__index = Bot
new = function
set_state = function

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.

Yes
No
No
Yes
scout:set_state("plan")
Colon syntax supplies
scout as self
Look for set_state
in scout
Key found?
Call that value
with scout as self
Read scout's metatable
__index available?
Lookup produces nil
Follow __index to Bot
Find Bot.set_state

The left side of the process chooses the first argument. The right side finds the function value. They cooperate, but they are separate steps.

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 value
end

Validate 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.

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.

Knowledge check

Translate the index

Rust vector element 0 becomes which conventional Lua sequence key?

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.

Chapter 11: 11 lessons

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.