Skip to lesson
Game Hacking Academy
Preparing lesson text…
Exit reader mode
Voice and speed

Preparing read-aloud controls…

Lesson 11.3 Listening edition

Lua Tables, Functions, and Metatables

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

About 30 minutes

Section: Lua tables represent arrays, records, and maps.

A Lua table maps keys to values. These are all tables:

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

The first table uses integer keys 1, 2, and 3. Lua sequences conventionally start at one, not zero.

local bag = { "sword", "shield", gold = 25 }.

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:

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

The same Lua table implementation can represent all three, but host validation and the choice of iteration method differ.

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

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.

Section: 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 divided by 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.

Labelled memory cells

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.

Section: Iterate according to the shape.

Use ipairs for a dense integer sequence:

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

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:

Narration: A missing sequence entry can end an ipairs loop even when later numbered entries exist. Check for holes before blaming the body of the loop.

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

Labelled memory cells

Use pairs when keys are not a simple sequence:

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

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.

Section: 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.

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

The points (0, 0) and (3, 4) differ by 3 and 4; 3 to the power of 2 + 4 to the power of 2 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.

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

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

The returned function keeps access to remaining after make_cooldown has returned. That remembered environment is a closure.

Alternative: A closure carries a private notebook with its function: later calls read and update the remembered counter without making it global.

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:

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

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:

Labelled memory cells

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.

Section: 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 equals "plan".

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

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.

Section: 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".

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

The line that confuses nearly everyone is Bot.__index equals 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 equals 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:

Lesson diagram

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.

Lesson diagram

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

Section: Validate host-shaped tables.

Dynamic types do not remove the need for contracts:

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

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.

In brief: Check tables again when they cross back into the host, because a script can change fields after an earlier check.

Section: 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.