Custom ECS Engine Code Review

Place File: ECSCodeReview.rbxl (94.2 KB) Looking for any feedback, both positive & negative!
(Please note this current iteration is unfinished, although still functional to an extent.)

hierarchy structure

The Flow -

My (hybrid) Entity Component System consists of three main classes that do the following -

  • Cache: The central data handler responsible for handling the life cycle of entities & their components, archetyping & contiguous data storage, & system queries. My setup is designed to consist of TWO caches per session, one dedicated to both the Server & the Client.

  • System: The base class that all my systems derive from, they operate primarily through queries or through an event-driven approach.

  • System-Manager: I also include two of these instances per sessions (for the client and server respectively) & charge them with the priority-based execution of all systems via runservice as well as pushing updates-such as entity removals from a specific bitmask-to all relevant systems.

All of these objects are leveraged by two scripts, the mainServer & mainLocal which handle the initialization.

Final Notes -

This is my first attempt an implementation, so yes I am aware I’ve probably made my fair share of mistake here, though I would like to know what those are as opposed to being blissfully unaware lol. As stated, it’s unfinished; The last real step needed to achieve complete functionality is replication (as currently the client & server cache’s are completely seperated) which I’ve started. My plan is to use an event-driven replication system on the server that listens to all of the fantastical happenings of the cache (entity additions, component-mutations, etc.) & batches them together before serializing/hashing them into a buffer to send off. A cache-map “NetworkID” unique to both sides of the network is used to bridge the gap between local entities and server replicated ones.

Upon opening the game, if you play-test you can view some of the functioning bits & pieces of the framework via the output in the console log.

3 Likes

Real issue

That’s not ECS; that’s OOP with middleware.

ECS is more of:

local Armor = {}::{[Player]:number}--Component

local PlayerAdd = function(Plr:Player):()
--Plr is an entity
	Armor[Plr] = 100
end

local PlayerRemove = function(Plr:Player):()
	Armor[Plr] = nil
end

local Players = game:GetService("Players")
Players.PlayerAdded:Connect(PlayerAdd)
Players.PlayerRemoving:Connect(PlayerRemove)

other example of struct-like ECS:

local PlayerData = {}::{[Player]:buffer}--[[
+0 Health
+4 Armor
]]

local PlayerAdd = function(Plr:Player):()
	local Data = buffer.create(8)
	buffer.writeu32(Data,0,100)--Health
	buffer.writeu32(Data,4,100)--Armor
	PlayerData[Plr]=Data
end


local PlayerRemove = function(Plr:Player):()
	Armor[Plr] = nil
end

local Players = game:GetService("Players")
Players.PlayerAdded:Connect(PlayerAdd)
Players.PlayerRemoving:Connect(PlayerRemove)

Roblox developers should wake up.
Middleware is a cage.
Optimization is freedom.
Every unnecessary layer.
Every unnecessary allocation.
Every unnecessary abstraction.
Someone pays for it.
Optimize or Be Optimized. :gear:

Because ECS is just an extension of functional & procedural programming with a focus on code separation.
Any attempts to tie it towards OOP or classes are a great misunderstanding of what ECS truly is.
Real developers choose truth :vulcan_salute:

Next problem:

Use of a single remote event
RemoteEvent has a cost
A very measurable cost of its instance ID
While we can’t do anything about this real cost, we can make it not go to waste:
Use remote events as a batch layer
This way you DON’T make this signature go to waste and instead act as a batching layer.
YES, min-maxing remote events is surprisingly a good idea.
OFF and ON switches, while it may sound weird, make sense to be 2 remote events.
This does not apply for cases where you can send all data at once, however, and only applies to independent cases like button pressing.

1 Like

While I agree that my ECS is a hybrid of OOP and DOP concepts, Roblox is inherently object-oriented. Everything is itself an object with properties, methods, events, etc. meaning you cannot have a functionally pure ECS without some level of abstraction. The examples you’ve provided are no more pure to an ECS than mine is as we’re both operating within the same constraints of an object-oriented engine. It’s a perfectly valid approach to trade the layers of readability, maintainability, & structural integrity that comes with abstraction for micro-optimized spaghetti code but both absolutely come with their own sets of benefits & challenges.

Bru its not a ECS, its just a HashMap. And entity is a number not a key to a HashMap.

And also hash part of table is slower than array part cuz array part is just a:

TValue* array; // allocated on heap through `malloc`

all ECS libs like JECS/ECR uses Array part of tables.

maybe you mean ECS has a focus on data layout?

How will you map instance towards number then?
It requires hash lookup and then you reinvent the wheel again by having to do Key → Hash → Array rather than just Key → Hash

if it is a Sparse Set ECS then structure looks like:

sparse:

