Architecture

Lua Metatables: Share Behavior Without Sharing Player State

Build a small prototype with shared methods, independent fields, and a clear explanation of colon syntax.

A green stamp and orange gear producing matching character-stat cards
Original tutorial artwork · PixelDevHub
PixelDevHub editorial desk · Intermediate · 4 min read · Core Lua 5.4

Introduce a prototype for a specific reason

Metatables become useful when several objects need the same behavior while keeping their own state. A character-stat object is a small example: every character can take damage, but each has its own health. You could copy the damage function into every new table, yet that obscures the fact that the behavior is shared. A prototype provides a common place for methods. Introduce it when that relationship improves understanding, rather than treating every plain record as an object that needs an abstraction.

Keep the distinction between state and behavior visible. Health belongs to one character. The implementation of taking damage can be shared. A nested inventory table also belongs to one character, so it should be allocated when constructing that character rather than placed on the common prototype.

Create fresh state in the constructor

The following prototype keeps its methods on Character and uses an __index lookup to find them. The constructor creates a new table for every call and attaches the shared metatable. This is one common Lua pattern, not a built-in class declaration. The example deliberately validates a bounded whole-number health value so unusual data does not become a surprising object state.

local Character = {}
Character.__index = Character

function Character.new(health)
    assert(type(health) == "number" and health == health
        and health >= 1 and health <= 1000000
        and health % 1 == 0, "Invalid health")
    return setmetatable({ health = health, inventory = {} }, Character)
end

function Character:takeDamage(amount)
    assert(type(amount) == "number" and amount == amount
        and amount >= 0 and amount <= 1000000
        and amount % 1 == 0, "Invalid damage")
    self.health = math.max(0, self.health - amount)
    return self.health
end

local first = Character.new(100)
local second = Character.new(100)
assert(first:takeDamage(15) == 85)
assert(second.health == 100)
first.inventory.crystal = 1
assert(second.inventory.crystal == nil)

Read colon syntax as an explicit first argument

The method call first:takeDamage(15) supplies first as the initial argument. The corresponding dot-style call is Character.takeDamage(first, 15). Understanding that relationship makes a common mistake easier to diagnose: calling a colon-defined method with a dot while omitting the object shifts every argument. The issue is not mysterious class behavior; the function received the wrong inputs. Pick a consistent calling convention and show it clearly in your public examples.

Watch for shared mutable defaults

A common prototype mistake is assigning an inventory table directly to Character and leaving it out of the constructor. Both objects can then reach the same nested table through the prototype. An addition made for one character may become visible to another. Create mutable per-object collections in the constructor, including nested collections where needed. Assigning a table to a different variable only creates another reference, so a shallow top-level copy does not necessarily isolate all nested state.

Keep ordinary operations unsurprising

Metatables can customize more than method lookup, but additional behavior comes with a comprehension cost. Avoid surprising side effects when someone reads a field or prints a value. A debugging helper should not trigger a network request, and a property lookup should not silently modify an inventory. Begin with the minimum behavior required by the design. When a metamethod is necessary, document its assumptions and write examples that make its effects visible rather than hiding them behind clever syntax.

Exercise: add healing and test independence

Add a maximum-health field and a heal method that clamps health to that maximum. Construct two characters with different limits, damage both, and heal only one. Assert that their current values and inventories remain independent. Test zero damage, an excessive heal, and invalid inputs. Finally, ask whether the object owns engine instances, event listeners, or scheduled work. Those resources need an explicit lifecycle in your host; attaching a metatable does not automatically disconnect listeners or destroy scene objects.

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

Keep construction and validation understandable

A constructor should leave the caller with an object whose documented invariants already hold. Avoid creating a partly initialized character and expecting a later method call to make it usable. If construction needs outside dependencies, either validate them first or use an explicit setup phase with a clear state. Keep mutable per-character values on the instance. Use the shared prototype for behavior that is genuinely common. A short constructor that creates obvious ownership is easier to debug than a clever chain of fallback lookups that makes it unclear where health, inventory, or permissions are actually stored when a method begins executing.

Inspect lookup behavior when a field surprises you

When reading a value produces an unexpected result, check both the object's own fields and the behavior supplied by its metatable. Ask whether the value was stored on the instance or reached through the shared lookup path. This distinction explains many accidental-default bugs without requiring a complicated inheritance model. For diagnostic work, keep a simple representation of the object's own state and avoid adding side effects to inspection helpers. If a field can be overridden, document whether methods should use the override or always consult a private value. A predictable policy prevents different methods from interpreting the same apparent property in incompatible ways.

Use composition when behavior stops fitting one prototype

A growing character system may need inventory management, movement state, and quest progress that change at different rates. Placing everything on one prototype can make small changes affect unrelated features. Consider keeping those responsibilities in separate owned components with explicit interfaces. A prototype remains useful for shared behavior within a focused component; it does not need to become the entire application architecture. Test ownership boundaries by constructing two objects and changing one nested component at a time. If an update affects the other object unexpectedly, investigate shared references before adding another inheritance layer. Clear ownership usually solves the practical problem more directly than increasingly complex lookup rules.

Find your next tool or idea

Search tools, guides, and Lua resources.

↑ ↓ Browse resultsESC to close

Choose a tool