In the first part, we introduced the ECS architecture and explained a bit about why we chose to avoid Unity's standard architectural approach. We also set up an example, using entities with both a
HealthComponent and an optional
In this part I'll go into some more detail, using this example, of some specific areas where we thought a pure ECS offered some benefits. Zentropy has provided some value suggestions for this article, and again I'd like to point out that this is purely our opinion, and we'd welcome discussion!
Separation of Logic and Data
As I touched on in the first part, a major difference between a pure ECS and Unity's approach to ECS is that Unity encourages merging components and systems into the same class, that is: data and logic are in the same place. This approach works quite well for small or simple projects, however as the project grows in complexity, I'd argue that it can quickly lead to a code base that is tangled up and confusing.
Already we can see a potential confusion in our Health / Shield example. We have placed our logic in the
HealthComponent, but in order to determine how much damage to take, the
HealthComponent has to access the
ShieldComponent. However, the
ShieldComponent may also need its own logic to determine how much damage it saves.
All of a sudden, our components are no longer standalone, and there is a dependency between them which looks like the following:
This is usually resolved by making everything public on MonoBehaviours, or exposing public methods, which isn't too different to the ECS approach.
However, two issues remain. Either:
- Our damage logic is spread over two (or more) files, or
- Components are responsible for setting values on other components, which violates the single responsibility principle.
In the pure ECS approach, this problem is resolved by moving logic "up" a level. The logic is placed in a
HealthSystem, which reads in the component data, and allocates the appropriate amount of damage to shield energy and current health. The dependencies now look a little like this (assuming current shield and health levels are accessed publicly)
Now arguably there isn't a huge difference between ECS and Unity here in this simplistic example, but in my mind the ECS approach is considerably cleaner - the logic is all in one place, and the Components don't do anything except hold data.
When all our logic is in standalone systems, our game itself (i.e. not just the entities) becomes composable. For instance we can build up our game from systems:
Ecs.AddSystem(DamageSystem); Ecs.AddSystem(HealthRegenerationSystem); Ecs.AddSystem(MovementSystem);
Functionality can then be turned on or off in one place in the code base, by adding or removing a single line where we "bootstrap" our ECS. This is much harder to do when the logic is scattered throughout MonoBehaviours and prefabs.
Managing Links Between Components
Writing our own ECS let us have complete control over the lifecycle of the classes that it contains via their constructors. We can use this to provide efficient methods for accessing either specific components on an entity, all instances of a component type, or a subset of components.
If we wanted all Components of a given type currently in the game with Unity, we would need to do something like this:
var healthComps = Object.FindObjectsOfType(typeof(HealthComponent)) as HealthComponent;
This approach loops through every game object and component in the scene, which quickly becomes performance prohibitive.
In our ECS, we can do:
var healthComps = _ecsEngine.Get<HealthComp>();
Internally, our ECS approach uses a Dictionary lookup, which in the normal, best case is
O(1) - i.e. it takes the same amount of time no matter how many Components we have.
As we have complete control over the construction of Components and Entities, we can also trigger
Events when Components are added or removed from the ECS system.
This lets us do neat things; we can create lazily evaluated
Matcher classes that retrieve components based on a specific criteria, and only update when the underlying ECS data changes. In our example, we can use this to track all Entities with a
HealthComp but not a
ShieldComp, and be guaranteed that this is up to date:
// field declaration private Matcher _noShieldEntities = new Matcher() .AllOf(ComponentTypes.HealthComponent) .NoneOf(ComponentTypes.ShieldComponent);
These can then be accessed by calling
_noShields.GetMatches(), which returns
IEnumerable<EcsEntity> and is evaluated lazily, and only if the underlying data structure is "dirty".
This gives us improved performance and flexibility relative to Unity's standard approach, but perhaps more importantly the logic for this is hidden inside the ECS implementation, and doesn't clutter up our Components themselves.
Shield entities again, lets assume there are 10 different types of enemies. Half of them have shields, but all of them have different shield energy and maximum health values. How do we handle this in Unity?
We could manage this with prefabs, however as we can't use inheritance here, this approach scales very poorly. Unity's built in serialisation is a bit hit and miss, but luckily there are several decent external libraries. We could load data from file (XML, YAML or JSON), and then somehow overwrite, or manually populate MonoBehaviours. Again this scales poorly for any sort of moderately complex data structure and needs a fair bit of hand written logic if we want to dynamically add or remove components.
By contrast, the ECS approach has the following structure
The key here is that we are just serialising / deserialising data structures. As we have complete control over the process we can determine when and how Unity's game objects are created, pooled or destroyed. Unity has become a GUI for our game. It is trivial to serialise an entire Entity, or a group of Entities, load them in and attach "display" game objects to them.
As our entire ECS is serializable and housed in a single location, saving games suddenly becomes a lot simpler - we just serialize our ECS system to file.
The way our ECS is designed, we essentially use Unity as a GUI, which overlays our game architecture. Unity does a couple of things:
- Handles user input,
- Provide a physics engine (i.e. notifying the ECS when a collision occurs, handling projectile ballistics, etc),
- Displays the current state of the game to the player
In specific instances, such as collision handling, or UI we use "bridging" MonoBehaviours, which inject data from Unity methods such as
OnCollisionEnter into our ECS. These are the only Unity specific aspects of the game architecture and are usually very simple 1-2 line methods.
In theory, this means we are less bound to the Unity ecosystem. If we decided to move to Godot once C# support landed, we would only have to replace the "bridging" classes and the rest of our game logic could remain almost the same.
This may be a bit of stretch, but by Unity's own analysis (and admittedly on iPhone builds), using Unity's
Update magic method was \(5-20\times\) slower than just calling a bare update method. This may not add more than a few milliseconds per frame (2-18ms added with 10,000
MonoBehaviour instances) so the impact is not huge, but if the MonoBehaviour's can be avoided than that's an easy performance pickup. At the end of the day, why carry around the whole MonoBehaviour or ScriptableObject baggage when in these instances the functionality isn't being used?
(Of course, it possible that this performance gap will decrease over time as Unity optimises further).
Refactoring and Adding Features
A side effect of following the single responsibility principle, is that when its time to refactor code it becomes a lot simpler. If we want to change the way that damage logic works, we just go to the DamageSystem and edit that file, with no need to hunt through any other classes. This is true of both Unity and pure ECS approaches, but as we've seen the Unity approach sometimes leads to logic being spread out amongst different classes.
Similarly, adding new features becomes relatively easy. Say we want to introduce a system which can take energy from shields and add it to health. All we need to do is create a system that looks a little bit like our health system, runs after it, and transfers shield energy to health.
We could of course add these same lines to our HealthComponent in a Unity approach, (although as noted this creates interdependencies in our components), but under the ECS approach, we don't have to touch any of our other code to make these changes, which reduces the chance of introducing unwanted bugs.
One potential pitfall here is that if we add a lot of systems like this you can see that we might end up looping through the lists of shield or health components a lot. For very large games, perhaps this might cause unwanted performance issues. My usual mantra here is to implement it first, then if the profiler suggests we are spending too much time looping through components, we can start to consolidate and otherwise optimise our systems.
Having a single source of truth
Actually, it turns out that having a single source of truth - i.e. a container which holds all relevant information is extremely useful for certain features. In particular I'm thinking of AI. Our in-house AI system is based on GOAP, and to make sensible decisions it needs to be able to efficiently query the game state and ask a wide variety of questions. Our AI would be several orders of magnitude more difficult to implement if we weren't able to use our ECS to find / match and interrogate game state through the ECS.
Its not all bad, right?
I wouldn't say the Unity design approach doesn't work, in fact I've happily used this type of architecture on lots of other projects.
There are some definite weaknesses of our ECS approach:
- A lot of our performance gains in terms of querying for Components and Entities comes at the expense of increased memory usage
- Using Unity's physics and colliders etc requires a level of indirection to work
- The ECS approach probably requires more overall code to be written, even if the individual methods and classes are small(er).
In our case, and despite these compromises, we felt that our custom ECS offered a cleaner, more reusable architecture that we could apply to multiple different game styles. Hopefully I've given you a bit of an insight into our reasoning.
In the third and final instalment of this series, I'm going to give an example of how we implemented a particular feature in our ECS for our current game project so you can see how this would work "irl".
If you want to discuss anything about this post or to find out more about Zenobit, feel free to get in touch via Twitter, @ZenobitStudios or @wlhart. Follow us to get notified when the next part gets posted up!