All about ECS!
Foreword
Disclaimer: Interested in a ECS library that I recently made? Check out Jecs, a stupidly fast ECS with entity relationships as first class citizens for powerful query capabilities. But keep in mind that this post will not go over the usage of it!
I am basically going to copy the exact format of magnalite’s post All about Object Oriented Programming because it is the most known post of a similar topic. At times, I will even be using the same exact wording to make a reference, so I expect you to have read the aforementioned post.
For the tutorial, I will be using Matter because I believe it is the best available ECS, you can follow this page to get instructions on how to install it. I want to give acknowledgement to other great alternatives:
Table of Contents
What is ECS?How does it help me?
How do I make this work in Roblox?
Sources
What is ECS?
How does it help me?
Most who are new to programming find it difficult to express what they want to achieve with their code because of their lack of proper and fundamental approach to logic. Most believe that it is hard to break things down to step-by-step.
OOP tells to think in terms of objects with layers of abstractions that handles underlying processes. For example, let’s say you’re making a racing game. It would be useful to be able to do car = Instance.new(“Car”, Workspace)
and then be able to find useful information such as car.Position
or use methods such as car:Respawn()
. This is a problem because you are tying behaviour to a specific construct, which means you cannot use that behaviour without using that construct.
In simpler terms, you cannot use ::Respawn()
without car unless you inherit those traits and behaviours. So, to make objects, you do inheritance. The following snippet shows what has been described.
Car = require(game.ReplicatedStorage.Car)
Truck = {}
Truck.__index = Truck
function Truck.new(position, driver, model, powerup)
local newtruck = Car.new(position, driver, model)
setmetatable(newtruck, Truck)
newtruck.Powerup = powerup
return newtruck
end
local truck = Truck.new()
truck:Respawn()
To demonstrate the issue with this pattern, look at this example where both electric and gasoline cars inherits properties of Car
.
Once you add a new type of class
Hybrid
that inherits from both the Electric
and Gasoline
classes. Notice how in the diagram, this forms this kind of diamond shapeThe hybrid car is a subset of both the electric and gasoline car, which results in a duplication of paths which is impossible to express with a prototypal-like language, i.e Luau.
OOP claims that inheritance can be seen everywhere in the world, e.g. a child inherits traits such as hair-, eye- colour, skin tone, etc from their parents. But after acquiring traits from their parents, they also develop their own personalities on top of those traits. Think of it as a top-to-bottom hierarchy.
ECS does this the opposite way, it makes us think of what specific data a thing contains instead of what that thing is.
Pretend you have a game which uses ECS, and this game contains planes and ships, they may share a few traits such as position (where the game object is in the world), velocity (how fast it is), and etc. One day you want to update the game and add floatplanes, which can both fly in the air with its wings and yet float in the water with its hull. This is not a problem with an ECS, if you want your entity to fly, you just add a Fly
component or maybe a Gravity
component with negative values. If you want it to float, you just add a Float
component
Due to the nature of the ECS paradigm that allows for new components and systems to be added without conflicting with existing logic makes it suitable for games where many layers of overlapping behaviour will be defined on the same set of game objects, i.e. new behaviour defined in the future.
How does it work in Roblox?
Let’s begin with creating our world first!
local world = Matter.World.new()
In Matter, we construct components using the component function of the Matter namespace.
local Velocity = Matter.component("Velocity")
local Renderable = Matter.component("Renderable")
But these components don’t contain any data yet! When we spawn our entity, we need to call these components again with some data.
local arrowModel = game:GetService("ReplicatedStorage").Assets.ArrowModel
arrowModel.Parent = workspace
local arrow = world:spawn(
Velocity({ speed = 50}),
Renderable({ model = arrowModel })
)
Now we want to give some behaviour to our arrows, because right now they would be stationary. So we have to query the world for entities that both contain the data we are looking for, namely velocity and a renderable. We are going to use the component tables to query the world with. Behind the scenes, it is going to look for matching archetypes or a similar set of components and return every entity matching that description.
local function arrowsFly(world)
for id, vel, render in world:query(Velocity, Renderable) do
local currentPosition = render.model:GetPrimaryPartCFrame()
render.model:PivotTo(CFrame.new(currentPosition + Vector3.new(vel.speed, 0 ,0)))
end
end
The problem right now is that it is going to move 50 studs every single frame, you can imagine it is going to be incredibly fast. Luckily there exists a very useful hook function named useDeltaTime which gives us the time between frames.
render.model:PivotTo(CFrame.new(currentPosition + Vector3.new(vel.speed * Matter.useDeltaTime(), 0 ,0)))
Now let’s make it hurt some people on contact! We want to use the .Touched
signal to evaluate whenever the arrow has gotten any contact. Here we will use another useful hook useEvent
.
local function arrowsHurt(world)
for id, vel, render in world:query(Velocity, Renderable) do
for _, hit in Matter.useEvent(render.Model.PrimaryPart, "Touched") do
if Players:GetPlayerFromCharacter(hit.Parent) then
hit.Parent.Humanoid:TakeDamage(5)
end
end
end
end
Lastly we need to run these systems! We will be using the loop export from the Matter namespace. The Loop
class accepts multiple arguments which will be passed into every single system (look at each of our functions, they have a world parameter).
local loop = Matter.Loop.new(world)
loop:scheduleSystems({
arrowsFly,
arrowsMove,
arrowsHurt
})
loop:begin({
default: RunService.Heartbeat -- you can set it to whatever signal you want but commonly you will use Heartbeat
})
Sources
- Versus Series #1 - ECS vs OOP
- What is a diamond problem in Object-Oriented Programming?
- My RustConf 2018 Closing Keynote
Matter is a great ECS but Jecs design goal is to prioritize performance first. Its query iterators are as fast as iterating over arrays directly without overhead. If that is something that intrigues you, check out the benchmarks under the README: GitHub - Ukendio/jecs