Hello!
We start the fourth stream
“Developer C ++” , one of the most active courses we have, judging by the real meetings, where not only the “crusaders” come to communicate with
Dima Shebordaev :) Well, in general, the course has already grown to One of the biggest among us, it remains unchanged that
Dima holds open lessons and we select interesting materials before the start of the course.
Go!
')
Introduction
The Entity Component System (ECS, "entity-component-system") is now at its peak of popularity as an architectural alternative that emphasizes the Composition over inheritance principle. In this article I will not go into the details of the concept, since there are already enough resources on this topic. There are many ways to implement ECS, and, but I, most often, choose rather complex ones that can confuse newbies and take a lot of time.
In this post, I will describe a very simple way to implement ECS, the functional version of which requires almost no code, but completely follows the concept.

ECS
Speaking of ECS, people often mean different things. When I talk about ECS, I mean a system that allows you to define entities that have zero and more pure data components. These components are selectively processed by pure logic systems. For example, the position, speed, hitbox and health of the component are tied to the entity E. They just store the data. For example, the health component can store two integers: one for current health and the second for maximum. The system can be a health regeneration system that finds all instances of the health component and increases them by 1 every 120 frames.
Typical C ++ implementation
There are many libraries offering ECS implementations. Usually, they include one or more items from the list:
- Inheritance of the base Component / System class
GravitySystem : public ecs::System
; - Active use of templates;
- Both are in some CRTP form;
- The
EntityManager
class that manages the creation / storage of entities in an implicit way.
Some quick examples from google:
All these methods have the right to life, but there are some flaws in them. How opaque they process data means that it will be difficult to understand what is going on inside and whether there is a slowdown in performance. This also means that you will have to examine the entire abstraction layer and make sure that it fits well with the already existing code. Do not forget about the hidden bugs, which are probably hidden a lot in the amount of code that you have to debug.
The template-based approach can greatly influence the compile time and how often you will have to rebuild the build. While concepts on the basis of inheritance can worsen productivity.
The main reason why I think these approaches are excessive is that the problem they solve is too simple. In the end, these are just additional data components related to the entity and their selective processing. Below I will show a very simple way of how this can be implemented.
My simple approach
EssenceIn some approaches, the class Entity is defined, in others they work with entities as ID / handle. In the component approach, an entity is nothing but the components associated with it, and for this the class is not needed. An entity will explicitly exist based on its associated components. To do this, we define:
using EntityID = int64_t;
Entity componentsComponents are different data types associated with existing entities. We can say that for each entity e, e will have zero and more accessible component types. In essence, these are component-based key-value relationships and, fortunately, for this there are standard library tools in the form of maps.
So, I define the components as follows:
struct Position { float x; float y; }; struct Velocity { float x; float y; }; struct Health { int max; int current; }; template <typename Type> using ComponentMap = std::unordered_map<EntityID, Type>; using Positions = ComponentMap<Position>; using Velocities = ComponentMap<Velocity>; using Healths = ComponentMap<Health>; struct Components { Positions positions; Velocities velocities; Healths healths; };
This is enough to label entities through components, as expected from ECS. For example, to create an entity with a position and health, but without speed, you need:
To destroy an entity with a given ID, we simply
.erase()
it from each card.
SystemsThe last component we need is systems. This is the logic that works with components to achieve a specific behavior. Since I like to simplify everything, I use normal functions. The health regeneration system mentioned above may simply be the next function.
void updateHealthRegeneration(int64_t currentFrame, Healths& healths) { if(currentFrame % 120 == 0) { for(auto& [id, health] : healths) { if(health.current < health.max) ++health.current; } } }
We can put a call to this function in a suitable place in the main loop and pass it to the repository of the health component. Because the health store only contains entries for entities that have health, it can process them in isolation. It also means that the function takes only the necessary data and does not touch the irrelevant.
What if the system works with more than one component? Let's say a physical system that changes position based on speed. To do this, we need to intersect all the keys of all the involved component types and iterate their values. At such a moment, the standard library is no longer enough, but writing helpers is not so difficult. For example:
void updatePhysics(Positions& positions, const Velocities& velocities) {
Or you can write a more compact helper, allowing more efficient access through iteration instead of searching.
void updatePhysics(Positions& positions, const Velocities& velocities) {
Thus, we got acquainted with the basic functionality of the usual ECS.
Benefits
This approach is very effective, as it is built from scratch, without limiting abstraction. You do not have to integrate external libraries or adapt the code base to the predefined ideas of what Entities / Components / Systems should be.
And since this approach is completely transparent, you can create any utilities and helpers based on it. This implementation grows with the needs of your project. Most likely, for simple prototypes or games for game jam'ov, you will have enough functionality described above.
Thus, if you are new to this entire ECS area, such a straightforward approach will help you understand the main ideas.
Restrictions
But, as in any other method, there are some limitations. In my experience, just such an implementation using
unordered_map
in any non-trivial game will lead to performance problems.
The iteration of key crossing on multiple
unordered_map
instances with multiple entities does not scale well because you are actually doing
N*M
search operations, where N is the number of overlapping components, M is the number of matching entities, and
unordered_map
not very good at caching. This problem can be resolved by using a key-value store that is more suitable for iteration instead of
unordered_map
.
Another limitation is boilerplating. Depending on what you are doing, identifying new components can become tedious. It may be necessary to add an advertisement not only in the Components structure, but also in the spawn function, serialization, utility debug functions, and so on. I ran into this myself and solved the problem using code generation - I defined components in external json files, and then generated C ++ components and helper functions at the assembly stage. I’m sure you can find other pattern-based ways to fix any boilerplate problems you’ll run into.
THE END
If you have questions and comments, you can leave them here or go to
an open lesson with
Dima , listen to him and ask around there.