GM Market — Premium GMod & FiveM Scripts
Roblox Guide 🇺🇸 English

OOP in Luau: a practical guide to metatables, classes, and the .new() pattern

Mateo
@Mateo ·
6 views 0 replies

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
LineWhat it does
Animal.__index = AnimalStores a reference to the class inside itself for method fallbacks
setmetatable({}, Animal)Creates an empty instance whose metatable is Animal
return selfReturns 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; self is a regular parameter.
  • Colon (:) — syntactic sugar that injects the calling table as the implicit first argument self.
-- 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(...)) passes Dog as self and initialises fields on the class table itself — a shared-state bug.


Common pitfalls

  1. Forgetting Animal.__index = Animalsetmetatable({}, Animal) sets the metatable but __index is nil, so method calls silently return nil and you get attempt to call a nil value.
  1. Using : to call .new()Animal:new(...) passes the class as self, writing fields directly onto Animal and sharing them across all future instances.
  1. Mixing __index and __newindex carelessly__index fires on reads of missing keys, __newindex on writes. Combining them in the same metatable without rawset() / rawget() can cause a stack overflow.
  1. Mutating the class table directlyAnimal.lives = 3 is 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

ConceptKey rule
MyClass.__index = MyClassEnables 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
Inheritancesetmetatable(Child, { __index = Parent })
--!strict typingUse export type T = typeof(setmetatable(...))

Drop your questions below — happy to help debug your specific class setup!

0

0 Replies

No replies yet — be the first to respond.