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.)
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.
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.
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
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.
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.
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
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:
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 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