ECS is all the rage these days. You can hardly have a conversation with anybody about game development, at least in the context of gameplay programming, without somebody bringing up components as if they are the end-all-be-all of gameplay programming. But are they truly all they're cracked up to be? Let's break it down.
Note that I will be using the terms "ECS" and "component systems" interchangeably. I recognize that there’s a subtle difference where "ECS" has a formal idea of a "System" that can declare its read- and write-dependencies to get some kind of "automatic parallelism" where a "component system" is less formal and is just in reference to the ability to add a bit of data to an entity at runtime; however the distinction is not important for most of the points I am going to raise here. If you notice a point where the distinction does actually matter, and I don't address it, please let me know.
While my game and engine are written in Jai, the code snippets in this video will be written in C-like C++ so as to be understandable to most people.
Let's begin by enumerating the purported benefits often touted by ECS advocates. These are that ECS:
1) allows composition of functionality to create emergent behaviour
2) is good for designers
3) has huge performance benefits.
These are mostly half-truth memes that have become highly viral due, at least in part, to some good marketing done by certain companies in the commercial game engine space. What I will hopefully convince you of is that:
a) none of these benefits are exclusive to ECS
b) you probably don't need all of these for your game
c) ECS is a compression algorithm.
Composition
Let's talk about composition. ECS advocates will often say things like "ECS allows you to compose functionality to create emergent behaviours that are not explicitly programmed." This is, as I said before, a half truth. Composition isn't an outcome of ECS, rather it is a property of it. This is simply the ability to add and remove functionality from an entity during runtime. This is not something unique to ECS, it's not even something unique to games; it's just something that games tend to leverage as one way to create interesting gameplay. For example: say you have a campfire and want objects to ignite if they get too close to the fire. In an ECS, perhaps the campfire entity would have some OnFireComponent, and it would check for entities that get too close and add the OnFireComponent to ones that do. Let's see if we can find a way to do this without having all the extra system complexity of components, just entities. All we have here is a situation where we need a single bit of information: is this entity on fire or not? So why not just use a single actual bit to denote that by using flags?
enum Entity_Flags {
ACTIVE = 1 << 0,
CHARACTER = 1 << 1,
PLAYER_CONTROLLED = 1 << 2,
AI_CONTROLLED = 1 << 3,
IS_ON_FIRE = 1 << 4,
HAS_BOUNDS = 1 << 5,
RENDERABLE = 1 << 6,
DRAW_TEXT_IN_WORLD = 1 << 7,
};
struct Bounds {
AABB aabb;
};
struct Character {
float move_speed;
int health;
};
struct Renderable {
Sprite sprite;
Vector4 color;
};
struct Draw_Text {
char *text;
Font *font;
int size;
};
struct Entity {
EntityID id;
Vector3 position;
uint64_t flags;
Bounds bounds;
Character character;
Draw_Text draw_text;
Renderable renderable;
};
Array<Entity> all_entities;
The idea here is that every entity has ALL the data that an entity could have, and then you have flags to turn certain behaviours on and off. The code for updating such an entity might look something like this:
void update_game(float dt) {
for (int64_t i = 0; i < all_entities.count; i++) {
Entity *entity = &all_entities[i];
if (!(entity->flags & ACTIVE)) continue;
if (entity->flags & CHARACTER) {
if (entity->flags & PLAYER_CONTROLLED) handle_player_input(entity, dt);
if (entity->flags & AI_CONTROLLED) do_ai(entity, dt);
}
if (entity->flags & IS_ON_FIRE) {
assert(entity->flags & HAS_BOUNDS);
Array<Entity> nearby_entities = query_nearby_entities(entity->position, entity->bounds.aabb);
for (int64_t j = 0; j < nearby_entities.count; j++) {
Entity *other = &nearby_entities[j];
if (other == entity) continue;
other->flags |= IS_ON_FIRE;
}
}
}
}
We iterate through all the entities, skip the ones that aren't ACTIVE, check the flags and do certain things based on which flags are set. Going with the OnFire example from before, here we gather all the other entities that are within our axis-aligned bounding-box and then set the IS_ON_FIRE flag on them. We have achieved composition, trivially, without components.
This approach works well and I often start new games with something that looks like this for maximum flexibility when I am just exploring a design idea. I then gradually pull things out as the design gets more solidified and I want some more typesafety and code clarity. One downside with this approach is that it is difficult to treat the various "components" homogenously, because they are just stored as heterogenous data. There's no way to, for example, iterate over all the "components" an entity has, because it's all just unordered data. If you're in a language that has good runtime type information then you can iterate the fields of the struct and whatnot, but then you also need to map the fields to the Entity_Flag that controls them; which might be fine but certain fields might be controlled by multiple. It just gets hairy quite quickly if you want to be able to treat data homogenously. Heterogeneity vs homogeneity is a common point of tension when writing new systems and ultimately you will have to decide which one is more important on an individual system-to-system basis.
Another potential problem with this kind of solution is the memory wasteage. For certain kinds of games there might only be a handful of characters, yet ALL entities will have this character data on them; taking up space and reducing cache locality. Player data in particular can be the biggest offender because there is often only one of them, and it often has the most data. The issue with this "problem" is that it is not obvious that it is actually a problem. Reduced cache locality isn't in and of itself an issue. The issue is when that becomes an actual performance bottleneck; and you discover that by actually measuring. That it feels somehow "wrong" to have this little bit of wasteage is an aesthetic, superstitious problem; not a real, concrete problem.
But okay, maybe you're making a game where this does become the bottleneck somehow and you have verified this through profiling, or maybe you just can't stomach the little bit of wasteage for aesthetic reasons. What can you do? A couple options come to mind:
1) You can pull the data out and hold handles to it.
struct Entity {
// ...
int32_t bounds_index;
int32_t character_index;
int32_t renderable_index;
int32_t draw_text_index;
};
Array<Entity> all_entities;
Array<Bounds> all_bounds;
Array<Character> all_characters;
Array<Renderable> all_renderables;
Array<Draw_Text> all_draw_texts;
Now here we've pulled each of the structs out into separate arrays and the entity struct just holds indices to them. You might be thinking to yourself that this is starting to look more like a component system now. If we had a way to index into the arrays with the entity ID alone then we could remove the indices completely and this would be even closer to just being a component system. Notice what has happened here: we have functionally the same program, but our memory footprint is shrinking. This is what I meant before about how ECS is just a compression algorithm. We had the composition aspect before, and now as we decrease our redundancy, without changing functionality or capability, we approach a more ECS-looking solution. In fact this is true if you went the inheritance route as well. Subtyping in general is just a compression algorithm. Splitting up a megastruct with all the data (and therefore maximum flexibility) into derived structs is purely a process of stripping away all the data you know you don't need for a particular kind of entity. That is the definition of compression. Which is not to say that it's trivial and you can ignore it, the idea is just that you might not need it, and assuming you do will add unnecessary complexity to your system.
2) Another option for compressing out the wasteage is using unions.
struct Entity {
// ...
Bounds bounds;
union {
Character character;
Draw_Text draw_text;
};
Renderable renderable;
};
Array<Entity> all_entities;
Say, for example, that we know we will never have an entity that has both the CHARACTER flag and the DRAW_TEXT_IN_WORLD flag set. DRAW_TEXT_IN_WORLD is maybe for drawing damage numbers when a character gets hit or something. In that case we can use a union so that the memory for both of those structs is overlaid, reducing the amount of wasted memory. The union will still of course be the size of the largest element, but any structs less than that will cost nothing.
Complexity
Having these properties isn't free. You have to implement and maintain them, which takes time. If you are an indie developer, time isn't a resource you have a lot of and you should be ruthless in only implementing things that you actually need if you plan to make any progress on your projects.
struct Enemy {
uint64_t id;
Enemy_Definition *definition;
float move_speed;
int current_health;
// ...
};
struct Tower {
Projectile_Definition *projectile_to_spawn;
float aoe_range;
Damage_Type damage_type;
int damage;
float speed_modifier;
// ...
};
struct Projectile {
Projectile_Definition *definition;
uint64_t target_id;
// ...
};
Array<Enemy> enemies;
Array<Tower> towers;
Array<Projectile> projectiles;
1) Composition - If the game design you are targeting with your system doesn't need arbitrary levels of flexibility, then you don't need the level of composition that ECS allows for. If you are making a small game, or your game has some concrete number of object types, whose properties are well-defined, you might not even need an entity system at all. Take, for example, a tower-defence game. You could have an Enemy struct and a Tower struct; and have an array of each and just program the exact thing you need without any sort of "system" backing it. Systematizing your entities only becomes useful once you need to generalize over all your entities; treating them as if they were homogenous data for the purposes of serialization, tool-making, and modularity. If you decide that you do need enough flexibility to warrant an entity system then that is totally fine, but it is not a trivial amount of work you are opting into. If you can get away without it, that is a huge win. Or maybe you do need an entity system because you want to make a highly integrated editor, that still doesn't necessarily mean you NEED composition. There's a gradient here and good software engineering is understanding where your project lies on that gradient and using that to inform your solutions.
2) Tools - Creating good tools is hard, and you should only do it if there is a large benefit to doing so. If you are a solo programmer making a game (or even a small team), a code-first approach is often best as it is the simplest, most flexible, and fastest option (both in developer time investment and in runtime performance). If your team size gets larger and you have more specialization like dedicated level designers, then that's when you will want to start making good tools for your designers. The cost of one or two programmer's time is hugely outweighed by having several designers being highly productive. The part that is good for designers is the composition aspect, and as we've already talked about, composition is less tied to ECS than people think. You can get that composition another way and still have good tools to interface with it.
struct System {
void (*execute)(System *system, float dt);
// ...
};
System gravity_system = {};
add_dependency<Gravity_Component>(&gravity_system, READ);
add_dependency<Physics_Component>(&gravity_system, WRITE);
gravity_system.execute = [](System *system, float dt) {
Array<Gravity_Component *> gravity_components =
gather_components<Gravity_Component>(system);
Array<Physics_Component *> physics_components = gather_components<Physics_Component>(system);
assert(gravity_components.count == physics_components.count);
for (int64_t i = 0; i < gravity_components; i++) {
Gravity_Component *gravity = gravity_components[i];
Physics_Component *physics = physics_components[i];
physics->add_force(gravity->direction * gravity->strength * dt);
}
};
add_new_system(gravity_system);
3) Performance - Getting the performance benefits of ECS mostly comes in the S part of acronym: Systems. This is one place where the distinction between ECS and "component systems" matters. With the formal concept of a System, the idea is that you can define which components a system depends on to read, and which components it modifies. The benefit here is that you can write an algorithm to generate a dependency graph of all your Systems and then automatically parallelize them since you know all the data dependencies. A System that moves every entity with a Gravity_Component downward can, in principle, run in parallel with a System that ticks ability cooldowns and calculates which abilities the player can activate.
People often talk about how ECS is good for cache locality, but as far as I can tell this is another half-truth meme mostly in response to an object-oriented way of programming where you have each entity allocated randomly on the heap, and all their data is also randomly allocated on the heap, so you have crazy pointer indirections everywhere. If instead you just have a tightly packed array of entities that you iterate linearly and update as in the composition example from before, you will have the cache performance you're looking for without having to introduce the concept of a "component" in your design. The only real, notable performance wins are in the possibility of parallelization.
So now we ask the question: do you need this? Are you making a complex enough game that it is actually worth spending the time it will take to ensure that this system works correctly? Multithreading is very, very difficult to get right, and if you aren't making huge games with bajillions of entities you almost certainly don't need this level of optimization. Certain designs with highly serial update orders would get no benefit from this auto-parallelization at all, and in some cases it could even be a pessimization because of all the bookkeeping overhead. Additionally, there's nothing about this parallelization that requires components to be involved at all. The real point of Systems in ECS is to force the user to define their dependency graph ahead of time, which is orthogonal to whether you have components in your entity system at all.
In my case, the component system in my engine accounts for nearly half of the API surface of the entity sytem in total (todo: find out how many lines of code would be removed, serialization and deserialization code aside). This is a non-trivial amount of complexity and it would be a huge simplification to just rip it out entirely. At this point I have converted everything in my game code to not use components, but I can't remove it from the engine just yet because a buddy that I share the engine with is still using it. Hopefully at some point in the near future I can actually rip it all out. So what did I end up doing for my game specifically?
So what did I do for my game?
For a long time in my game I did the megastruct approach where I had one Entity struct with all the data and a bunch of flags. It worked great and is maximally flexible but once the project started getting big, the lack of any typesafety started to bother me, so I switched the game over to using components which I already had in the engine at the time. There was a Hero component, an Activated_Ability component, components for the backgrounds, doing the parallax and such, a component for the damage numbers when a hero gets hit, and so on. Up until this point in my engine there was only a single entity type; you couldn't define multiple. But I realized that if you WERE able to define multiple, you could achieve composition by having "child" entities with types different from the parent. The code now looks something like this:
struct Entity_Base {
Vector3 local_position;
int64_t entity_type;
// ...
Entity_Base *parent;
Entity_Base *first_child;
Entity_Base *last_child;
Entity_Base *prev_sibling;
Entity_Base *next_sibling;
};
Entity_Base maintains pointers to its, parent, children, and siblings in a linked-list fashion. Writing a loop that goes through all siblings, children, or climbs up the hierarchy to parent entities is trivial. It also has a field for which entity type it is, so when iterating over a bunch of entities I can check its type and pointer cast to access derived fields.
Vector3 get_absolute_position(Entity_Base *e) {
Vector3 result = e->local_position;
Entity_Base *current = e->parent;
while (current) {
result += current->local_position;
current = current->parent;
}
return result;
}
You may have noticed that Entity_Base has a local_position now. With this hierarchical structure, each entity's position is relative to its parent, so we will have to manually calculate the absolute position to know where to render entities in the world. Here we start off with the entity's local_position and then climb up the hierarchy, summing up each parent's local_position. Once there are no more parents, that is the entity's absolute position. In the case where the entity never had a parent to begin with, its local_position is equivalent to its absolute position. I have this same sort of thing going on for the entity's scale, rotation, and render_order; it's all relative to the entity's parent.
struct Hero_Entity : Entity_Base {
char *hero_name;
Hero_Equipment equipment;
// ...
};
struct Spine_Animator : Entity_Base {
State_Machine *state_machine;
spSkeleton *skeleton;
// ...
};
For the Heroes I have a Hero_Entity at the top-level and I have a Spine_Animator as a child to that entity. Projectiles for abilities are similar, there is a top-level Ability_Animation entity and a Spine_Animator as a child entity. The Ability_Animation handles moving towards a target and damaging the target on arrival (or heal or whatever else the projectile is defined to do). Rendering is a matter of iterating over all the Spine_Animators in the scene and rendering them based on their absolute positions so that they follow wherever their parent entities are.
union Entity_Slot {
Entity_Base base;
Hero_Entity hero;
Spine_Animator spine;
...
};
Array<Entity_Slot> all_entities;
In terms of storage, I have a type called Entity_Slot that is a union over all the entity types, and then I just have a single array of that type. You could split each entity type into its own array to limit the wasteage from using a union but I haven't bothered to do that yet, maybe I will at some point, but keeping it all in one array makes EntityID lookups trivial.
for (int64_t i = 0; i < all_entities.count; i++) {
Entity_Slot *slot = &all_entities[i];
if (!(slot->base.flags & ACTIVE)) continue;
switch (slot->base.entity_type) {
case HERO_ENTITY: {
Hero_Entity *hero = &slot->hero;
break;
}
case SPINE_ANIMATOR: {
Spine_Animator *spine = &slot->spine;
break;
}
}
}
Iterating over all the entities would look something like this, though in practice you would probably have some kind of custom iterator to reduce the boilerplate of the first three lines. Not a big deal but it's nice to have.
Entity_Slot :: union {
using _Base: Entity_Base;
#insert -> string {
sb := make_string_builder(temp);
for entity_type: ENTITY_TYPES {
sbprint(*sb, "_%1: %1;\n", entity_type);
}
return builder_to_string(*sb);
};
}
Hero_Entity :: struct {
using #as base: Entity_Base;
// ...
} @entity
// all entities
for *entity: entity_iterator(scene, Entity_Base) {
// ...
}
// all Hero_Entity entities
for *hero: entity_iterator(scene, Hero_Entity) {
// ...
}
My real codebase is in Jai, not C++, so just for fun here is what the Entity_Slot struct ACTUALLY looks like. It uses metaprogramming to generate all the union members. ENTITY_TYPES is an array of Types that gets generated by a metaprogram that finds all the structs in the program with an @entity tag as shown here.
As for performance, since we've retained the modularity of a component system, you could easily imagine doing the exact same auto-parallelization as ECS by introducing capital-S-Systems that are in charge of updating each entity type, declaring its dependencies and so on. I don't think this is super important for my game (or most games, really), so I'm not going to bother; but you could do that if you wanted.
You might be thinking "well you just reinvented components and called them something different." To which I would say that in your mind you still have conflated the concept of "composition" with components and I invite you to rewatch this video. The win here is that my solution is hugely simplified. The system itself is smaller, the API surface is much smaller, and there are fewer concepts to worry about; all while retaining all the power and flexibility. Problem solving is more straightforward because because is only one concept to worry about, entities, and as a result there are fewer degrees of freedom, again, while retaining power. When building systems, all else being equal, simplicity is king.
Composition is the main win for component systems over the more traditional object-oriented approaches, but we have to stop thinking that ECS has some monopoly over composition and recognize that it is simply a property of ECS rather than being inextricably bound to it. We have to wake up to the idea that we can, and should, deconstruct ECS (or any other paradigm) into its own component parts, decide which parts we actually need, and build our solution from there. That's good software engineering.