Appearance
Metatables and OOP
Written: 2026-10-14
Learning objectives
- You attach and read metatables safely.
- You build __index chains for fallback lookup.
- You use __newindex, __call, __tostring, __add, __len, and __eq correctly.
- You call methods with colon syntax.
- You build class-like tables with .new.
- You prefer composition and prototype design where it fits.
Prerequisites
Metatables add hidden behavior
A metatable is an ordinary table that you attach to another table. It holds metamethods. Luau calls those metamethods when code indexes, writes, calls, prints, adds, measures, or compares the host table. Attach with setmetatable and read with getmetatable. The full list lives in metatables.
Run this pure snippet in the Command Bar.
luau
--!strict
local pet = {name = "Pip"}
local mt = {}
setmetatable(pet, mt)
print(getmetatable(pet) == mt)txt
trueBuild a host table and an empty metatable, then attach one to the other. The identity check prints true. setmetatable also returns the host table, so you can attach and return in one line inside constructors.
A locked __metatable field hides the real metatable. getmetatable then returns that field instead. setmetatable onto a protected table errors. The lock suits shipped types that must not change shape at runtime. Skip it in game code where tests need access.
__index supplies fallback lookups
Luau fires __index when a key is missing from the host table. The handler can be a table or a function. A table handler replays the lookup in that table. A function handler computes the answer. Chains form when the fallback table has its own metatable.
Run this pure snippet in the Command Bar.
luau
--!strict
local defaults = {hp = 100, speed = 16}
local hero = setmetatable({name = "Mara"}, {__index = defaults})
print(hero.name)
print(hero.hp)txt
Mara
100name lives on the hero, so no fallback fires. hp is missing, so Luau reads it from defaults. Table fallbacks suit defaults, roles, and species stats. Keep defaults frozen so instances cannot edit shared tuning.
A function fallback computes per-key answers. It receives the host table and the missing key. It suits lazy values and helpful errors.
Run this pure snippet in the Command Bar.
luau
--!strict
local t = setmetatable({}, {
__index = function(host, key)
return `missing {key}`
end,
})
print(t.sword)txt
missing swordThe handler builds a message from the key. Every missing read returns a sentence naming the key. This shape suits debug proxies, not gameplay data. Silent string fallbacks hide typos in real systems.
Chain depth stays shallow. One fallback level covers most designs. Two levels suit species plus global defaults. Deeper chains slow lookups and confuse readers. Flatten a chain that needs a comment to explain it.
__newindex guards writes
Luau fires __newindex when code writes a missing key. Like __index, it accepts a table or a function. A table handler stores the write in that table. A function handler decides what to do. Existing keys bypass the handler and write directly.
Run this pure snippet in the Command Bar.
luau
--!strict
local log = {}
local proxy = setmetatable({}, {
__newindex = function(host, key, value)
table.insert(log, key)
rawset(host, key, value)
end,
})
proxy.hp = 40
print(log[1])
print(proxy.hp)txt
hp
40The handler records the key, then stores the value with rawset to avoid looping back into itself. Later reads find hp directly. This shape suits change tracking, validation, and read-only proxies that error instead of storing.
Keep guards simple. Validate type and range, then store or error. Complex guards belong in named methods where tests can call them. Metamethods should stay thin because they fire on everyday syntax.
__call makes tables callable
A table with __call runs like a function. The handler receives the host table plus call arguments. It suits factories, matchers, and state machines that read naturally as calls.
Run this pure snippet in the Command Bar.
luau
--!strict
local adder = setmetatable({step = 5}, {
__call = function(host, x)
return x + host.step
end,
})
print(adder(10))txt
15Calling adder invokes the handler with 10. It adds the stored step and returns 15. Reserve callable tables for one clear action. A table with five behaviors should expose five named methods instead.
__tostring controls print output
print and tostring consult __tostring when present. The handler receives the host table and returns a string. It suits logs, errors, and debug views. Keep the output short and stable so tests can match it.
Run this pure snippet in the Command Bar.
luau
--!strict
local pt = setmetatable({x = 3, y = 4}, {
__tostring = function(host)
return `{host.x},{host.y}`
end,
})
print(pt)txt
3,4Printing the table calls your handler. It formats both fields with backtick interpolation from strings. Show values that identify the object. Leave out secrets or huge dumps.
__add teaches plus to tables
Arithmetic metamethods give operators to tables. __add handles +. It receives both operands in order. Return a fresh value rather than mutating inputs. Sibling metamethods cover the rest of the operators listed in metatables.
Run this pure snippet in the Command Bar.
luau
--!strict
local mt = {
__add = function(a, b)
return {x = a.x + b.x, y = a.y + b.y}
end,
}
local p = setmetatable({x = 1, y = 2}, mt)
local q = setmetatable({x = 10, y = 20}, mt)
local r = p + q
print(r.x)
print(r.y)txt
11
22Adding two points calls the shared handler. It sums each field into a new table. The inputs stay unchanged. Add operators only when the math reads better than a named function. Positions and colors qualify. Game entities do not.
__len customizes the length operator
Luau fires __len when code applies # to the host. The handler returns the reported length. It suits custom containers such as sets and queues where #host should mean count, not array boundary.
Run this pure snippet in the Command Bar.
luau
--!strict
local bag = setmetatable({sword = true, bow = true}, {
__len = function(host)
local n = 0
for _ in host do
n += 1
end
return n
end,
})
print(#bag)txt
2The handler counts every key with generalized iteration and returns 2. Without it, # on a dictionary would report 0. Document custom lengths clearly because readers expect # to mean array size.
__eq gives shared types fair equality
Luau fires __eq for == only when both operands share the same metamethod function and the same basic type. Different handlers mean plain identity comparison. This rule stops accidental equality across unrelated types. The equality footnote in metatables states the requirement.
Run this pure snippet in the Command Bar.
luau
--!strict
local same = {
__eq = function(a, b)
return a.id == b.id
end,
}
local a = setmetatable({id = 5}, same)
local b = setmetatable({id = 5}, same)
local c = setmetatable({id = 5}, {})
print(a == b)
print(a == c)txt
true
falsea and b share one handler, so their ids compare and return true. c carries a different metatable, so Luau falls back to identity and returns false. Define __eq for value types like points and item stacks. Skip it for entities where identity is the point.
Methods take self first
A method is a function stored in a table that expects the host as its first parameter, named self. Colon syntax passes the host automatically. obj:M() means obj.M(obj). Dot syntax needs the argument spelled out. Definitions follow the same rule: function obj:M() means function obj.M(self). The method guide in functions shows both directions.
Run this pure snippet in the Command Bar.
luau
--!strict
local door = {open = false}
function door.Toggle(self)
self.open = not self.open
return self.open
end
print(door.Toggle(door))
print(door:Toggle())txt
true
falseThe dot call passes door by hand and opens it. The colon call passes door automatically and closes it. Define with colon and call with colon for methods. Define with dot and call with dot for plain functions. Mixing shapes is the top method bug in reviews.
Class-like tables use .new plus __index
Luau has no class keyword. Class-like tables use three pieces: a class table holding methods, a .new constructor that builds instances, and an __index link from instances to the class. Each instance holds its own fields. Shared methods live once on the class.
Put this ModuleScript in ReplicatedStorage and name it Bank.
luau
--!strict
local Bank = {}
Bank.__index = Bank
function Bank.new(owner: string): any
local self = setmetatable({}, Bank)
self.owner = owner
self.coins = 0
return self
end
function Bank:Add(n: number): number
self.coins += n
return self.coins
end
return BankSetting Bank.__index to Bank itself links instances to the class. The constructor makes an empty instance, links it, fills fields, and returns it. Method calls like acct:Add(10) find Add through the link. The instance holds coins while the class holds behavior. This is the standard Luau class shape.
Put this Script in ServerScriptService.
luau
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Bank = require(ReplicatedStorage:WaitForChild("Bank"))
local a = Bank.new("Mara")
local b = Bank.new("Bo")
print(a:Add(10))
print(b:Add(5))
print(a.coins)txt
10
5
10Two accounts start separately. Deposits move each balance alone. The shared method serves both through the metatable. Test constructors by proving independence exactly like this.
Keep inheritance shallow or absent. Deep chains hide where a method lives. Roblox data rarely needs more than one reuse level. When two classes share behavior, extract a helper module instead of a base class.
Composition beats inheritance
Composition builds features from parts. An entity owns a mover, a health block, and a wallet. Each part is a small table with a tiny API. The entity forwards calls or exposes parts directly. Parts swap without rewriting the entity.
Run this pure snippet in the Command Bar.
luau
--!strict
local function makeHealth(max: number): any
return {hp = max, max = max}
end
local function damage(h: any, n: number): number
h.hp = math.max(0, h.hp - n)
return h.hp
end
local hero = {name = "Mara", health = makeHealth(100)}
print(damage(hero.health, 30))
print(hero.health.hp)txt
70
70Health is a plain part with two fields. damage is a helper that clamps at 0. The hero owns the part but shares no metatable with it. Health tests alone, reuses for pets and doors, and replaces without touching movement code. That swap cost is why teams prefer composition.
No metatable is needed for composition. Plain tables plus functions carry most designs. Add a metatable only when fallback lookup, call syntax, or operators earn their complexity. Simple tables from tables stay the default.
Prototype style clones a model
Prototype design clones a model object instead of calling a constructor. Build one exemplar with good defaults. Shallow- or deep-copy it for each use, then tweak fields. table.clone suffices for flat models. Nested models need the deep copy from Tables in Depth.
Run this pure snippet in the Command Bar.
luau
--!strict
local goblin = {hp = 30, speed = 12, kind = "goblin"}
local function spawn(from: any, hp: number): any
local copy = table.clone(from)
copy.hp = hp
return copy
end
local a = spawn(goblin, 30)
local b = spawn(goblin, 45)
print(a.hp)
print(b.hp)
print(goblin.hp)txt
30
45
30Each spawn clones the exemplar and overrides hit points. The model stays at 30 for future spawns. Prototypes suit enemies, loot, and projectiles with small variations. Switch back to constructors when setup logic outgrows copy plus tweak.
Common mistakes
Wrong: you call a method with a dot and lose self.
luau
--!strict
local box = {n = 1}
function box.Bump(self)
self.n += 1
end
box.Bump()
print(box.n)Studio reports an error about indexing nil because self is nil without the host argument. Fix it with a colon call or an explicit argument.
luau
--!strict
local box = {n = 1}
function box:Bump()
self.n += 1
end
box:Bump()
print(box.n)txt
2Wrong: you set __index to a table you later mutate as shared state.
luau
--!strict
local K = {}
K.__index = K
K.hp = 100
local m = setmetatable({}, K)
m.hp = 10m.hp now shadows, but reads of other missing keys still hit the mutable class table. Fix it by keeping instance state on instances and shared behavior only on the class. Freeze class defaults when instances must not edit them.
Wrong: you expect __eq across different metatables.
luau
--!strict
local a = setmetatable({id = 1}, {__eq = function(x, y)
return x.id == y.id
end})
local b = setmetatable({id = 1}, {__eq = function(x, y)
return x.id == y.id
end})
print(a == b)txt
falseTwo literal handlers are different functions, so Luau uses identity. Fix it by sharing one metatable or one handler function between compared values.
Wrong: you write to a guarded table inside __newindex without rawset.
luau
--!strict
local t = setmetatable({}, {
__newindex = function(host, k, v)
host[k] = v
end,
})
t.x = 1The assignment re-enters __newindex forever and overflows. Fix it with rawset(host, k, v) for the actual store.
Exercises
- Build an __index fallback. Read one present key and one defaulted key.
- Guard writes with __newindex plus rawset. Log each new key.
- Add __tostring, __add, __len, and __eq to small demo tables. Print each result.
- Write a class-like Bank with .new and two methods. Prove two instances stay independent.
- Compose an entity from health and wallet parts. Compare the code against a prototype clone version.
Solution 1
Run this pure snippet in the Command Bar.
luau
--!strict
local base = {hp = 50}
local unit = setmetatable({name = "Imp"}, {__index = base})
print(unit.name)
print(unit.hp)txt
Imp
50Present keys read directly. Missing keys fall back to the base table.
Solution 2
Run this pure snippet in the Command Bar.
luau
--!strict
local seen = {}
local p = setmetatable({}, {
__newindex = function(host, k, v)
table.insert(seen, k)
rawset(host, k, v)
end,
})
p.a = 1
p.b = 2
print(table.concat(seen, ","))
print(p.a + p.b)txt
a,b
3Each new write logs its key once. Rawset stores without re-entering the guard.
Solution 3
Run this pure snippet in the Command Bar.
luau
--!strict
local mt = {}
function mt.__tostring(host)
return `v{host.n}`
end
function mt.__add(a, b)
return setmetatable({n = a.n + b.n}, mt)
end
function mt.__len(host)
return host.n
end
function mt.__eq(a, b)
return a.n == b.n
end
mt.__index = mt
local a = setmetatable({n = 2}, mt)
local b = setmetatable({n = 3}, mt)
print(a)
print((a + b).n)
print(if #a == 2 then "len ok" else "len bad")
print(a == b)
print(a == setmetatable({n = 2}, mt))txt
v2
5
len ok
false
trueTostring formats, add sums into a fresh value, len reports n, and equality needs the shared handler plus matching values.
Solution 4
Run this pure snippet in the Command Bar.
luau
--!strict
local Coin = {}
Coin.__index = Coin
function Coin.new(): any
return setmetatable({n = 0}, Coin)
end
function Coin:Add(x: number): number
self.n += x
return self.n
end
function Coin:Total(): number
return self.n
end
local a = Coin.new()
local b = Coin.new()
a:Add(4)
print(a:Total())
print(b:Total())txt
4
0Two instances share methods through one link. Their n fields move independently.
Solution 5
Run this pure snippet in the Command Bar.
luau
--!strict
local model = {hp = 20, coins = 0}
local function spawn(hp: number): any
local c = table.clone(model)
c.hp = hp
return c
end
local x = spawn(20)
local y = spawn(35)
print(x.hp)
print(y.hp)txt
20
35Clones vary by field overrides with no constructor. This suits setup that is copy plus tweak, while constructors suit setup that needs validation and wiring.
Summary
- Metatables add fallback, guard, call, print, math, length, and equality behavior.
- Colon syntax passes self. Dot syntax needs it by hand.
- Class-like tables pair .new with an __index link. Instances hold state.
- Composition and prototypes beat deep inheritance for most game code.
- Next: Errors and Defensive Code.