As mentioned in the previous post , Luau does not have a classic class syntax; objects and their behavior are defined through tables and metatables.
Instead of rigid inheritance hierarchies, it is convenient to use the Entity-Component-System (ECS) approach, which is based on composition. Personally, I came to this architecture about four months ago without realizing it, as it seemed simpler and more natural to me. Later, I specifically rewrote the code for a cleaner ECS implementation.
Below, we will discuss what ECS is and why it is often chosen over OOP, as well as how to properly organize the code structure with a core and modules.
What is ECS?
- Entity is a maximally abstract container object, usually represented simply by a unique ID. The entity does not have its own data, it only connects the components describing its state.
- Component is a module or a table with pure data describing one characteristic of an object (for example, health, speed, position). Components in ECS do not contain logic – only data fields
- System is a set of logic for processing component data. At each frame, the system iterates over all entities that have the required set of components and updates them. Systems do not store entity data themselves, acting as a processing “pipeline”.
In other words, in ECS, everything is built around component composition rather than inheritance. The idea is to stop defining entities through a class hierarchy and start using composition. In ECS, the principle of “composition over inheritance” is widely accepted: Any object can be described as a set of small properties (components), and complex behavior arises from a set of corresponding systems.
Why ECS instead of OOP?
ECS is becoming increasingly popular in games and game development. For example, many modern games (Overwatch, Minecraft, Rust, etc.) use this architecture, I will immediately highlight the key advantages
- Weak interconnection: Logic is divided into systems and interacts through data in entities, rather than through rigid inheritance chains. This simplifies refactoring: new functionality can be added “on the side” without modifying existing code. In the ECS pipeline, everything is expressed in terms of data. The entity itself is essentially similar to an
Objectwith no guaranteed fields, and systems interact through shared data fields. - Modularity and reusability: Since components contain only data and systems contain only logic, they can be easily transferred between projects or run on any data. This means that the same system can be applied to different games or situations: it is enough to provide the expected format of the component data. At the same time, testing the systems is easier: you can run them on a dummy set of components.
- Combinatorics of properties: Any object can receive any combination of components on the fly. For example, you can add a
Health componentto a camera, and the damage system will automatically start processing it; then you can attachOnFire, and the camera will receive damage from fire. This is especially useful for dynamic game worlds, where game designers can add new features to entities on the fly. There are no restrictions on the combination of components, so you can create hybrids from arbitrary data without worrying about inheritance issues. - Simplicity of SRP (Single Responsibility): Each system in ECS is responsible for one specific task (such as movement, rendering, or physics). The logic is purely functional and separated from the data, making it easy to control and divide as needed. The system code is concise and clear (usually a for loop over a set of components), making it easier to profile and debug
In the end, ECS is a really awesome framework for your game, because it gives you something that any other project doesn’t have - flexibility. By the way, classic functional programming is much closer than you might think, but more on that another day
How to Structure ECS in Luau
Now that we’ve established the why, let’s look at the how. Since Luau doesn’t provide class syntax, ECS fits very naturally with its table philosophy. The goal is to keep data (components) and logic (systems) decoupled while letting entities act as simple identifiers that glue everything together.
- Entity Manager: Responsible for creating and destroying entities. Typically, an entity is just an integer ID. The manager may also maintain a mapping of entities to their assigned components.
- Component Storage: Each component type (like
Health,Transform,Velocity) has its own table keyed by entity IDs. This way, systems can quickly fetch the data they need without searching through all entities.
local Health = {}
Health[entityId] = { value = 100, max = 100 }
- systems: Each system is just a function or module that iterates over entities with specific components. For example, a
MovementSystemmight look for entities that have bothTransformandVelocity.
local function MovementSystem(entities, Transform, Velocity, dt)
for id in pairs(entities) do
local pos = Transform[id]
local vel = Velocity[id]
if pos and vel then
pos.x += vel.x * dt pos.y += vel.y * dt
end
end
end
But let’s be honest, it’s inconvenient to write such functions forever… I agree. That’s why we make engines
ECS in a Roblox engine composition
Roblox itself offers an object-oriented paradigm: everything in the game is a hierarchy of Instance-objects that have methods, events, and properties. At first glance, it may seem that ECS and Roblox-style conflict, but in reality, they perfectly complement each other. ECS can be layered on top of Roblox’s classic object model and utilize the engine’s built-in features, such as CollectionService.
Components via tags?
In Roblox, each Instance can be tagged using CollectionService. The tag effectively acts as a “component”
local CollectionService = game:GetService("CollectionService")
CollectionService:AddTag(npcModel, "Enemy")
CollectionService:AddTag(npcModel, "HasHealth")
systems with listeners
Systems can be implemented as modules that listen to CollectionService:GetInstanceAddedSignal(tag) and GetInstanceRemovedSignal(tag) events, allowing the system to “come to life” when an object with the desired set of tags is added.
local CollectionService = game:GetService("CollectionService")
local function DamageSystem()
CollectionService:GetInstanceAddedSignal("HasHealth"):Connect(function(obj)
obj:SetAttribute("Health", 100)
end)
CollectionService:GetInstanceRemovedSignal("HasHealth"):Connect(function(obj)
print(obj)
end)
end
return DamageSystem
Similarly, you can implement movement, animation, interaction, and other systems that simply follow objects with the appropriate tags.
A combination of ECS and OOP
Instead of choosing between “ECS only” and “OOP only” in Roblox, you can use both approaches:
- ECS manages “high-level game logic”: a set of tags determines which systems are applied to an object. This is flexible and allows you to quickly combine properties.
- Roblox’s regular OOP is reserved for low-level details: Humanoid, BasePart, RemoteEvent, and other classes provide ready-made mechanics on which components are built.
An NPC can be a model with a Humanoid (the OOP part), but in ECS, it gets the Enemy, HasHealth, and CanAttack tags. The attack and damage logic is handled by the systems, while the Humanoid is responsible for physics etc.
In the end, we come to the conclusion that the best option is the ECS abstraction layer, without completely reshaping the paradigm. You can still use OOP within ECS. In fact, every system you create, unless it's a function or a table (which is recommended), is an object that was created using OOP. Therefore, it's challenging to consider ECS as a complete replacement for OOP, unless you create your own engine within the existing one, but even then, you'll need to use OOP for interaction. Essentially, they operate on different layers.
why? (The advantages of this approachuses):
- We use Roblox’s native features, without unnecessary layers of abstraction.
- Systems automatically respond to the creation and deletion of objects.
- Components (tags and attributes) can be easily edited directly in the Studio, making it convenient for designers.
- ECS remains an “add-on” rather than a replacement for the standard engine.
ECS in composition with Roblox turns into a lightweight tool that uses the engine’s tags and events to organize flexible logic. We don’t rewrite Roblox-OOP, but simply add a composition layer to enjoy the convenience of ECS: modularity, reusability, and loose coupling of systems.
Download structure made by me: (its not only about ECS)
Struct.rbxm (8,7 КБ)
Tutorial
This structure is built around simplicity and clarity: there is Core (the core), Controllers (input and client logic controllers), and Systems (systems that respond to tags and attributes). Together, they create a convenient ECS layer on top of Roblox.
- Creating controller
A controller is a module that listens for any actions. For example, InputController. For the sake of simplicity, I will say “anything that is not a system is a controller,” which is a bit more complicated, but ultimately correct in relation to this structure. - CreatingSystem
Write your tag in analogy with the example, after creating methods InitializeEntity, DeInitializeEntity and OnUpdate. If your system is reactive then get rid of OnUpdate which is triggered every frame, after all optimization is an important thing. Classic ECS like every frame is processed for each system, but it seemed like nonsense to me, except for the fact that it is useful for laying out
Then, simply add a tag to the object, and everything will work.
You can see an example of their implementation in the modules I have already prepared.
- 1
- 2
- 3
- 4
- 5
Also will be useful:
Pseudo-OOP in luau: Click here
Deterministic and non-deterministic state machines: Click here