Posted On: 2019-10-21
I have recently been looking at Unity's new ECS architecture, to see if it might be useful for solving the save issue I described in last week's post. This is not the first time I have looked at it (I even mentioned it in a previous post), but, each time I consider it, I am tempted by its strengths and concerned by its limitations.
For those that are unfamiliar, ECS (Entity Component System) is a software architecture pattern that enforces the separation of data and behavior. In ECS, each item that the program is representing (such as a character in a game) is an "entity". The entity itself has no data or behavior - instead, it is associated with multiple components, each of which contains a small piece of data about that entity (basically the adjectives describing that character, such as how tall or how fast they are). These components contain only the data about the entity - they do not contain any implementation of behavior (basically the verbs of the character, such as running or jumping).
Behavior is implemented only using systems, each one of which is written such that it can be completely dissociated from any particular entity. Thus, when implementing a mechanic (such as character movement) a system must be created such that, for every entity, it will always behave correctly (such as characters can run, but walls cannot). Typically this is accomplished by associating each system with a subset of relevant components (such as movement speed and player input.) The system then only affects those components, ignoring all the rest (so, for example, characters with the "player input" component can move, while walls, which do not have that component, do not).
The best example for this is (imho) actually a game. The delightful rule-bending puzzle game Baba is You can be clearly described using ECS terminology*. If you are unfamiliar with the game, please watch the trailer - it's about a minute long (no sound required) and succinctly describes the game's core mechanics much better than words can.
In Baba is You, each entity (such as Baba or the Rock) is associated with a word ("BABA" or "ROCK", respectively). All objects with that word are grouped together, so that they have the same rules (thus "Rock is Push" means that any rock can be pushed). In ECS terms, those rules are enity-component associations: when "Rock is Push", that means that any entity with the "Rock" component also has the "Push" component. The logic of the game, then, is like the systems of ECS: based on the components on each entity, it performs specific changes to the entity each turn. When any entity moves into an entity with the "Push" component, it (the entity with the push component) is pushed out of the way.
Perhaps the clearest example is the "You" component: anything with the "You" component responds to player input. Thus, while "Baba is You" the player controls Baba, but when "Rock is You" the player controls the rock. Importantly, if both "Baba is You" and "Rock is You", then the player controls both Baba and the rock, simultaneously. The system doesn't care which entity has the "You" component, or even how many entities do; it simply updates them so that when the player pushes up, any (and every) entity with the "You" component moves up. Relatedly the (soft) lose condition for the game is when no character has the "You" component: the game will continue even if that happens, but, since nothing responds to your input, you probably can't win anymore (thankfully, the undo and restart mechanics are implemented so they work even without a "You").
Now that it is (hopefully) clear what ECS is, I'll describe what advantages I would gain if I used it. I will be focused on just the ECS implementation specific to Unity (as that is the one I have researched most extensively) and how ECS could improve my project's architecture*. Additionally, since most of these are deep enough topics to warrant blog posts of their own, I will try to describe them succinctly, focusing on generalities rather than details.
Separation of Data and Behavior
Separating data and behavior typically reduces side effects and simplifies automated testing. In some cases it can also simplify sharing code between dissimilar objects/entities.
Flatter Data Hierarchy
Composition over inheritance is often regarded to be beneficial, but nested composition can lead to extensive traversal as the object graph grows. By allowing systems to mix and match any combination of components, behavior that requires otherwise unrelated data generally becomes more maintainable.
Inversion of Control
While less robust than dependency injection, the approach of having systems define which components they affect (rather than having the entities/components call the system directly) provides many of the benefits of inverting control (dependency is self-evident, code is more expressive, etc.)
Data First Architecture
Storing data alongside behavior is often the path of least resistance (the file's already open). Yet such decisions, when made lightly, often come at the cost of having future uses of that data impaired or outright obstructed. By architecturally encouraging thinking about data before implementation, ECS fosters thoughtfulness and planning upfront, rather than in the middle of implementation.
For all its benefits, ECS has significant limitations as well. I will use the same approach as the previous section - a succinct list of limitations, focused on what most affects my project.
Performance Over Usability
ECS was designed to maximize performance. In computationally expensive projects, this is likely a benefit, but in this project I stand to gain more from productivity or maintainability boosts, rather than performance boosts. As an example, whenever I have the choice between more expressive code and code that generates less garbage, I will choose the expressive code, as it will help my future self maintain and improve upon the system.
ECS is still technically in preview. While this theoretically means the API could completely change, in more practical terms it means that many of the features that one might expect a game engine to have are not yet available in ECS. While there are workarounds for each limitation (such as using hybrid mode or coding the implementation oneself,) most available workarounds entail both upfront development and ongoing maintainance.
Blittable Data Only
Blittable data structures map directly between manage and unmanaged code, allowing Unity to directly copy (blit) the data between the C# and C++ parts of its engine. Unfortunately, very few data structures are blittable*, so this puts severe constraints on the kinds of data that can be stored in components**. Considering how ubiquitously I (like most developers) use non-blittable types, this constraint will likely result in dramatic productivity loss (at least until thinking in blittable-only becomes second nature.)
Every time I investigate ECS, I am always faced with a choice. Do I fully commit to ECS, converting my existing code and accepting all its limitations? Do I try to use it in hybrid mode, and incur the costs of mapping data between ECS and Unity's normal architecture? Or do I simply admire it from afar, knowing that the costs of using it do not justify the benefits it will provide? Considering my current problem is a maintainability issue (each feature I implement requires new code to save its values) it seems that the hybrid approach would simply be just trading one problem for another. Thus, I am left with the choice between using ECS fully (including converting all my existing code) or not at all. For now, I am leaning into the "not at all" option - but the cost/benefit for that choice may change, depending on what (if any) alternatives I am able to identify.
ECS is fascinating, providing many architectural benefits with steep costs. For projects that need the performance, ECS is certainly beneficial, but even for other projects, it may still be quite useful - provided that they can cope with all the down sides.