How to Use setmetatable for OOP in Roblox (With Examples)

Quick answer: Pair a class table holding your methods with setmetatable so every instance's __index points back to the class, construct with a .new function, and call methods with a colon. That is the standard OOP idiom in Luau.

You build object-oriented code in Roblox by pairing a class table with setmetatable: the class defines methods and a .new constructor, and every instance gets a metatable whose __index points back to the class so it can find shared methods. This is the standard OOP idiom in Luau — Luau has no class keyword, so metatables are how classes are made.

Why Roblox scripts need metatables for OOP

Luau is prototype-based at its core. A table only contains what you literally put in it; there is no built-in notion of "this table belongs to a class." Metatables add that missing link. When you read a field that a table does not have, Luau consults the table's metatable — and if that metatable has an __index entry, Luau looks the field up there instead.

That single rule gives you everything OOP needs:

Without __index, every instance would need its own copy of every method, or method calls would simply error.

The __index metatable pattern explained

The canonical pattern has three parts. First, define the class table holding its methods. Second, write a .new constructor that creates an instance table, sets its metatable with __index pointing at the class, and returns it. Third, call methods with a colon so the instance is passed as self.

local Weapon = {}
Weapon.__index = Weapon

function Weapon.new(name, damage)
    local self = setmetatable({}, Weapon)
    self.Name = name
    self.Damage = damage
    self.Ammo = 10
    return self
end

function Weapon:Attack()
    if self.Ammo <= 0 then
        return false
    end
    self.Ammo -= 1
    print(self.Name .. " fires for " .. self.Damage .. " damage")
    return true
end

function Weapon:GetAmmo()
    return self.Ammo
end

local pistol = Weapon.new("Pistol", 25)
pistol:Attack()          -- Pistol fires for 25 damage
print(pistol:GetAmmo())  -- 9

Here is what happens on the line pistol:Attack(). The pistol table itself has no Attack key, so Luau checks its metatable, finds __index = Weapon, and resolves Attack from the class table. The colon syntax then calls it with pistol as the first argument, which arrives inside the function as self.

Note the two different roles of the class table: Weapon.__index = Weapon makes it work as a metatable for lookups, while Weapon.new, Weapon.Attack and so on live directly on it as ordinary fields.

Colon methods and self: dot vs colon

The colon exists purely as shorthand. These two definitions and these two calls are equivalent:

-- Definition forms (identical)
function Weapon:Attack(target)
    -- "self" is implicit here
end
Weapon.Attack = function(self, target)
    -- explicit form of the same thing
end

-- Call forms (identical)
pistol:Attack(zombie)
pistol.Attack(pistol, zombie)

The rule to remember: define and call consistently. If you define a method with a colon but call it with a dot, the first argument becomes whatever comes after the dot — usually nil or a completely wrong value. Calling Weapon.Attack(zombie) would treat the zombie as self and try to index ammo off it, producing a confusing runtime error far from the actual mistake.

Use a colon whenever the first parameter is the object itself, and a plain dot for anything else, such as static helpers like Weapon.new.

Inheritance with setmetatable on subclasses

Subclassing reuses the same trick one level up: give the subclass an __index chain that falls through to the parent. Set the subclass's own __index to itself (so instances find subclass methods), then point the subclass's metatable at the parent (so misses continue into the parent).

local Shotgun = setmetatable({}, { __index = Weapon })
Shotgun.__index = Shotgun

function Shotgun.new(name)
    local self = Weapon.new(name, 60) -- reuse the parent constructor
    self.Pellets = 8
    return setmetatable(self, Shotgun) -- re-metatable to the subclass
end

function Shotgun:Attack()
    if not Weapon.Attack(self) then -- call the parent implementation
        return false
    end
    print(self.Name .. " hits with " .. self.Pellets .. " pellets")
    return true
end

local scattergun = Shotgun.new("Scattergun")
scattergun:Attack()
print(scattergun:GetAmmo()) -- inherited from Weapon, returns 9

Two details matter here. First, setmetatable(self, Shotgun) replaces the instance's metatable, so lookups now check Shotgun first and reach Weapon second — that is method overriding plus inheritance in one move. Second, the subclass can extend the parent's behavior by calling Weapon.Attack(self) explicitly rather than silently replacing it.

When OOP helps versus plain functions

Metatable-based classes shine when you have many objects of the same kind that each carry state and share behavior. Weapons, NPCs, round managers, data wrappers, UI controllers — anything instantiated repeatedly benefits from a class because the methods exist once in memory and each instance stays small.

Plain functions and module tables are often better when:

