What Is An ECS?
You may have heard of ECS before. It seems a bit like a trend these last years. A lot of people start to use ECS for the first time. Unity for example announced their DOTS technology in 2018.
ECS stands for Entity Component System. It doesn’t mean it’s just a kind of system. The name is actually the separate elements that an ECS implementation contains. So you have Systems that process Components which are linked to Entities.
Why is this ECS thing so interesting? Well, ECS goes hand-in-hand with Data-Oriented-Design (DOD). You probably know OOP or Object Oriented Programming. DOD does things a bit differently and looks specifically at the data you would like to manipulate. Because that is what code does, manipulate data.
Why?
Currently I am developing a 2D game engine which teaches people that there is more than just OOP.
Where programmers should focus on the hardware, the data and the simplicity of the code.
Something that doesn’t get taught through Object Oriented Programming.
This is where this ECS system comes in, as I was looking for a kind of code structure that would help focus on those things. it’s like the back bone of my engine now. Instead of thinking about classes and inheritance, users of the engine can focus on what really matters, the data and the manipulation of it.
This tutorial will try to explain and show you my implementation of an ECS. So you will understand what makes an ECS system interesting as well as have a practical example of a working ECS implementation.
For more information on my 2D game engine, please see here.
Preparations
To be able to follow this tutorial you’ll need intermediate knowledge of C++ programming.
Especially of concepts like macros, typedef, pointers and structs.
It’s best to test this code in an empty C++ project where you can print stuff to a console. This way we can validate if the code works properly.
Basics of ECS
As I said before ECS stands for Entity, Component and System. However this doesn’t describe one solid design. There are different types of ECS and methods to implement such idea.
You may have heard of the Archetype ECS, since that is what Unity DOTS uses. It works really well when iterating over the components, since everything is packed together. On the other hand, it takes more work to add or remove components on an entity than for example a Sparse Set ECS.
The Sparse ECS is the one I choose for this implementation. It’s good at adding or removing components, but iterating over them takes a bit more work. Though it’s easier to implement than an Archetype ECS.
For more information regarding different ECS types you can check the ECS FAQ by Sander Mertens.
The Logic as Systems
So let’s start working on our Sparse ECS.
Be sure to have an empty project where you can print stuff in the console.
Later on you can try to merge this ECS into your own programs.
Let’s assume you want to make a game like Super Mario Bros.
Looking only at the player we can imagine we need a sprite, a way to move that sprite forward, collision detection, input handling as well. A lot to think about, but for this tutorial we will focus on the moving forward part. As the other elements can be managed the same way.
Movement System
So a movement system is what we need to change our character’s position.
When using OOP we would create a player class that would contain everything involving the player. It’s position, velocity, acceleration, sprite, but also all the logic to change it’s position, change it’s velocity, animate the sprite, etc.
When using ECS we take all the logic out of that class. Since the enemies may move in exactly the same way or you have more than one player, we can re-use that logic. Not by copying the code or inheriting from Player or some kind of Movement class. We create a system which contains the logic and only the logic.
First let’s define that player class as a struct instead. We leave out the logic though, since as I said we put the logic in it’s own system.
Create a header file called player.h.
Define the player struct like so:
#pragma once
#include <stdint.h> //so we can use uint8_t etc.
struct player_t
{
uint32_t sprite_id;
float position_x;
float position_y;
float velocity_x;
float velocity_y;
float acceleration_x;
float acceleration_y;
bool on_ground;
bool is_jumping;
};
Normally you would have vectors for the position, velocity and acceleration. So I’ll define a vector2, it that makes it a bit more organized.
#pragma once
#include <stdint.h> //so we can use uint8_t etc.
typedef struct vector2
{
float x;
float y;
};
struct player_t
{
uint32_t sprite_id;
vector2 position;
vector2 velocity;
vector2 acceleration;
bool on_ground;
bool is_jumping;
};
That looks better. As you can see we don’t have any functions defined in this struct. This player_t struct only contains the data of the player, nothing more.
What we want to do with that data is manipulate it, especially the position vector through the velocity and acceleration vectors. This manipulation is done by running this data through a system. That system will then modify only the position and velocity of that player. Nothing more, that’s very important. As this makes testing the logic very easy. As you simply put in data, which you expect to change a certain way. If the result is not what you expected, than there may be something wrong with the code. It’s easier to find bugs that way and easier to maintain the code as well.
Let’s create our movement system by creating a new header file named movement_system.h. Then we define a function which holds the logic to manipulate our player data to make it move.
#pragma once
#include "player.h"
void movement_update(float delta_time, player_t* player)
{
//movement logic here
}
void movement_update(float delta_time, player_t* player)
{
player->velocity.x = player->velocity.x + (player->acceleration.x * delta_time);
player->velocity.y = player->velocity.y + (player->acceleration.y * delta_time);
player->position.x += player->velocity.x * delta_time;
player->position.y += player->velocity.y * delta_time;
}
This should move the player like we want. As you can see only the velocity and position variables will change. The acceleration variable will get manipulated by an input system. Which in this case would be by keyboard input or gamepad input. We won’t be creating and using that system, we just hard code the acceleration.
This is really it for this system. Let’s try to test it out. We need to change the main.cpp a bit to make it work though.
#include <stdio.h>
#include "movement_system.h"
void main()
{
player_t player;
player.position.x = 0.0f;
player.position.y = 0.0f;
player.velocity.x = 0.0f;
player.velocity.y = 0.0f;
//Hard code the acceleration
//We make the player move to the right
player.acceleration.x = 1.0f;
player.acceleration.y = 0.0f;
//We also hard code the delta time for now
//We aim for 60FPS, so one frame should take
//1/60th of a second
float delta_time = 1.0f / 60.0f;
while (1)
{
movement_update(delta_time, &player);
printf("Player's position is: { %0.2f, %0.2f }\n", player.position.x, player.position.y);
}
}
If everything went well and you run the code, you should see messages where the x position keeps increasing. Which means our system works. Well done! That’s your first step towards a full ECS system.
The Data as Components
While it’s nice to have one struct which contains all the player’s data, it’s actually not that efficient.
When a system only needs the sprite_id variable for example.
The whole struct or part of it gets loaded into the CPU’s cache. Then when the second player needs to be processed, the CPU first need to fetch their data from RAM into the cache. The code would run faster when we can prevent the step of fetching that data from RAM.
If the step of fetching the data for player 2 wouldn’t take a lot of time, you could say that loss of time is negligible. But unfortunately the opposite is true, as Mike Acton pointed out in his talk at CppCon 2014. Fetching from L1 cache is roughly 3 cycles, while fetching from RAM is around 200 cycles (depending on the CPU). Imagine taking three steps in 3 seconds, now imagine taking those same three steps in 200 seconds. That’s a big difference.
This is a remake of Mike Acton’s animation from his talk. Also if you want to learn more about L1 and L2 cache, please take a look here for the short explanation and here for the long and more technical one.
So what happens when the CPU is fetching data from RAM? Well, nothing happens. It just sits there, waiting for the data to arrive. If we can prevent the CPU from waiting for data, that’s where we can speed up our code. That’s the reason why we split up our player class in logic & data and now also split up that data into different components.
Splitting Player Data
Let’s look at our current player struct.
It should look something like this:
struct player_t
{
uint32_t sprite_id;
vector2 position;
vector2 velocity;
vector2 acceleration;
bool on_ground;
bool is_jumping;
};
With our movement system we only access velocity, acceleration and position.
If we could group that data together, then we won’t fill the cache with unnecessary data and may prevent some cache misses. Which will save us some CPU cycles like described above.
So we can split the player up into a hot and cold part.
struct player_hot_t
{
vector2 position;
vector2 velocity;
vector2 acceleration;
};
struct player_cold_t
{
uint32_t sprite_id;
bool on_ground;
bool is_jumping;
};
With some tiny changes to the rest of the code this would work just fine. We save some memory space and some very valuable CPU time. But there is actually another approach of doing this. The approach that actually uses components.
We could split up these hot and cold parts into their own components. We could create a transform component for the position. A rigidbody component for the velocity and acceleration. A sprite component for the sprite_id.
Building the player out of these different components makes our code much more flexible. As we can change our movement system to use these generic components instead. This would result in the possibility to use the movement for other characters in our game world and not only the player.
#pragma once
#include <stdint.h> //so we can use uint8_t etc.
typedef struct vector2
{
float x;
float y;
};
struct transform_t
{
vector2 position;
vector2 rotation;
vector2 scale;
};
struct rigidbody_t
{
vector2 velocity;
vector2 acceleration;
};
struct sprite_t
{
uint32_t id;
vector2 size;
};
This is how the player.h file looks now. I removed the on_ground and is_jumping variables since we won’t be using them. Instead I added rotation and scale to the newly created transform component.
With these components we can rebuild the player. However it’s also possible to create things like enemies, walls or houses. We don’t need to make specific enemy or house components, we can just use these generic ones.
For this tutorial these components are all we need. The question now is, how can components refer to one another? When an enemy is trying to follow the player, how does it know which transform component belongs to the player?
The Missing Link
This is where entities come in. Entities represent the different objects, players, etc.
In this implementation of an ECS an entity is nothing more than an ID actually. This unique ID is what ties different components together and separates two similar ones. So when an enemy needs the player’s transform component, the player’s entity ID is used. Through the entity ID it can retrieve the transform component.
Let’s define the entity ID as a typedef. In this case we just use a 32-bit integer. That would give us a hypothetical maximum of 4,294,967,295 entities.
Create a new header file named entities.h and type in the following.
#pragma once
#include <stdint.h>
typedef uint32_t entity_id;
Of course we want to keep a list of which entities we currently have. We can do this easily with an array or vector, but what happens when we destroy an enemy? Do we just set everything to some default value? Or have a bool which indicates if an entity is destroyed?
Actually a good way to solve this problem is to sacrifice the top 8 bits of our 32-bit entity ID. These 8 bits, or 1 byte, will represent a generational index. This is just a number that increases when we create or destroy an entity.
Let’s look at a small example. When we destroy enemy with entity ID 6, it’s generational index would go up from 1 to 2. Then we quickly create a new enemy which gets assigned ID 6 since that spot is free. The generational index will go up again from 2 to 3.
Now when we try to access the original enemy in whatever way, we see that the generational index is not 1 anymore, so that indicates the enemy doesn’t exist anymore.
If we only would use a bool there would be no way to know that the enemy with ID 6 was a completely different one at this point in time.
To help us get the generational index or just the ID itself we can use some macros and bit operations. I won’t go into detail about how these work, but I recommend looking it up. Let’s also create a vector and queue to keep track of all the entities.
#pragma once
#include <stdint.h>
#include <vector>
#include <queue>
#define CAPACITY_ENTITIES 0xFFFFFF
#define GENERATION_BIT 24
#define GET_GENERATION(id) ((id & 0xFF000000) >> GENERATION_BIT)
#define GET_INDEX(id) (id & CAPACITY_ENTITIES)
#define CREATE_ID(generation, index) (generation << GENERATION_BIT) | index
// The upper 8 bits is the generation
// The lower 24 bits is the actual index in the array
typedef uint32_t entity_id;
//entities
std::vector<uint8_t> entities = { 0 };
std::queue<uint32_t> empty_entities_spots;
The index in the vector is the bottom 24 bits of the entity ID, while the upper 8 bits, the actual data in the vector, is the generational index.
Creating & Destroying Entities
We need some functions to create and destroy entities. They are pretty simple. Just add them to the bottom of the entities.h file.
entity_id create_entity()
{
if (!empty_entities_spots.empty())
{
uint32_t empty_spot = empty_entities_spots.front();
empty_entities_spots.pop();
entities[empty_spot] += 1;
entity_id new_entity = CREATE_ID(entities[empty_spot], empty_spot);
return new_entity;
}
entities.push_back(1);
return CREATE_ID(1, entities.size() - 1);
}
void destroy_entity(entity_id entity)
{
uint32_t entity_index = GET_INDEX(entity);
entities[entity_index] += 1;
empty_entities_spots.push(entity_index);
}
When creating an entity we first check if there are any empty spots in the vector. When there are we grab the first one, which is at the front of the queue. That empty spot is the index, but we also need the generational index. So we grab it’s current generational index and increase it by 1. Then we can create an entity ID out of it and return that.
If there is no empty spot we just create one at the end of the vector and create an entity ID with generational index 1.
Deleting is very simple. Just increase the generational index and add the vector index to the empty spots queue.
To test if this all works we can try to create an entity. We need to make some changes to our main.cpp. First we need to include our entities.h file.
#include "entities.h"
Then we comment out the old player stuff. We just want to test if our entity creation works or not. So we only create the player entity and print it’s entity ID every frame. Don’t forget to destroy the entity as well after the while loop.
void main()
{
entity_id player_entity = create_entity();
//player_t player;
//player.position.x = 0.0f;
//player.position.y = 0.0f;
//player.velocity.x = 0.0f;
//player.velocity.y = 0.0f;
////Hard code the acceleration
////We make the player move to the right
//player.acceleration.x = 1.0f;
//player.acceleration.y = 0.0f;
//We also hard code the delta time for now
//We aim for 60FPS, so one frame should take
//1/60th of a second
float delta_time = 1.0f / 60.0f;
while (1)
{
//movement_update(delta_time, &player);
//printf("Player's position is: { %0.2f, %0.2f }\n", player.position.x, player.position.y);
printf("Player's entity ID: %d", player_entity);
}
destroy_entity(player_entity);
}
Before we try to run this we also need to comment out the movement system like so:
#pragma once
#include "player.h"
//void movement_update(float delta_time, player_t* player)
//{
// player->velocity.x = player->velocity.x + (player->acceleration.x * delta_time);
// player->velocity.y = player->velocity.y + (player->acceleration.y * delta_time);
// player->position.x += player->velocity.x * delta_time;
// player->position.y += player->velocity.y * delta_time;
//}
If you followed along correctly you can build and run the code now. The console will spit out the player’s entity ID which should be 16777217. Try to figure out for yourself why the entity ID is that number and not 0 or 1.
End of Part 1
That’s already the end of part 1.
Luckily you can check out part 2 here.
Be sure to take a little break before continuing though.
Rest your eyes a bit by looking into the distance for around 20 seconds.
Drink some water or take a short stroll.
Your brain, eyes and the rest of your body will thank you.
The second part won’t go anywhere, it will still be here when you return from your stroll.
Comments
Interestingly, I *do* get an entity ID of 1. I assume it’s got something to do with the what 0hFFFFFF is in decimal but I’m still a little confused. The number you have is 2 integers higher than what can be represented with 24 bits…right?
Firstly, sorry for the very late reply!
I think there is something going wrong with the generation.
So you get 1 because the index is 1, but the generation is 0.
The generation should actually be 1. Then when you destroy it, do +1 so you get a generation of 2. This way you can easily tell if an entity is active or not, since only entities with an uneven generation are active at that point.