What the heck is an Entity Component System anyway? #
So this is the time for the ECS tangent, where I try to get anyone not super familiar with their concept up to speed on what they are. An Entity Component System is a way of structuring applications, especially games, in such a way where data is separate from functionality. This is as opposed to gluing data and functionality like in Object Oriented Programming where a class owns some data and has a bunch of functionality.
An entity is just an identifier (think just an incrementing unique number like 1, 2, 3, 4, ...etc for example)
A component is a set of related data, as an example we might have a HealthPoints
component that contains both the maximum health and a current percentage of something's health.
The mental model #
Let's elaborate with an example. A good mental model to keep in mind is a 2-dimensional table:
Each row is identified with a single entity, and each component is a column. So if we have 3 enemies we spawn 3 entities for them, each of those entities would have the HealthPoints
component (unless there's a special enemy that has no health!). And the player character can be an entity too! And we can reuse the same HealthPoints
component to describe their current health, because why wouldn't we?
So in that tiny table we have 4 rows (1, 2, 3, 4) and one column HealthPoints
which is full of data for each row about the health of one of our entities.
Let's add another component to the mix to make things more interesting, how about EnemyType
? It can be an enum (enumeration) that has the possible variants Robot
, Chicken
and Poring
. We can use it to figure out how to render each enemy on the screen (you don't want your robots rendering as chickens!) and we can use it to figure out other things, like say if our game has contact damage that depends on the enemy then on contact we need to figure out the EnemyType
so we can subtract -42 when a robot touches you and -2002 when a Poring touches you.
Notice something though? Our player doesn't have an EnemyType
! This means our table (remember our mental model) has a hole in the cell at the intersection of EnemyType
column and the player's entity row, and those holes are THE ONLY THING which distinguishes entities from each other. In this little example we can say "An entity with EnemyType
is an enemy and one without is the player"
Okay, okay, what about the last part of ECS? What the heck are systems? Well... they are everything else! So far we just have a boring table, sitting like a wet blanket, doing nothing even if it has interesting data. Systems take this data and operate on it. They can read it, modify it, or remove it or just mess around with it in almost every way you can imagine. A system can create or remove entities, attach components to them or remove components from them, or modify the values of components inside of a system and much more.
Here are some examples demonstrating some of the possible operations a system can do.
- A
spawn_enemies()
system can spawn enemies by spawning entities that have bothHealthPoints
andEnemyType
components. - A
receives_damage()
system can detect when your bullets (which would be entities too, by the way!) contact the enemies and update theirHealthPoints
component accordingly. While another systemremove_dead()
can run after it to detect ifHealthPoints
is at 0% to remove the entity (along with all its components). - If our game is multiplayer allowing us to have more than one player entity and our chickens had the chickenism ability then once one of them bites one of our players the
chickenify()
system can associate theEnemyType
component with valueChicken
to the entity of the player that got bitten!
Systems #
What's rather different about systems in ECS is the way they access data: they typically ask for specific components, or more technically they query for specific components. For example a system can ask for HealthPoints
component and it will get access only to the data in that component NOT the full table and NOT all the components for entities that has an associated HealthPoints
component. This can be our regenerate_health()
system, it only needs to know the current health values so that it can tick them all up by +1 because yes our game is boring everything has same health regeneration.
Then there's our draw_enemy()
system, it needs to know the type of the enemy (and their position on the screen of course which can be another component but we will focus solely on the HealthPoints
and EnemyType
for these examples). But there are a couple of difference! Our regenerate_health()
component needed to both know the current HealthPoints
value and be able to modify it. This is an important and extremely handy distinction. Whether a system can modify a particular component or not makes reasoning about games much easier, because if something is going wrong with the positions of the enemies on the screen, you know only the systems which explicitly ask to be able to modify the positions can be at fault for this. And since it's such a handy distinction we can give it a more succinct name: mutability. A system can query components mutably, i.e regenerate_health()
queries HealthPoints
mutably. Or immutably, i.e draw_enemy()
queries EnemyType
immutably.
Another difference from the regenerate_health()
example is that our column has holes in them! Oh no! What happens now? Does our draw_enemy()
system crash because of the hole? Or maybe we have to add hole checks everywhere in our game, oh the terror...
Nope! Entity Component Systems are extremely brilliant and will let your system safely iterate over the EnemyType
components while jumping any holes there are. Effectively acting as a query that says "I want only the EnemyType
components that are full"
Suddenly your friend contacts you saying they tried the latest version of the game you sent them and they found it extremely unrealistic that the robots regenerate as fast as chickens, and that chickens should regenerate WAY faster. You nod heartily in agreement, this won't do.
So you go update regenerate_health()
system, the system now needs to be able to modify the health but also needs to know the enemy type, so you ask the ECS for HealthPoints
component mutably, and EnemyType
immutably. By default such a compound query is typically considered an AND query. What you actually asked for is "the HealthPoints
(mutably) and EnemyType
components for entities that has both an associated HealthPoints
and an associated EnemyType
." Or more simply "query the intersection of HealthPoints
and EnemyType
components." If you're familiar with databases, especially relational ones, this might start sounding very similar to database queries, because it is.
Now you can check what's the enemy type and if it's a chicken you regenerate +100 health for it but only regenerate +1 for the robot. You test it out and looks like it's working but it seems you introduced a bug by mistake! Your original regenerate_health()
component was designed to regenerate health for all entities with health, including the player! Now this new system only works for enemies. There are a bunch of solutions for this, so why don't we explore all of the ones I could think of?
- Let's start with the slightly less elegant one, we can just rename this system to
regenerate_enemy_health()
and create a differentregenerate_player_health()
system, that system would use a feature we haven't talked about yet: query filters! We want to query all theHealthPoints
mutably for all entities that DOESN'T have anEnemyType
component attached. Or more simply "Query theHealthPoints
mutably for entities withoutEnemyType
." And like a genie the ECS would give you only theHealthPoints
for entity 2! - A more elegant solution would be to change the compound query from the updated
regenerate_health()
to be an OR query instead of the default AND. What we want is any entity withHealthPoints
orEnemyType
components. For entities withHealthPoints
only, we just do +1 because it's a player, for entities withEnemyType
only we just do nothing (those could be the special enemies that have no health), for entities with both we do our chicken check and regenerate +1 or +100 accordingly. - And finally for arguably the most elegant solution, we can just literally ask the ECS for all
HealthPoints
and OPTIONALLYEnemyType
if it exists! This way we don't even have to skip entities with only anEnemyType
because our beloved ECS will do it for us!
And so this is a taste from the many different types of queries that are at your disposal to use while constructing systems! And with that all of the magical letters of ECS have been explained, you have the main toolkit you need to go venture into a new world of game making. This isn't the full toolkit though! There's a lot of extra fluff we will explore soon. However, before I let you go there's one last important thing that you should keep in your mind whenever you think about Entity Component Systems.
If you think about your computer memory (AKA RAM) as a group of boxes then those boxes will be laid out in a line⁽²⁾. This means it's not possible to represent a 2D table in memory without doing some flattening. Since it's a two dimensional table there are two ways to go about this, either we can put the rows after each other:
[[HealthPoints1, EnemyType1], [HealthPoints2, EnemyType2], [HealthPoints3, EnemyType3], [HealthPoints4, EnemyType4]]
or we can put columns after each other:
[[HealthPoints1, HealthPoints2, HealthPoints3, HealthPoints4], [EnemyType1, EnemyType2, EnemyType3, EnemyType4]]
As you have seen from our examples we want to be able to iterate over all HealthPoints
in order, this is what enables our ECS to do all those cool things like queries we described above. This is why in an ECS we use column based flattening because it lets the iterations be faster. Having to jump through memory to find the next HealthPoints
is not as fast as having them sequentially in memory next to each other. Column based flattening is more technically known as "column storage" or "columnar store". Another common name for these two ways of laying data is Array of Structures (row based flattening) and Structure of Arrays (column based flattening) feel free to Google that if you're curious about the tradeoffs of each way!
This brings us to the important distinction I have tried to keep very clear while explaining things previously, an entity is just an identifier into those contiguous sets of components. It's the 1
, 2
, 3
or 4
. If we used row based storage then an entity could instead be described as [HealthPoints1, EnemyType1]
but we don't!
And this is why the terminology is a bit confusing. You will often hear people say "add a component to an entity," or "insert a component into an entity" which are technically not possible and "an entity that has an EnemyType
component" which is technically not accurate. But what they mean is "associate a component with an entity" or "relate a component to an entity" and "an entity that identifies an EnemyType
component". You can probably see why the former examples are more used than the latter examples as the latter ones are a bit of a mouthful. And please do feel free to use the former examples for just that reason (even I do that!) while keeping this very important distinction in mind because it will make you think about an ECS with a more accurate mental model.
And now, my friend, venture forth into the new world of game making! ❤️