A good smell test: if you find yourself passing the same state table as the first argument to several functions, promote it to a class. If not, do not add machinery for its own sake. For guidance on structuring larger scripts around services and modules, see Roblox scripting for beginners.

Common mistakes with setmetatable

Forgetting Class.__index = Class. This is the number one error. Setting a table as a metatable does not by itself forward lookups: local self = setmetatable({}, Weapon) without the earlier line Weapon.__index = Weapon means indexing the instance finds nothing, and calling obj:Attack() errors with "attempt to call a nil value". Always keep the line Weapon.__index = Weapon right under the class declaration.

Using a dot where a colon belongs. Weapon.Attack() passes nothing as self, so inside the method self.Ammo indexes nil and crashes. Match your definition and call syntax.

Returning before setting the metatable. If new returns the instance before calling setmetatable, callers receive a bare table with no methods. Set the metatable inside the constructor, then return.

Sharing reference-type defaults across instances. Writing self.Items = {} inside new is correct, but putting a shared table like Weapon.Defaults onto every instance means all instances mutate the same array. Create fresh tables per instance in the constructor.

Assuming __index copies values. Remember __index only triggers for missing keys. Once you assign self.Ammo = 5, the instance owns that value; later changes to a class-level default will not propagate, which is usually what you want.

Full runnable example: a Weapon class with durability

This complete Script drops into ServerScriptService and runs as-is. It combines construction, methods, and a simple subclass:

-- Script in ServerScriptService
local Weapon = {}
Weapon.__index = Weapon

function Weapon.new(name, damage, maxDurability)
    local self = setmetatable({}, Weapon)
    self.Name = name
    self.Damage = damage
    self.Durability = maxDurability
    self.MaxDurability = maxDurability
    return self
end

function Weapon:Hit()
    if self.Durability <= 0 then
        print(self.Name .. " is broken!")
        return false
    end
    self.Durability -= 1
    print(string.format(
        "%s deals %d damage (%d/%d durability left)",
        self.Name, self.Damage,
        self.Durability, self.MaxDurability
    ))
    return true
end

local LegendarySword = setmetatable({}, { __index = Weapon })
LegendarySword.__index = LegendarySword

function LegendarySword.new(name)
    local self = Weapon.new(name, 90, 100)
    self.Charge = 3
    return setmetatable(self, LegendarySword)
end

function LegendarySword:Special()
    if self.Charge <= 0 then
        return false
    end
    self.Charge -= 1
    print(self.Name .. " unleashes a special attack!")
    return true
end

local sword = LegendarySword.new("Dawnbreaker")
sword:Hit()     -- Dawnbreaker deals 90 damage (99/100 durability left)
sword:Special() -- Dawnbreaker unleashes a special attack!

Paste this into Studio, press Play, and watch the Output window. From here you can extend the pattern with private-ish fields (underscores by convention), destructors via __gc alternatives such as explicit :Destroy() methods, and type annotations with Luau's type checker.

Learn Luau by doing

Reading about metatables only goes so far — the pattern clicks once you break it and fix it yourself. Brush up on language fundamentals in What is Luau?, practice the concepts hands-on with Luablox problems and theory, or follow structured Luablox lessons that build OOP skills step by step.

Frequently asked questions

What does setmetatable do in Roblox?

setmetatable attaches a metatable to a table. The metatable can define special behaviors such as __index, which Luau consults when a key is missing — this is how Roblox OOP shares class methods across all instances of a class.

Why does my method call say attempt to call a nil value?

Usually the instance is missing the line Class.__index = Class. Without it, setmetatable links the metatable but lookups never fall through to the class, so the method cannot be found. Add that line immediately after declaring the class table.

Should I use a colon or a dot to call methods in Luau?

Use a colon (obj:Method()) when the function should receive the object as self, and match it with colon syntax in the definition. Use a dot only for functions that do not operate on an instance, such as Class.new(). Mixing them silently corrupts the self argument.

How do I inherit from another class in Roblox Luau?

Create the subclass with setmetatable(Sub, { __index = Parent }), then set Sub.__index = Sub. Constructors typically call the parent constructor, then re-apply setmetatable(instance, Sub) so overridden and inherited methods both resolve correctly.

Is OOP in Roblox worth learning for beginners?

Yes, once you know tables and functions. Classes pay off whenever you spawn many similar things — weapons, enemies, round systems. Start with the .new constructor and __index pattern, and only introduce inheritance when you genuinely need it.

Understand tables as sequence arrays and key-value dictionaries to manage dynamic data sets.

Related pages