MediumEmulating Object Oriented Programming in Roblox Development | by Uche Uche-Ike | Nov, 2026 | Medium
Sitemap
Uche Uche-Ike

Uche Uche-Ike

Software Engineer. Game Dev. Game Designer. In that order.

Emulating Object Oriented Programming in Roblox Development

12 min readNov 5, 2026

--

For the past several weeks, I’ve been diving back into Roblox development, and reacclimating myself has been a blast, to say the least.

Press enter or click to view image in full size
Thumbnail Image of Marble Statue & Luau logo

I’ve had the most fun relearning Object-Oriented Programming (OOP) in Luau — or rather, the lack of it. Luau has a barebones syntax and no traditional class setup, but we can emulate OOP patterns using techniques that we’ll discuss.

The more interesting challenge is making those designs understandable and easy to debug. Recently, Roblox introduced strict type checking. Strict means the Luau type checker automatically tracks value types and flags type errors as we write code.

--!strict  --required at the top of the script!
local function deposit(balance: number, amount: number): number
return balance + amount
end

print(deposit(100, 20))
print(deposit("100", 20)) -- this gets flagged in the editor!

This, combined with type references, makes it very easy to catch mistakes in the editor before they become runtime errors.

Which is a nice segue to the topic of today:

How do we build classes that are easy to reuse, understand, and catch mistakes in a language without built-in class syntax?

But before we do all that, we need to understand how we write OOP classes in Luau.

It’s Tables All The Way Up

This is going to be a long one, so we’ll start nice and easy. Here’s Roblox’s canonical OOP example without strict typing or type references.

local Account = {}
Account.__index = Account

function Account.new(name, balance)
local self = {}
self.name = name
self.balance = balance

return setmetatable(self, Account)
end

function Account:Deposit(amount)
self.balance = self.balance + amount
end

local account = Account.new("John Doe", 500)

That’s a lot of boilerplate. Let’s break it down.

As mentioned before, Luau doesn’t have classes like traditional programming languages like Python. Tables are the main data object for anything and everything.

Tables are most similar to hash maps in traditional languages. If you’re familiar with Python, Luau’s syntax is shockingly similar!

local Account = {
name = "John Doe",
balance = 100,
}

-- Inserts a new key-value pair using bracket notation
Account["balance"] = 150
-- Alternatively, we can use dot notation
Account.name = "Jane Doe"

In Luau, functions are first-class citizens, meaning you can assign them to variables.

local balance = 100

local deposit = function(amount)
balance = balance + amount
end

Or you can even….

local balance = 100

local Account = {
deposit = function(amount)
balance = balance + amount
end
}

…add a function to a table!

We add functions and values to tables to create rudimentary classes in Luau.

local Account = {
name = "John Doe",
balance = 100,
deposit = function(self, amount)
self.balance = self.balance + amount
end
}

The self parameter refers to the original table, so the deposit function here updates the value of balance in the Account table.

If you’ve ever written in Python, it’s basically the same as the keyword self.

class Account:
def __init__(self):
self.name = "John Doe"
self.balance = 0
def deposit(self, amount):
self.balance += amount

So now we have a basic Account class with member variables and methods, but no easy way to create instances of it.

local Account = {
name = "John Doe",
balance = 100,
deposit = function(self, amount)
self.balance = self.balance + amount
end
}

print(Account.name) -- prints "John Doe"
Account.deposit(100)
print(Account.balance) -- prints 200

We take this setup a step further and define a dedicated constructor function that creates new instances of our Account class.

local function accountConstructor(newName)
local Account = {
name = newName,
balance = 100,
deposit = function(self, amount)
self.balance = self.balance + amount
end
}
return Account
end

Now we can have easily create multiple independent instances of our Account class

local johnAccount = accountConstructor("John")
local janeAccount = accountConstructor("Jane")

johnAccount:deposit(100)

print(johnAccount.balance) -- 100
print(janeAccount.balance) -- 0

Each account owns its own balance. Depositing into John’s account doesn’t affect Jane’s.

And this approach works… somewhat. The major problem with this is our deposit method. Every time we call the accountConstructor, we create a new functionally identical instance of the deposit function.

Press enter or click to view image in full size
Diagram of Account Instances and Deposit Functions

As our programs scales and we create hundreds of Account instances, this becomes a huge waste of resources. It’d be much more efficient to define the function once and have all instances of our class use it. Now, if only there were a way…

Metatables

Metatables are tables that contain Metamethods. Metamethods are special functions that are invoked when a certain action is performed on a table. The full list of metamethods is listed here — they’re all super cool!

