OOP sucks
OOP this, OOP that, OOP yourself.
Do your framework's services look like this?
-- ServerScriptService/Services/RoundService
local Players = game:GetService("Players")
local RoundService = {}
RoundService.__index = RoundService
function RoundService.new()
local self = setmetatable({}, RoundService)
self.InProgress = false
self.Alive = {} :: { [Player]: true }
return self
end
function RoundService:Start()
self.InProgress = true
for _, player in Players:GetPlayers() do
self.Alive[player] = true
end
end
function RoundService:Eliminate(player: Player)
self.Alive[player] = nil
end
function RoundService:IsAlive(player: Player): boolean
return self.Alive[player] == true
end
function RoundService:End()
self.InProgress = false
table.clear(self.Alive)
end
return RoundService
-- ServerScriptService/Main
local Services = {}
for _, module in ServerScriptService.Services:GetChildren() do
Services[module.Name] = require(module).new()
end
Looks fine, right? The round state lives on the object, everything goes through methods, and there's a neat little loader that boots every service the same way. This is how a lot of frameworks are structured.
Now count how many RoundServices exist. One. .new() is called exactly once, by the loader, for the entire lifetime of the server. Nothing will ever call it again, and if something did, you'd have two rounds running with two different lists of who's alive.
That's a class with one instance. It has a constructor that constructs one thing, a metatable that serves one table, and a self that is always the same self. You wrote a factory for a single product.
Here's the same thing without the class:
-- ServerScriptService/Services/RoundService
local Players = game:GetService("Players")
local RoundService = {}
local inProgress = false
local alive: { [Player]: true } = {}
function RoundService.Start()
inProgress = true
for _, player in Players:GetPlayers() do
alive[player] = true
end
end
function RoundService.Eliminate(player: Player)
alive[player] = nil
end
function RoundService.IsAlive(player: Player): boolean
return alive[player] == true
end
function RoundService.IsInProgress(): boolean
return inProgress
end
function RoundService.End()
inProgress = false
table.clear(alive)
end
return RoundService
require already runs a module once and hands every script the same table, so a ModuleScript is the single instance. No .new(), no metatable, no self, no loader. And alive is a local, so it's actually private instead of sitting on self where anything holding the service can reach in and edit it.

I've been onboarded onto enough games to know this isn't a rare thing. Every system is a class. DataService.new(), RoundService.new(), ShopService.new(), CameraController.new(), all instantiated once, all sitting in a Services or Controllers folder. And when I ask why, the answer is almost always some version of "because that's how you're supposed to organize code" or "there is no better way to do it".
So let's talk about why OOP sucks, and why it sucks specifically in Luau.
What OOP even is
OOP (Object-Oriented Programming) is the idea that you bundle data and the functions that operate on that data into one thing called an object. Assume a Car has a Speed property and a Drive() method. You make a class (the blueprint) and stamp out instances (the cars).
It usually comes with four pillars that every CS course makes you memorize:
- Encapsulation: The object hides its internals, you only touch it through methods.
- Inheritance: A
SportsCaris aCar, so it gets everything aCarhas. - Polymorphism: You can call
:Drive()on any car and each one does its own thing. - Abstraction: You don't need to know how
:Drive()works, just that it does.
Luau doesn't have classes
Luau has no class keyword yet. What it does have is tables and metatables, and OOP in Luau is a "hack" built with those:
local Car = {}
Car.__index = Car
function Car.new(speed: number)
local self = setmetatable({}, Car)
self.Speed = speed
return self
end
function Car:Drive()
print("Driving at", self.Speed)
end
Here's what's actually going on:
Caris a plain table that holds the methods.Car.__index = Carsays "if you index an instance and the key isn't there, go look inCar".setmetatable({}, Car)creates an empty table and attachesCaras its metatable.car:Drive()is syntax sugar forcar.Drive(car). The:passes the table in asself.
So when you call car:Drive(), Luau looks for Drive on the instance, doesn't find it, falls through __index into Car, finds the function, and calls it with car as the first argument.
And this brings up the first problem. You're emulating a feature the language doesn't have yet.
And even when Luau does get a class keyword, it shouldn't be treated as an invitation. Nicer syntax doesn't make a DataService with one instance any less pointless. It just makes it easier to write.
The negatives
1. Inheritance is a trap
Once you have classes, it's tempting to start stacking them:
local Weapon = {}
Weapon.__index = Weapon
local Gun = setmetatable({}, Weapon)
Gun.__index = Gun
local Rifle = setmetatable({}, Gun)
Rifle.__index = Rifle
local AK47 = setmetatable({}, Rifle)
AK47.__index = AK47
Now call ak:Equip(). Luau checks the instance, then AK47, then Rifle, then Gun, then Weapon. Four __index hops to find one function.
Luau's performance docs explicitly say to avoid this: "it's strongly recommended to avoid __index functions as well as deep __index chains." The VM's inline caching is built around objects with one metatable that points to itself.
The bigger issue is when something doesn't fit the tree. Say you add a crossbow. It shoots projectiles like a Gun, but it has no magazine. Now you either copy part of Gun or put it under Gun and override the bits that don't apply.
Joe Armstrong (the creator of Erlang) had a good line for this in Coders at Work:
You wanted a banana but what you got was a gorilla holding the banana and the entire jungle.
Often, what you actually want is data describing what a weapon has:
return {
AK47 = { Damage = 30, FireRate = 0.1, Magazine = 30, Projectile = "Bullet" },
Crossbow = { Damage = 80, FireRate = 1.2, Magazine = nil, Projectile = "Bolt" },
}
And functions that operate on whatever has the right fields. fire(weapon) doesn't care if it's a gun or a crossbow. It cares that there's a FireRate and a Projectile.
2. Hidden state everywhere
Once everything is an object, every object holds references to other objects. Round holds Players, each PlayerObject holds its Inventory, the Inventory holds a reference back to the PlayerObject so it can check cash, and Shop holds a reference to Round so it knows when it's allowed to open.
Now :BuyItem() is one line, and to understand what it does you have to know the current state of four objects, any of which could have been mutated by anything that has a reference to it.
Brian Will's "Object-Oriented Programming is Bad" is the best breakdown of this I've seen. His point is that the "sea of independent objects passing messages" idea doesn't survive contact with real code. Objects need to talk to each other, so they end up holding references to each other, so encapsulation is basically a lie. The state is still shared, it's just harder to find.
Ilya Suzdalnitski's Object-Oriented Programming — The Trillion Dollar Disaster goes way harder than I'm willing to, but the core argument is the same: OOP encourages sharing mutable state, and shared mutable state is where bugs live.
Compare that with a function where everything it needs comes in through its arguments:
local function canBuy(coins: number, item: Item, shopOpen: boolean): boolean
return shopOpen and coins >= item.Price
end
You can read it top to bottom and know exactly what it does. You can test it in the command bar without spinning up a round.
3. Thinking in "one" instead of "many"
Say you have 500 zombies. The OOP instinct is:
for _ = 1, 500 do
local zombie = Zombie.new(spawnPoint)
-- each one connects its own Heartbeat and its own Died
end
500 metatables, 1000 connections, 500 :Destroy() methods you have to remember to call.
Or:
local zombies: { [Model]: ZombieData } = {}
RunService.Heartbeat:Connect(function(dt)
for model, data in zombies do
updateZombie(model, data, dt)
end
end)
One connection. One loop. When a zombie dies, you remove one key.
Mike Acton's Data-Oriented Design and C++ talk (CppCon 2014) is built on this exact idea: where there's one, there's many. Design for the many. And Casey Muratori's "Clean" Code, Horrible Performance shows the same polymorphic "shape class" example running over 10x faster once you drop the class hierarchy for a plain switch over the data.
Now, those are C++ numbers and Luau isn't C++. Same as I said in the FFC post, performance isn't really my argument. Luau's VM is genuinely optimized for method calls when you use the "ideal" single-metatable object. The argument is that the one-loop version is shorter and has fewer places to leak.
Don't believe functional programming is more performant than an OOP approach? Try benchmarking this here with the official Luau compiler: https://play.luau.org/.
local N = 2^22
-- Class
local Zombie = {}
Zombie.__index = Zombie
function Zombie.new(zombie: any)
local self = setmetatable({}, Zombie)
self.zombie = zombie
return self
end
function Zombie:Destroy()
self.zombie = nil
end
-- Benchmark
local start = os.clock()
for i = 1, N do
local zombie = Zombie.new(i)
zombie:Destroy()
end
print("OOP", os.clock() - start)
-- Benchmark
local start = os.clock()
for i = 1, N do
local zombie = i
zombie = nil
end
print("Functional", os.clock() - start)
x16 faster. Most OOP codebases chain method calls, which would lead to functional programming being much more than x16 more performant.

