Foundations

Lua Tables: Build an Inventory That Stays Predictable

Use records, keyed collections, and explicit updates to model a small game inventory without accidental shared state.

A green inventory chest with separate compartments for crystals, keys, and potions
Original tutorial artwork · PixelDevHub
PixelDevHub editorial desk · Beginner · 5 min read · Core Lua 5.4

Choose a shape before writing the logic

An inventory is easier to maintain when its data has one clear shape. Start by deciding whether you need an ordered list of slots or a collection looked up by item identifier. A slot-based bag and a stack counter solve different problems. For a simple crafting prototype, use a dictionary whose keys are stable item identifiers and whose values are whole-number quantities. Keep display names and artwork in a separate item catalog so renaming an item does not invalidate saved quantities.

Write down what each value means. Does a missing entry mean the player owns zero items, or does it indicate an unknown item? In this example, a missing inventory entry means zero ownership, while the catalog decides whether the identifier exists. That distinction keeps storage compact without letting arbitrary identifiers become real items.

Keep the catalog separate from ownership

The catalog describes the available item types; the inventory describes one owner's quantities. They should not be the same table. Otherwise changing an item description might unexpectedly overwrite a balance, or modifying a balance might affect every user. A small constructor that returns a new table for each inventory makes ownership explicit. When code receives an inventory reference, it can modify that exact inventory; merely assigning it to another variable does not create an independent copy.

local catalog = {
    crystal = { label = "Crystal", maxStack = 99 },
    key = { label = "Key", maxStack = 5 }
}

local function newInventory()
    return {} -- A fresh table for each owner.
end

local bag = newInventory()
local otherBag = newInventory()
assert(bag ~= otherBag)

Validate before changing a quantity

Make an update function responsible for checking both the item and the requested amount. Reject unknown identifiers, nonnumeric quantities, fractions, zero, and negative additions. A limit on each requested addition also prevents unexpectedly large operations. The function should finish validation before writing anything, so a rejected request leaves the original inventory untouched. This example chooses to reject additions that exceed the stack limit rather than silently discard some of the requested items.

local function addItem(inventory, itemId, amount)
    local item = catalog[itemId]
    if not item then return false, "Unknown item" end
    if type(amount) ~= "number" or amount ~= amount
        or amount < 1 or amount > 1000000
        or amount % 1 ~= 0 then
        return false, "Amount must be a bounded positive integer"
    end
    local nextAmount = (inventory[itemId] or 0) + amount
    if nextAmount > item.maxStack then
        return false, "Stack limit exceeded"
    end
    inventory[itemId] = nextAmount
    return true
end

assert(addItem(bag, "crystal", 3))
assert(bag.crystal == 3)
assert(not addItem(bag, "crystal", -2))
assert(bag.crystal == 3)

Decide how removal and empty entries behave

Use a separate removal function with the same validation standards. It should verify sufficient ownership before subtracting anything. When a quantity reaches zero, remove the entry by assigning nil if that matches your storage convention. Do not alternate between nil, zero, false, and empty strings to represent absence. Consistent representation helps your display, saving, and crafting systems agree about what the player owns. A production trade also needs an authoritative transaction boundary around changes to multiple inventories.

Do not assume a dictionary has display order

An inventory dictionary supports convenient lookups, but the interface usually needs a stable order. Collect the item identifiers into a dense list and sort that list using your chosen rule before rendering. Avoid treating the length operator as a dictionary entry counter. Track a separate count or count entries explicitly when you need that information. If you instead implement numbered slots, keep the sequence dense or manage the slot count yourself rather than relying on ambiguous holes.

Exercise: add a crafting operation

Extend the model with a recipe that consumes three crystals and creates one key. First inspect every ingredient and the output stack limit. Only after all checks succeed should you apply the changes. Test an empty inventory, exact ingredients, excess ingredients, an unknown recipe, and a full output stack. In a multiplayer host, run the ownership decision in the trusted game state and treat client messages as requests. This small model teaches table organization; it does not supply persistence, networking, duplicate-receipt protection, or a complete trading system.

Language reference

These examples use core Lua rather than an engine API. Consult the official Lua 5.4 reference manual for the language contract. Embedded runtimes can differ in available libraries, loaders, and scheduling behavior.

← Explore the Lua library

Introduce an explicit save boundary

A live inventory table is convenient for game logic, while a stored representation needs a clear format and version. Decide which fields belong in a save and which can be reconstructed from the item catalog. Avoid persisting display labels when stable identifiers are available. Add a version field so future loading code can recognize older data deliberately. On load, validate the structure and quantities before replacing the active inventory. A readable error is preferable to silently accepting a malformed balance. The table example here does not provide storage, but thinking about that boundary early helps prevent a prototype's accidental data shape from becoming an undocumented permanent format.

Check reference ownership at function boundaries

Consider what a caller can do with the inventory reference returned by a helper. If it receives the actual mutable table, it can bypass your addition and removal rules. That may be acceptable inside a small trusted module, but it should be a conscious choice. A display layer often needs only a read snapshot or an enumerated list of entries. Remember that copying the outer table does not automatically isolate nested tables. Document the intended ownership model before adding deeply nested item metadata. The simplest useful contract is often to keep mutations in one module and expose only the small set of queries the rest of the game actually needs.

Build a regression checklist around invariants

An invariant describes what must remain true after every accepted operation. For this inventory, quantities should stay within the chosen limits, unknown items should not appear, and rejected updates should leave state unchanged. Write a small sequence of assertions that adds, removes, and attempts invalid operations, checking the invariants after each step. Include operations on two different inventories to catch shared-state mistakes. When adding crafting, test both an accepted recipe and a rejected one with insufficient ingredients. These checks are more useful than verifying only that the happy-path example prints the expected number. They give you a compact way to protect the model while adding new features around it.

Find your next tool or idea

Search tools, guides, and Lua resources.

↑ ↓ Browse resultsESC to close

Choose a tool