The most important one for our case is __index(table, index), which affects how indexing is resolved on a table.

Simply put, if we try to access a value from the table and it isn’t present, we check whatever table is set as the index for that value.

local BaseAccount = {
currency = "USD"
}

local UserAccount = {}
local metatable = {__index = BaseAccount}

print(UserAccount["currency"]) -- prints nil
setmetatable(UserAccount, metatable)
print(UserAccount["currency"])

Ordinarily, our attempt to access the currency key in UserAccount would fail, but since we’ve defined a metatable, any index to the UserAccount table first checks the UserAccount table itself, then the BaseAccount table!

Now let’s circle back to our class implementation. Let’s use this same concept to avoid the multiple function issue we referenced before: we’ll create separate table that houses all of our class methods.

local AccountMethods = {
deposit = function(self, amount)
self.balance = self.balance + amount
end
}

function accountConstructor(value: number)
local Account = {
name = newName,
balance = 0
}
setmetatable(Account, {__index = AccountMethods})
return Account
end

Now we can use all the methods of our Account and we only have to define them once!

local johnAccount = accountConstructor("John")
local janeAccount = accountConstructor("Jane")

johnAccount:deposit(100)

print(johnAccount.balance) -- 100
print(janeAccount.balance) -- 0

print(johnAccount.deposit == janeAccount.deposit) -- true

The balances belong to each account, while the deposit is resolved through the shared method table. We’ve separated instance data from shared methods.

Back to Accounting

Now that we’ve completed our training, the Roblox’s example Account class should make a lot more sense.

local Account = {}
Account.__index = Account

function Account.new(name, balance)
local self = {}
self.name = name
self.balance = balance

return setmetatable(self, Account)
end

function Account.deposit(self, amount)
self.balance += amount
end

local account = Account.new("Alexander", 500)

Like before, let’s break things down step by step

local Account = {}

The first Account table holds all the methods for our Account class.

function Account.new(self, credit)

function Account.new(name, balance) looks scary at first, but it’s just syntactic sugar — a simpler way of writing this:

Account["new"] = function(name, balance) 
-- Create the new account
end

-- alternatively, we can write..

Account.new = function(name, balance)
--Create the new account
end

With our knowledge in mind, deciphering this constructor (new) is a piece of cake.

local Account = {}

function Account.new(name, balance)
local self = {}
self.name = name
self.balance = balance

return setmetatable(self, Account)
end

We first create a new table called self; it contains all the member variables unique to this account instance, such as the name and balance.

We finally set the metatable of self to be the Account table. To reiterate, the Account table’s main purpose is for handling methods specific to this class.

One small wrinkle readers may have noticed is that we defined our metamethods and the class methods in the same Accounts table; whereas in our previous examples, we kept the metamethods and class methods in separate tables. Both approaches are functionally identical.

Especially eagle-eyed readers might notice that this pattern is effectively identical to the Prototypal Inheritance you’ll find in JavaScript.

Press enter or click to view image in full size
A Diagram of the Prototype Pattern. With a Class on the left with several methods connected to a Data Instance with several Fields. This highlights the separation of data and functionality in the prototype.
Diagram of Prototypal Inheritance Pattern

The Account table is the prototype that exposes a method new to make new instances of it. self is the data instance.

Get Uche Uche-Ike’s stories in your inbox

Join Medium for free to get updates from this writer.

From here on out, I’ll refer to the table containing all the methods as a Prototype and the individual instance of the class with all the member variables as the Data Instance.

Describing Classes with Types

That was one hell of a background, but hopefully all this context makes things make a bit more sense when we introduce strict typing and type references. Here’s Account example from Roblox’s official docs but with strict typing.

local Account = {}
Account.__index = Account

type AccountData = {
name: string,
balance: number,
}

export type Account = typeof(setmetatable({} :: AccountData, Account))

function Account.new(name, balance): Account
local self = {}
self.name = name
self.balance = balance

return setmetatable(self, Account)
end

function Account.deposit(self: Account, amount: number): ()
self.balance += amount
end

It’s largely the same as the Account class we looked at just a second prior. The most notable thing is the new type declaration we made for Account.

type AccountData = {
name: string,
balance: number,
}

export type Account = typeof(setmetatable({} :: AccountData, Account))

Here, the AccountData type refers to the contents of our Data Instance; that’s what self refers to inside the constructor. self’s metatable points to the table Account.

Press enter or click to view image in full size