So what do I do instead?
Modules of functions, and plain tables of data. Functional programming is what Luau was made for.
local Inventory = {}
export type Inventory = { [string]: number }
function Inventory.Add(inventory: Inventory, item: string, amount: number)
inventory[item] = (inventory[item] or 0) + amount
end
function Inventory.Has(inventory: Inventory, item: string): boolean
return (inventory[item] or 0) > 0
end
return Inventory
Inventory.Add(profile.Inventory, "Sword", 1)
- The data is a plain table that anything can read, no getters needed.
- Every function gets what it needs through its arguments, so there's no hidden state.
- It's still organized.
Inventory.Addreads the same asinventory:Add. You didn't lose anything.
That last point is the one I want to hammer in. Modules are organization. You don't need a metatable to group related functions together.
The one case where OOP is right
I'm not going to pretend OOP has zero use. There's exactly one case where I personally reach for it, and it's the thing OOP was supposed to be about in the first place: reusable things that own state.
Specifically, when all of these are true:
- You need many independent instances of it, created and thrown away at runtime.
- Each instance owns an internal state that has to stay consistent.
- It's generic. It doesn't know anything about your game.
My favorite classic example is a Janitor/Maid.
local janitor = Janitor.new()
janitor:Add(workspace.ChildAdded:Connect(onChildAdded))
janitor:Add(part)
janitor:Add(function()
print("cleaned up")
end)
janitor:Cleanup()
Every system that spawns stuff wants its own janitor. Each one tracks its own list of things to clean, and that list has rules (connections get :Disconnect(), instances get :Destroy(), functions get called). You can't hand someone the raw table and trust them with it. And it has nothing to do with any particular game.
My own InfArray module is also a class. Every array owns a list of chunks, a manual length, and a manual count, because # can't be trusted once there are holes. In a sense, I had to go with an OOP (metatable) approach to allow for shorthand with metamethods to mimic the usage of a native table syntax.
local sieve = InfArray.new(n, 0)
sieve:Set(4, 1)
print(#sieve)
That's what encapsulation is actually for. Signals, Promises, Springs, Trove, InfArray, they all fit. Notice what doesn't fit: DataService, RoundService, CameraController. Those aren't reusable, there's only ever one of them, and the state they "protect" is state the rest of your game needs to read anyway.