seecs (pronounced see-ks) is a small header only RTTI ECS sparse set implementation for C++. Seecs stands for Simple-Enough-Entity-Component-System, which defines the primary goal:
To implement the core of a functional ECS using sparse sets as a resource for learning, while still keeping it efficient.
It's my take on a 'pure' ECS, in which entities are just IDs, components are data, and (most importantly) systems query entities based on components and operate that on data.
Here's an example of seecs in action:
#define SEECS_INFO_ENABLED
#include "seecs.h"
// Components hold data
struct A {
int x = 0;
};
struct B {
int y = 0;
};
struct C {
int z = 0;
};
int main() {
// Base ECS instance, acts as a coordinator
seecs::ECS ecs;
seecs::EntityID e1 = ecs.CreateEntity();
seecs::EntityID e2 = ecs.CreateEntity("e2"); // Custom name for debugging
seecs::EntityID e3 = ecs.CreateEntity();
seecs::EntityID e4 = ecs.CreateEntity();
seecs::EntityID e5 = ecs.CreateEntity();
ecs.Add<A>(e1, {5}); // Initialize component A(5)
ecs.Add<B>(e1); // Default constructor called
ecs.Add<C>(e1);
ecs.Add<A>(e2);
ecs.Add<A>(e3);
ecs.Add<C>(e3);
ecs.Add<B>(e4);
ecs.Add<A>(e5);
ecs.Add<C>(e5);
auto view = ecs.View<A, B>(); // Defines a view of entities with components A and B
view.ForEach([&](seecs::EntityID id, A& a, B& b) {
// ...
});
// OR
view.ForEach([&](A& a, B& b) {
// ...
});
// OR
auto packed = view.GetPacked();
for (auto [id, components] : packed) {
auto [a, b] = components;
// ...
}
}If you'd like a more practical example, I made a game with an older build of this library here.
Specs: AMD Ryzen 5 5600x (6 cores, 3.7 GHz), Compiled via Visual Studio 2022 on a windows machine.
| Entities | 100 | 10,000 | 1,000,000 |
|---|---|---|---|
CreateEntity |
0.0112ms | 0.23ms | 34.5ms |
Add<T> |
0.0359ms | 0.24ms | 36.8ms |
Get<T> |
0.0011ms | 0.05ms | 5.5ms |
Remove<T> |
0.0027ms | 0.13ms | 14.3ms |
DeleteEntity |
0.0199ms | 0.74ms | 72.9ms |
ForEach (2 components) |
0.0024ms | 0.08ms | 9.1ms |
Get<T> (2 components) |
0.0013ms | 0.09ms | 12.0ms |
ForEach (4 components) |
0.0027ms | 0.10ms | 18.3ms |
Get<T> (4 components) |
0.0025ms | 0.19ms | 24.3ms |
- Note: These are IDEAL CONDITIONS in which the sparse set is densley populated and packed. Mileage may vary on use case.
Systems are not enforced in seecs. This is because it provides you with everything you need to get a system running, and I don't want to force you into some rigid structure just because I deem it best.
If you want to know how I add systems in seecs, I simply just do something like this:
namespace MovementSystem {
static void Update(ECS& ecs, float dt) {
auto view = ecs.View<Transform, Physics>().Without<Frozen>(); // Queries entities with Transform and Physics, but omits 'Frozen' entities
view.ForEach([dt](EntityID id, Transform& transform, Physics& physics) {
transform.position += physics.velocity * dt;
});
}
}
// In Main loop:
MovementSystem::Update(ecs, deltaTime);And that's it. It's on you to manage these systems however you want. You can make them function like I did here, or make a system it's own class that might even manage the entities belonging to it, whatever.
Here are some functions you might find useful when making systems:
// You can manually retrieve components via the Entity ID:
A& component = ecs.Get<A>(id);
A* componentPoints = ecs.GetPtr<A>(id);
// You can also check if an entity has a component(s):
bool hasComponent = ecs.Has<A, B, C>(id); // true if entity has ALL components
bool hasAny = ecs.HasAny<A, B, C>(id); // true if entity has ANY of the components
// Resets ECS to initial state, removing all entities/components
ecs.Reset();Components in an ECS should be nothing but data, and systems should handle the processing. Components typically should not reference or be aware of eachother.
Due to the way components are stored in sparse sets (where they can move around frequently during regular operation) a component must define a default constructor, copy constructor, copy assignment operator, move constructor, and move assignment operator.
Most components should be trivially copyable/movable, so these should be defined implicitly by the compiler (and it's in your interest in a data-driven design to keep them that way!)
Here's an example of a nice, 'good' components:
struct Sprite {
const Texture* src;
SpriteRegion region;
glm::vec3 tint{ 1, 1, 1 };
float rotation = 0;
bool flipHorizontal = false;
};All of these fields can be copied/moved trivially, and sparse sets like the simplicity.
Here's an example of a component you might run into trouble with:
struct BadComponents {
std::unordered_map<int, std::unique_ptr<int>> data;
}This component will not compile, since seecs will try and move it around in memory, but std::unique_ptr is not copyable.
As a VERY general rule of thumb (which I have been on record for breaking) keep big structures (vectors, maps) and pointers out of your components as much as possible. If you need to use a pointer (like the 'Sprite' component above) prefer to use flyweights.
seecs makes deleting entities easy and can de done directly while iterating:
view.ForEach([&ecs](EntityID id, HealthComponent& hc) {
ecs.DeleteEntity(id);
});You can also safely add/remove components while iterating without encountering undefined behaviour:
view.ForEach([&ecs](EntityID id, HealthComponent& hc) {
ecs.Remove<HealthComponent>(id);
ecs.Add<NewComponent>(id);
});
You can access an entity in one of two ways currenty,
- Via views
This is probably the most common way you'll access entities; by specifying a group of components and seecs will return all the entity IDs that match said group, like this:
auto view = ecs.View<A, B>();
view.ForEach([](A& a, B& b) { //... });Behind the scenes, a view takes the smallest of it's component pools and iterates all of the entities in it, checking if it has the other components. This means when there's little overlap between entities that share components, there will be wasted iterations. But in practise, I haven't run into this situation much; so I usually stick with views.
- Via
GetPacked()
This does something similar to views, but instead of iterating over the entities, it returns a vector of tuples containing the entity ID and the components that you requested. This is useful when you want to iterate over the entities in a different way, like this:
auto packed = view.GetPacked();
for (auto [id, components] : packed) {
auto [a, b] = components;
// ...
}You can even use indices to access the components if you want to:
auto packed = view.GetPacked();
for (size_t i = 0; i < packed.size(); i++) {
EntityID id = packed[i].id;
auto [transformA, colliderA] = packed[i].components;
// ...
}- Via ID lists
If we know what components an entity will have beforehand, we can utilize the Get method and just extract all the components that we need given an Entity ID:
vector<EntityID> enemies;
void Update() {
for (EntityID id : enemies) {
Transform& transform = ecs.Get<Transform>(id);
Health& health = ecs.Get<Health>(id);
}
}This is more rigid, and some call it an anti-pattern in an ECS, but it definitely has its merits and could potentially be more performant than views. It's a good idea to benchmark both.
- Copying
- Serialization (...?)
This project just one part of a project I'm working on, and I decided to release it on its own. This means improvements to seecs will roll around when they are needed in the main project.
A big thanks to EnTT and Austin Morian's ECS article, which were both invaluable when learning about the concepts used for this project.