A subtle different you probably didn’t notice is that all the methods in the strict-typing version of the Account class are made using dot-notation.

--!strict
function Account.deposit(self: Account, amount: number): ()
self.balance += amount
end

Whereas in the non-strict typing version, we used colon notation. Colon notation is syntactic sugar for dot notation. The main difference is that with Colon notation, the self parameter is added implicitly.

function Account:deposit(credit) -- self is an implicit parameter here
-- can still access self, even though its not declared as a parameter
self.balance += credit
end

Using dot notation, we can add a type reference to self and enforce the strict typing check on whatever member variables or class functions we use in the method.

function Account.deposit(self: Account, amount: number): ()
-- Works fine!
self.balance += amount
-- Shows error in editor
self.account_type = "Savings"
end

Whereas without strict typing, we could easily overlook this mistake.

Making the Pattern My Own

Since reacclimating into Roblox development, I’ve made my current style a mixture of my own preferences alongside some tricks I found by other Luau developers.

Here’s the set up for the Account class implementation again in my style.

local AccountPrototype = {}
AccountPrototype.ClassName = "Account"
AccountPrototype.__index = AccountPrototype

type AccountData = {
name: string,
balance: number,
}

type AccountPrototype = typeof({ __index = AccountPrototype })

export type Account = typeof(setmetatable(
{} :: AccountData,
{} :: AccountPrototype
))

-- Class Methods here are largely unchanged

function AccountPrototype.Destroy(self: Account): ()
setmetatable(self :: any, nil)
table.freeze(self)
end

table.freeze(AccountPrototype)

Let’s break it down.

The first things that probably catches your eye is that I don’t name the table with the class methods “Account”. Rather, I mimic the naming convention of the Prototype pattern I mentioned a second ago. AccountPrototype for the table that store class methods and AccountData for the table that stores class member variables. I find these names useful because they make the distinction between stored data and shared methods explicit.

local AccountPrototype = {}
AccountPrototype.ClassName = "Account"

The next thing that probably catches your eye is fields ClassName. Since Lua has no compile time type-checking, giving each class an explicitClassName field makes it possible validate classes at runtime.

--!strict 
local function isAnAccount(object: {any})
return (object.ClassName == "Account")
end

function Bank.RegisterAccount(self: Bank, account: Account)
-- Code crashes immediately if the account argument isn't valid
assert(isAnAccount(account), "\"account\" argument must be an instance of Account class")
-- Registration logic here...
end

Finally, there’s the logic for destroying the class.

--!strict
function Account.Destroy(self: Account): ()
setmetatable(self :: any, nil)
table.freeze(self)
end

I like to create a destroy method with three essential steps:

Removing the metatable prevents the instance from finding methods through its prototype. Freezing the table prevents further changes to its fields.

local johnAccount = Account.new("John Doe")
johnAccount:Destroy()

johnAccount.deposit(100) -- Error
johnAccount.name = "Jack" -- Error

Both these actions mean that once destroyed an Account instance is effectively useless.

Programming to an Interface

So far, our systems have known exactly what kind of account they’re working with.

But often, a system doesn’t need to know the specific class. A reward system, for example, might only care that it has something with a Deposit method.

That’s where interfaces come in. Instead of describing one particular class, an interface describes the methods another system can rely on.

In traditional languages, we may declare a class as an Interface or define Abstract methods.

from abc import ABC, abstractmethod

class DepositAccount(ABC):
@abstractmethod
def deposit(self, amount: float):
pass

class Account(DepositAccount):
def __init__(self):
self.deposit = 0
def deposit(self, amount: float) -> bool:
self.deposit += amount

In Luau, there’s no pecial keyword for interfaces. We define a type that describes the shape another object needs to have.

export type DepositAccountInterface = {
Deposit: (self: DepositAccountInterface, amount: number) -> (),
}
local function awardWelcomeBonus(account: DepositAccountInterface)
account:Deposit(100)
end

The payoff is that any class can satisfy this contract, as long as they provide a compatible Deposit method.

When creating classes that implements this interface, we can create an intersection between the type and the class type.

local AccountPrototype = {}
AccountPrototype.ClassName = "Account"


export type Account = typeof(setmetatable(
{} :: AccountData,
{} :: (AccountPrototype & DepositAccountInterface)
))
awardWelcomeBonus(AccountPrototype.new("John", 0))

We use an intersection type to say that AccountPrototype must provide both its own methods and everything required by DepositAccountInterface.

//.. Account boilerplate

