Yes! This is the essence of an ECS, but what I believe what @IdiomicLanguage is suggesting is some sort of “Networked” component which some NetworkSystem is interested in. This system would presumably just send the state of an entity with a Networked component to client(s) over the network at some rate to keep stuff in sync.
I think this is a bad idea for a few reasons:
- The NetworkSystem will touch a LOT of data, which means:
- the NetworkSystem becomes more coupled to other systems the more components it replicates, meaning:
- the NetworkSystem will likely suffer a huge loss of cohesion (absolutely disgusting)
What these observations should mean to us is that the concept of networking doesn’t neatly fit into a simplistic ECS architecture; the way we’re thinking about the problem is wrong. A “bigger” solution is needed!
This sounds a bit similar to how the first version of my ECS operated. In that model, components were represented by Folder
instances filled with ValueBase
instances, and entities were represented by referenced instances (tagged “component” and “entity,” respectively). For comparative purposes, here’s what the client did (the server did something very, very similar):
function WorldSmithClientMain:_setupEntityComponentMap()
CollectionService:GetInstanceAddedSignal("entity"):connect(function(entity)
self._entityComponentMap[entity] = {}
end)
CollectionService:GetInstanceRemovedSignal("entity"):connect(function(entity)
self._entityComponentMap[entity] = nil
end)
CollectionService:GetInstanceAddedSignal("component"):connect(function(component)
local entity = component.Parent
if self._componentEntityMap[component.Name] == nil then
self._componentEntityMap[component.Name] = {}
end
if self._entityComponentMap[entity] then
self._entityComponentMap[entity][#self._entityComponentMap[entity] + 1] = component
self._componentEntityMap[component.Name][#self._componentEntityMap[component.Name] + 1] = component
end
end)
CollectionService:GetInstanceRemovedSignal("component"):connect(function(component)
for _, v in ipairs(self._componentEntityMap[component.Name]) do
if v == component then
self._componentEntityMap[component.Name][v] = nil
end
end
end)
local assignedInstances = CollectionService:GetTagged("entity")
local componentTags = CollectionService:GetTagged("component")
for _, entity in pairs(assignedInstances) do
self._entityComponentMap[entity] = {}
for i, v in ipairs(entity:GetChildren()) do
self._entityComponentMap[entity][i] = v
end
end
for _, component in pairs(componentTags) do
if self._componentEntityMap[component.Name] == nil then self._componentEntityMap[component.Name] = {} end
self._componentEntityMap[component.Name][#self._componentEntityMap[component.Name] + 1] = component
end
end
(if you want to see the whole thing, here it is; again, keep in mind that there are many things wrong with it)
This is fairly nice. When a server side system changes the state of a component (i.e. a ValueBase
within a Folder
), it is automatically replicated to all clients. When a client changes the state of a component, it only occurs for that specific client. This is good - it’s what we should expect from this implementation. However, there are some problems with this approach:
- entity and component initialization is implicit for clients, but explicit for the server; in other words, the client can’t initialize its own data. This will lead to us having to work around race conditions on the client - I did this with a function called
YieldUntilComponentLoaded()
- we’re locked into the heavyweight Roblox client-server model (i.e. one change on the server replicates to every client, no exceptions). This problem cannot be reconciled using this approach; unacceptable when we want to replicate a change only to specific clients, or when we want clients to be able to have different parts of the game loaded at different times.
- it’s true that changes to instances will also always replicate, but that’s a behavior that we didn’t implement; it’s just a side effect of asserting that instance == entity (also related to the above point).
Can we do better? Hell yes.
ECS, YOU’VE GOT THE POWER
So, we’ve identified that we want the client to construct its own state, and we want to be able to update game state for only specific clients. To do this, we’re going to have to dispense with the notion that an entity is equivalent to an instance. If an entity isn’t an instance, then what is it?
Entities are numbers
An entity is a number (or a string, or any other type which can be made unique). It’s just a way to tag each game object; a way to tell our ECS that “this object’s state is located here in our master lists.” Instances do indeed give us a way to do this, but we’ve already decided that the way they behave is undesirable for our purposes.
With this in mind, we’re going to place in each instance some unique-valued ValueBase
which marks that instance as a coarse game object (we’ll also tag the instance itself with CollectionService
so it’s easy to find). We now have a way to associate a single, unique value with an instance within our ECS (but remember that this instance is not an entity!).
One interesting consequence of this: entities no longer have to be associated with instances at all. This allows for the creation of what I like to call abstract entities; that is, entities that do not correspond to anything in the instance tree (my current implementation doesn’t support these, but if I find a use for them it definitely will).
Clients construct their own game state
Sounds scary, right? It needn’t be. We’re going to use what Adam Martin refers to as the “CTRL+D” method.
The server will simply create a copy the current game state (or part of it, if that’s what we want), and send it over the network to the client. The client’s ECS then puts all of this data into its master lists. In my implementation, the server sends over an instance or instances (containing the previously created unique-valued ValueBase
) and either a partial or complete copy of the game state. Then, the client loops over those instances to internally associate them to the unique value, and creates the corresponding data structures in its master lists.
An authoritative server
The client still needs some way to reconcile its own state with the server’s state. We’ll supply the client with a method to request a state change. In my implementation, the server’s systems are responsible for handling these requests via GetClientRequestSignal()
. Clients have no way of affecting the server’s state unless this method is implemented by at least one server-side system. Thus, the server is in complete control of whether the request is actually honored - if the data is bad, we can simply ignore the request and send the correct state to that client. Again, this is all handled within the systems; there is no magic networking layer at work here.