[1] = 0 -- Default sentinel value
[2] = 1 -- key is a entity, value is a index in `dense` array
[3] = 2 -- Entity = 3, `dense` index = 2

dense:

[1] = Instance
[2] = Instance

sprase is a array that converts Entity to dense index
dense is a plain data
pros: O(1) component add/remove, very fast
cons: more slower iterations.

if its a Archetype ECS then everything is more complicated but faster.

Registry:

[Entity 1] -> Archetype: A, Row: 0
[Entity 2] -> Archetype: A, Row: 1
[Entity 3] -> Archetype: B, Row: 0

Archetypes

[Archetype A] (Components: Position, instance)
  - Entities:   {[1] = 1, [2] = 2 }
  - Positions:  {[1] = Pos1, [2] =  Pos2 }  
  - Instances: {[1] = instance1, [2] =  instance2 }   <- perfect cache locality

[Archetype B] (Components: Position, Health)
  - Entities:   {[1] = 3 }
  - Positions:  {[1] = Pos3 }
  - Healths:    {[1] = Hp3 }

pros: very very fast iteration, maximum as possible in Luau.
cons: when you adding/removing component to entity then you need “move” to other archetype, its very slow.

Why do you need to “move” it?
You can just you know store freed keys under array and then getting freed keys using next() function.

Once again if you just want to access one value or you are using struct-like buffers then you should use dictionary
Its the most optimized way to access that

So for example if our Entity â„–2 Is in Archetype A, but we removed Position component then we need find/create(bitset or graph) new archetype, in our case we need create new archetype like:

[Archetype C] (Components: instance) <- new archetype
  - Entities:   {[1] = 2 }
  - Instances: {[1] = instance2}

and after this we need move all entity components to our new archetype, you can use table.move/buffer.copy

and also we need now change in registry Archetype A to Archetype C

That’s incredibly inefficient.
Imagine if allocators had to move all memory because something got dealocated
The focus should be on setting ranges to freed instead
This way, no moving is required
Etc. Upon allocation, you get assigned a key from the “freed” list
That’s also how I handle player server IDs in my game, fun fact

bru memory allocator has nothing to do with it lol

Memory allocators care about allocation speed and fragmentation. They dont iterate linearly over all active allocations 60 times a second

If Entity №2 has [Position, Velocity] and we remove Velocity, how does it exist in the system? If we just “free” the Velocity slot in-place, the Position-only systems won’t know Entity 2 is now just a Position, because its data is still sitting in the {Position, Velocity} table. An archetype is defined by its exact set of components. If the type changes, the physical table must change.

What you are describing is how sparse set ECS or basic ID recycling works. But archetype ECS explicitly chooses to pay the cost of “moving” on component mutation so that iteration is 10x faster.

If you don’t move data to keep it 100% contiguous, you don’t have an Archetype ECS, you just have a standard hash map with extra steps.

No, you don’t.
Hashmap is just a “best guess” of where the slot must be located and a loop if the collision is found
This is nothing like a hashmap.
There is once again no reason for such “ECS” to even exist
Because you either allocate “struct-like” buffers or just a stream and then assign a position from a freed pool
no moving is required
Stop treating code like gospel
You should be searching for true results rather than “someone said so”
That’s also why I don’t recognize “ECS” as “ECS” because what people do is reinventing the wheel
Because you need to think in terms of memory
All data management should just be like managing an allocated stack of memory
A continuous block

Example:

local Players = game:GetService("Players")

local size = 255
local token_size = 25
local free = table.create(size ,true)
local stack = buffer.create(size * token_size)
local translate = {}::{[Player]:number}

local Player_Added = function(plr:Player):()
	local id = next(free)
	free[id]=nil
	local key = id-1
	translate[plr]=key
	local pos = key*token_size
	buffer.writeu32(stack,pos,1)
end

local Player_Removed = function(plr:Player):()
	local id = translate[plr]+1
	translate[plr]=nil
	free[id]=true
end


Players.PlayerAdded:Connect(Player_Added)
Players.PlayerRemoving:Connect(Player_Removed)
for i,v in Players:GetPlayers() do
	Player_Added(v)
end

That’s how REAL developers :vulcan_salute: should manage data
All this ECS is just lies

I believe the point he is trying to get across is that you can avoid/mitigate the cost of migrations between archetypes by approaching it differently. Instead of forcing an entity into a buffer in a specific archetype, you can instead pool component storage together like you would in a sparse set AND keep the benefits of a archetypical layout via bitsets mapped to entities. That way your migrations only consist of copying and freeing data for the relevant component and not transposing the entire entity, and you’re still able to query in a similar fashion because of the bitsets you’ve mapped. It’s more of a hybrid model really

2 Likes