function AccountPrototype.new(name, balance): Account
local self = {}
self.name = name
self.balance = balance

// Error here: Account instance is missing deposit method
return setmetatable(self, Account)
end

Now unless we define the deposit method, we’ll see a strict typing error in our constructor method.

Specialising with Inheritance

Let’s define a SavingsAccount class as a subclass of Account.

local SavingsAccountPrototype = {}
SavingsAccountPrototype.Super = Account
SavingsAccountPrototype.ClassName = "SavingsAccount"

setmetatable(SavingsAccountPrototype, { __index = AccountPrototype })
SavingsAccountPrototype.__index = SavingsAccountPrototype

type SavingsAccountData = AccountData & {
interestRate: number,
}

type SavingsAccountPrototype = typeof({ __index = SavingsAccountPrototype })

export type SavingsAccount = typeof(setmetatable(
{} :: SavingsAccountData,
{} :: SavingsAccountPrototype
))

The set up is similar to the Account we looked at a second ago with three key differences.

setmetatable(SavingsAccountPrototype, { __index = AccountPrototype })

We create an anonymous table with the __index set asAccountPrototype . And set SavingsAccountPrototype’s metatable to be that anonymous table. This sets up a chain between the two prototypes that instances of SavingsAccount follow.

Press enter or click to view image in full size

For any field thats not present in the SavingsAccountData, we check SavingsAccountPrototype and then AccountPrototype. This set up effectively simulates inheritance properties like method overriding.

type SavingsAccountData = AccountData & {
interestRate: number,
}

We define the type of SavingsAccountData to be the intersection of its member variables and AccountData’s member variables (that’s why separating them is useful!). So our new SavingsAccountData class has the same member variables as AccountData plus the new interestRate field.

--!strict

function SavingsAccountPrototype.applyInterest(self: SavingsAccountPrototype): ()
-- No type check error on "balance"
local interestAccumulated = self.balance * self.interestRate
-- No type check error on "deposit"
self:deposit(interestAccumulated)
end

local savings = SavingsAccountPrototype.new("Jane", 1000, 0.05)

savings:Deposit(200)
savings:ApplyInterest()

print(savings.balance) -- 1260

Deposit is not defined on SavingsAccountPrototype, so Luau finds it through the prototype chain. ApplyInterest is defined on the savings prototype and uses that inherited method.

Explicit Parent Access

SavingsAccountPrototype.Super = Account
--...

function SavingsAccountPrototype.new(name: string, initial_balance: number, interestRate: number): SavingsAccount
local self = self.Super.new(name, initial_balance)
self.interestRate = interestRate
setmetatable(self, SavingsAccountPrototype)

return self
end

Near the top of the file, we define the Super , the parent class, of SavingsAccountPrototype to be AccountPrototype .

We could alternatively just call using AccountPrototype table, but I find that this way makes it more obvious that the function call is meant to call the parent class’ constructor.

Class Validation With Inheritance

You may recall that for my Account class I suggested adding a special ClassName field.

AccountPrototype.ClassName = "Account"

We could add a new case to the isAnAccount function we made earlier..

function isAnAccount(self: Account):
return self.ClassName == "Account" or self.ClassName == "SavingsAccount"

But it scales poorly and separates the class inheritance logic from the class definition. I’m not a fan of it.

Alternatively, we can define an IsA() method for all classes

--!strict

function AccountPrototype.IsA(self: AccountPrototype, className: string): boolean
local currentClass = getmetatable(self) or self

while currentClass do
if currentClass.ClassName == className then
return true
end

currentClass = getmetatable(currentClass)
local isAClass: boolean = type(currentClass) == "table" and currentClass.__index
if isAClass then
currentClass = currentClass.__index
else
break
end
end

return false
end

For all our Account related validation checks, we can just verify whether the class is an “Account”.

function Bank.RegisterAccount(self: Bank, account: Account)
-- Works for both SavingsAccounts and regular Accounts
assert(account:IsA("Account"), "\"account\" argument must be an instance of Account class")
-- Registration logic here...
end

Conclusion

Relearning about Object Oriented Programming in Roblox Studio has easily been the most enjoyable part of me returning to the platform. Coming from more traditional programming languages makes you take Class implementations for granted, and having to think critically about how to emulate those patterns in Luau has given me a newfound appreciation for OOP. I hope this article helps you better understand how classes, tables, work and how we can leverage OOP patterns to write more robust Luau code.

--

--

Written by Uche Uche-Ike

Software Engineer. Game Dev. Game Designer. In that order.