OOP in Luau: a practical guide to metatables, classes, and the .new() pattern
TL;DR
Luau has no native class keyword — you build classes out of plain tables and metatables. Once you understand the three-line skeleton (MyClass, MyClass.__index = MyClass, setmetatable({}, MyClass)), everything else falls into place. This guide walks through every piece of that pattern, explains : vs ., covers inheritance, and ends with two real-world examples: an Entity class and an Inventory class.
Why OOP?
When your game grows beyond a handful of scripts, procedural code becomes a mess. Every NPC, weapon, or inventory slot ends up as a loose table with no shared interface. OOP fixes that by bundling data (fields) and behaviour (methods) in a reusable blueprint — a class.
Lua was not designed as a first-class OOP language. OOP is implemented on top of its table/metatable system, which keeps you in full control of every layer.
The building blocks
Metatables and __index
A metatable is a regular table that defines how operations on another table should behave. The metamethod we care about most for OOP is __index. When you read a key that doesn't exist on a table, Luau checks whether that table has a metatable with an __index entry — if __index is itself a table, Luau looks the key up there instead of returning nil.
local defaults = { speed = 16 }
local car = setmetatable({}, { __index = defaults })
print(car.speed) -- 16, found via __index fallback
This fallback is exactly how method lookup works on class instances.
The canonical Luau class pattern
-- Animal.lua (ModuleScript)
local Animal = {}
Animal.__index = Animal -- instances fall back here for lookups
function Animal.new(name: string, sound: string)
local self = setmetatable({}, Animal)
self.name = name
self.sound = sound
return self
end
function Animal:speak()
print(self.name .. " says " .. self.sound)
end
function Animal:getName(): string
return self.name
end
return Animal
| Line | What it does |
|---|---|
Animal.__index = Animal | Stores a reference to the class inside itself for method fallbacks |
setmetatable({}, Animal) | Creates an empty instance whose metatable is Animal |
return self | Returns the wired-up instance |
When you call myAnimal:speak(), Luau finds no speak on the instance, checks the metatable (Animal), finds Animal.__index pointing back to Animal, and retrieves speak. That's the whole magic.
: vs . — explained once and for all
This trips up almost every newcomer.
- Dot (
.) — you pass all arguments explicitly;selfis a regular parameter. - Colon (
:) — syntactic sugar that injects the calling table as the implicit first argumentself.
-- Identical definitions:
function Animal.speak(self) print(self.name) end
function Animal:speak() print(self.name) end -- colon auto-inserts self
-- Call site:
local a = Animal.new("Cat", "meow")
a:speak() -- ✅ passes `a` as self automatically
Animal.speak(a) -- ✅ same thing, explicit
a.speak() -- ❌ self is nil → runtime error
Rule of thumb: use .new() for constructors (you don't want the class table as self); use : for every other method, both at definition and call site.
Inheritance
Inheritance adds one more setmetatable layer — unresolved lookups on the child bubble up to the parent.
-- Dog.lua
local Animal = require(script.Parent.Animal)
local Dog = setmetatable({}, { __index = Animal })
Dog.__index = Dog
function Dog.new(name: string)
local self = Animal.new(name, "woof") -- dot, not colon!
return setmetatable(self, Dog)
end
function Dog:speak() -- override
print(self.name .. " barks loudly!")
end
function Dog:fetch(item: string)
print(self.name .. " fetches the " .. item)
end
return Dog
Calling myDog:getName() → not found on instance → not found on Dog → found on Animal via Dog's __index chain. That two-level fallback is prototype-based inheritance.
Watch out: use
Animal.new(...)with a dot when calling the parent constructor from a child. Using the colon (Animal:new(...)) passesDogasselfand initialises fields on the class table itself — a shared-state bug.
Common pitfalls
- Forgetting
Animal.__index = Animal—setmetatable({}, Animal)sets the metatable but__indexisnil, so method calls silently returnniland you get attempt to call a nil value.
- Using
:to call.new()—Animal:new(...)passes the class asself, writing fields directly ontoAnimaland sharing them across all future instances.
- Mixing
__indexand__newindexcarelessly —__indexfires on reads of missing keys,__newindexon writes. Combining them in the same metatable withoutrawset()/rawget()can cause a stack overflow.
- Mutating the class table directly —
Animal.lives = 3is shared by every instance through__index. Fine as a class constant, a nasty bug otherwise.
Real-world example 1 — Entity class
-- Entity.lua
local Entity = {}
Entity.__index = Entity
function Entity.new(id: number, name: string, maxHealth: number)
local self = setmetatable({}, Entity)
self.id = id
self.name = name
self.maxHealth = maxHealth
self.health = maxHealth
return self
end
function Entity:takeDamage(amount: number)
self.health = math.max(0, self.health - amount)
if self.health == 0 then self:onDeath() end
end
function Entity:heal(amount: number)
self.health = math.min(self.maxHealth, self.health + amount)
end
function Entity:isAlive(): boolean
return self.health > 0
end
function Entity:onDeath()
print(self.name .. " has died.") -- override in subclasses
end
return Entity
Subclassing is trivial — a Boss class can inherit Entity, override onDeath(), and add a phase2 mechanic without touching the base logic.
Real-world example 2 — Inventory class
-- Inventory.lua
local Inventory = {}
Inventory.__index = Inventory
function Inventory.new(owner: Player, maxSlots: number)
local self = setmetatable({}, Inventory)
self.owner = owner
self.maxSlots = maxSlots
self.items = {}
return self
end
function Inventory:addItem(itemId: string, qty: number): boolean
if #self.items >= self.maxSlots then return false end
for _, slot in ipairs(self.items) do
if slot.id == itemId then slot.qty += qty; return true end
end
table.insert(self.items, { id = itemId, qty = qty })
return true
end
function Inventory:removeItem(itemId: string, qty: number): boolean
for i, slot in ipairs(self.items) do
if slot.id == itemId then
slot.qty -= qty
if slot.qty <= 0 then table.remove(self.items, i) end
return true
end
end
return false
end
function Inventory:getCount(itemId: string): number
for _, slot in ipairs(self.items) do
if slot.id == itemId then return slot.qty end
end
return 0
end
return Inventory
Each player gets their own Inventory instance — no shared state, no global tables, easy to serialise to DataStoreService.
Luau type annotations (--!strict)
Luau can't always infer the type of self across methods. The idiomatic fix uses export type and typeof(setmetatable(...)):
local Account = {}
Account.__index = Account
export type Account = typeof(setmetatable({} :: {
name: string,
balance: number,
}, Account))
function Account.new(name: string, balance: number): Account
return setmetatable({ name = name, balance = balance }, Account)
end
This unlocks full IDE autocomplete on self fields and catches type mismatches at write-time.
FAQ
Does adding metatables hurt performance? Metatable lookups add a tiny overhead compared to direct field access, but it's rarely noticeable at Roblox-game scales. Be mindful when spawning thousands of instances in a hot loop.
Can I do multiple inheritance? Not directly, but a mixin pattern — where __index is a function that searches multiple tables — achieves it in practice.
Should I use a wrapper library? Tools like ClasseV2 automate metatables and type inference for large projects. Understanding the raw pattern first means you can debug any issue without magic getting in the way. You can also browse ready-to-use OOP-based systems on GM Market if you need a head start.
Summary table
| Concept | Key rule |
|---|---|
MyClass.__index = MyClass | Enables method fallback on instances |
setmetatable({}, MyClass) | Creates a new instance linked to the class |
function MyClass.new(...) | Constructor — always dot, never colon |
function MyClass:method() | Instance method — colon injects self |
| Inheritance | setmetatable(Child, { __index = Parent }) |
--!strict typing | Use export type T = typeof(setmetatable(...)) |
Drop your questions below — happy to help debug your specific class setup!