#include <archon/ecs.h>
Archon is a minimal archetype-based Entity-Component-System library written in C++20. I built this project as a learning exercise to explore how ergonomic APIs can be created using modern C++ features. If you are looking for an ECS for your next project, this is probably not it, but if you are interested in C++ template metaprogramming, consider reading on. This post documents my learnings about the language features and techniques I used in this project.
Why Archetypes?
Archetypes are basically containers that store component data of
entities with the same component types (a.k.a. component signatures) in
a dense manner, such as using vectors. The goal is to keep component
data close together in memory in order to reduce cache misses. The
result is that application code can, for example, iterate over all
RigidBody
, Velocity
and Transform
components in an efficient
manner, regardless of which entities these components belong.
// Sparse storage: Components are scattered across memory
Entity[0] -> Position*, Velocity*, Transform*
Entity[1] -> Position*, Velocity*, Transform*
Entity[2] -> Position*, Velocity*, Transform*
// Archetype storage: Components are tightly packed in SoA layout
Archetype<Position,Velocity,Transform> {
positions: [pos4, pos2, pos46, ...] // Cache line friendly
velocities: [vel4, vel2, vel46, ...] // SIMD friendly
transforms: [hp4, hp2, hp46, ...] // Prefetch friendly
// The entity association is implicit in that the components are stored at the same index.
entities: [ent4, ent2, ent46, ...]
entities_to_idx : {
ent4 -> 0,
ent2 -> 1,
ent46 -> 2,
}
}
What's the challenge?
The thing I needed to wrap my head around the most, is that Archetype
needs to store a collection of vector<T>
where T
is different for
each vector. This means some form of type-erasure is required. The
option I went with is to create a type-erased ComponentArray
that
stores raw component data along with component type infos. It is itself
not a template, to allow storing instances associates with different
types in a container, but has templated creation and access functions
that utilize ComponentTypeInfo
.
class ComponentArray
{
public:
template static ComponentArray create();
~ComponentArray();
[[nodiscard]] size_t size() const;
// Chooses optimal transition strategy based on type traits.
void push(void *src, bool allow_move = false);
void push(const void *src);
void reserve(size_t size);
void clear();
void remove(size_t idx);
void *get_ptr(size_t index);
template T *data();
template const T *data() const;
template T &get(size_t index);
template const T &get(size_t index) const;
private:
ComponentArray(const ComponentTypeInfo &meta);
void maybe_grow(size_t required_size);
void destroy_elements();
size_t element_count_ = 0;
ComponentTypeInfo meta_;
std::vector data_;
};
The ComponentTypeInfo
struct holds function pointers for special
member functions required for managing components, as well as type
traits that guide how transitions happen (std::memcpy
, move-construct,
copy-construct). It is created at runtime, when component types are
registered.
template <typename T>
void ComponentRegistry::register_component() {
const auto type_idx = std::type_index(typeid(std::decay_t<T>));
const ComponentTypeId meta_id = next_id++;
component_ids.insert({type_idx, meta_id});
// Store complete type metadata for runtime operations
ComponentTypeInfo meta_array{
.create_array = []() -> ComponentArray { return ComponentArray::create<T>(); },
// Type-erased constructor
.default_constructor = [](void *dst) { new (dst) T(); },
// Type-erased destructor
.destructor = [](void *obj) { static_cast<T *>(obj)->~T(); },
// Type-erased copy constructor
.copy_constructor = [](void *dst, const void *src) {
new (dst) T(*static_cast<const T *>(src));
},
// Type-erased move constructor
.move_constructor = [](void *dst, void *src) {
new (dst) T(std::move(*static_cast<T *>(src)));
},
// Type traits
.is_trivially_copyable = std::is_trivially_copyable_v<T>,
.is_nothrow_move_constructible = std::is_nothrow_move_constructible_v<T>,
.is_trivially_destructible = std::is_trivially_destructible_v<T>
.component_size = sizeof(T),
.type_name = typeid(T).name(),
};
meta_data.push_back(meta_array);
}
The type traits are used when pushing components to a ComponentArray
or during resizing to select the fastest operation:
void ComponentArray::push(void *src, bool allow_move) {
// Ensure we have space - may trigger reallocation with optimal strategy
maybe_grow((element_count_ + 1) * meta_.component_size);
auto *dst = data_.data() + element_count_ * meta_.component_size;
// Branch on compile-time type traits, optimized away by compiler
if (meta_.is_trivially_copyable) {
// Optimal: Plain old data types (Position, Velocity, etc.)
std::memcpy(dst, src, meta_.component_size);
} else if (allow_move && meta_.is_nothrow_move_constructible) {
// Good: Types with move semantics (std::string, std::vector, etc.)
meta_.move_constructor(dst, src);
} else {
// Fallback: Types requiring copy construction
meta_.copy_constructor(dst, src);
}
element_count_++;
}
The Archetype
is simply a container for ComponentArray
and entity
accounting.
class Archetype {
std::unordered_map<ComponentTypeId, ComponentArray> components;
std::unordered_map<EntityId, size_t> entities_to_idx;
std::vector<EntityId> entities;
ComponentMask mask; // Immutable after construction
};
Component Masks
The component registry assigns sequential IDs during the registration
phase, storing them in a hash map keyed by std::type_index
. Archetypes
store a bitmask of their component types for efficient bitwise
operations during the query process. The mask generation function uses
C++17 fold expressions to set bits for each component type:
template <typename... Components>
ComponentMask get_component_mask() {
ComponentMask mask;
(mask.set(ComponentRegistry::instance().get_component_type_id<Components>()), ...);
return mask;
}
API Design
Hiding storage layout and minimizing API surface
The API is designed so users never need to think about archetypes directly. After registering component types they can be added to entities. Behind the scenes, Archon automatically creates and manages archetypes as components are added and removed:
// Define components - any type works
struct Position { float x, y, z; };
struct Velocity { float vx, vy, vz; };
struct Health { int hp; };
// Register before first use
ecs::register_component<Position>();
ecs::register_component<Velocity>();
ecs::register_component<Health>();
ecs::World world;
auto entity = world.create_entity();
// Just add them, template parameters are deduced
world.add_components(entity,
Position{0.0f, 0.0f, 0.0f},
Velocity{1.0f, 0.0f, 0.0f}
);
// Add more components - archetype transitions happen automatically
world.add_components(entity, Health{100});
Query: How to work with the data
Now that component data can be stored in an efficient memory layout, the
question becomes how to work with it. I decided on a template-based
approach, where the query is encoded in the template parameter pack of
the Query
class. Query
also supports filtering on types that are not
used in the subsequent iteration using Query::with<>()
and
Query::without<>()
// Basic position update system
ecs::Query<Position, Velocity>().each(world, [](Position& pos, Velocity& vel) {
pos.x += vel.vx;
pos.y += vel.vy;
pos.z += vel.vz;
});
// Functions work too
void update_position(Position& pos, Velocity& vel) {
pos.x += vel.vx;
pos.y += vel.vy;
pos.z += vel.vz;
}
ecs::Query<Position, Velocity>().each(world, update_position);
// Optional entity parameter - automatically detected
ecs::Query<Health>().each(world, [](Health& health, ecs::EntityId entity) {
if (health.hp <= 0) {
printf("Entity %u died\n", entity);
}
});
struct PlayerTag {};
// Only process player entities
ecs::Query<Position, Velocity>()
.with<PlayerTag>()
.each(world, [](Position& pos, Velocity& vel) {
// Player-specific movement logic
});
struct EnemyTag {};
// Process all entities except enemies
ecs::Query<Position>()
.without<EnemyTag>()
.each(world, [](Position& pos) {
// Non-enemy position processing
});
On Query
construction, a component mask is created based on the RTTI
std::type_index
of queried components. When calling Query::each()
to
iterate over the resulting components, the component mask is matched
against all existing Archetypes which can then be iterated over:
bool Query<QueryComponents...>::matches(ComponentMask archetype_mask) const {
// include_mask and exclude_mask are computed at query construction time
const bool matches_all_included = (archetype_mask & include_mask) == include_mask;
const bool matches_no_excluded = (archetype_mask & exclude_mask) == 0;
return matches_all_included && matches_no_excluded;
}
Function Signature Deduction
The query system automatically analyzes arbitrary callable objects
(lambdas, function pointers, functors) and extract their parameter types
at compile time. The signature deduction is implemented via a neat trick
using C++17's Class Template Argument Deduction (CTAD). By constructing
a std::function
from the callable and immediately extracting its type
with decltype
, we can leverage the standard library's own function
analysis:
// Step 1: Force the compiler to deduce std::function specialization
template <typename F>
struct function_traits
: signature_extractor<decltype(std::function{std::declval<F>()})> {
};
// Step 2: Pattern match against the deduced std::function<R(Args...)> type
template <typename R, typename... Args>
struct signature_extractor<std::function<R(Args...)>> {
using argument_types = std::tuple<Args...>;
using decayed_argument_types = std::tuple<std::decay_t<Args>...>;
static constexpr size_t argument_count = sizeof...(Args);
};
// Step 3: Detect if function expects an extra EntityId parameter
template <typename T>
struct has_extra_param : std::false_type {};
template <typename Func, typename... ValueComps>
struct has_extra_param<std::tuple<Func, ValueComps...>>
: std::bool_constant<function_traits<Func>::argument_count == (sizeof...(ValueComps) + 1)> {};
template<typename... QueryComponents>
template<WorldType WorldT, typename Func>
void Query<QueryComponents...>::each(WorldT&& world, Func&& func) const {
// Compile-time function signature analysis
using ArgumentTypes = typename function_traits<Func>::argument_types;
constexpr bool has_entity_param = has_extra_param_v<std::tuple<Func, QueryComponents...>>;
for_each_matching_archetype(world, [&](auto& archetype) {
// Component arrays retrieved with compile-time type information
auto arrays = archetype.template data_arrays<QueryComponents...>(component_type_ids);
// Generated optimal inner loop with compile-time branching
for (size_t i = 0; i < archetype.entity_count(); ++i) {
[&]<size_t... I>(std::index_sequence<I...>) {
if constexpr (has_entity_param) {
func(std::get<I>(arrays)[i]..., archetype.get_entity(i));
} else {
func(std::get<I>(arrays)[i]...);
}
}(std::index_sequence_for<QueryComponents...>{});
}
});
}
This allows the query system to automatically determine the calling convention for any function. Consider these examples:
// Lambda without entity - detected as 2 parameters
auto update_pos = [](Position& pos, Velocity& vel) { pos.x += vel.vx; };
// function_traits<decltype(update_pos)>::argument_count == 2
// Lambda with entity - detected as 3 parameters
auto debug_entity = [](Position& pos, Velocity& vel, EntityId id) {
printf("Entity %u at (%f,%f)\n", id, pos.x, pos.y);
};
// function_traits<decltype(debug_entity)>::argument_count == 3
// The query system automatically calls the right overload:
ecs::Query<Position, Velocity>().each(world, update_pos); // No entity passed
ecs::Query<Position, Velocity>().each(world, debug_entity); // Entity passed automatically
Concept-Driven Const-Correctness
The const-correctness system uses C++20 concepts to ensure that const worlds can only be queried with functions that accept const component references, preventing mutation through the type system.
The foundation is a recursive concept that validates each function parameter against the world's constness:
// Core concept: Does this argument type respect the world's const-ness?
template <typename WorldT, typename ArgumentT>
concept ConstCompatible =
// Pass by value always works (you get a copy)
!std::is_reference_v<ArgumentT> ||
// OR: if it's a reference, const world requires const component
(!std::is_const_v<std::remove_reference_t<WorldT>> ||
std::is_const_v<std::remove_reference_t<ArgumentT>>);
// Validate all function arguments using fold expressions and immediate lambda
template <typename WorldT, typename Func>
concept ArgsConstCompatible =
[]<typename... Arguments>(std::tuple<Arguments...>*) {
return (ConstCompatible<WorldT, Arguments> && ...);
}(static_cast<typename function_traits<Func>::argument_types*>(nullptr));
This concept employs several C++20 features working in concert:
- Lambda template argument deduction:
static_cast
ofnullptr
toargument_types*
makes the parameter pack available for use inside the immediately invoked lambda. - Template parameter pack expansion:
Arguments...
unpacks all function parameters - Fold expressions: Combines the
ConstCompatible
compile-time boolean expression with logical AND to validate each argument.
// This compiles fine - const world, const components
void read_positions(const ecs::World& world) {
ecs::Query<Position>().each(world, [](const Position& pos) {
printf("Position: %f, %f\n", pos.x, pos.y); // Read-only
});
}
// Compilation ERROR - const world, non-const components
void illegal_mutation(const ecs::World& world) {
ecs::Query<Position>().each(world, [](Position& pos) { // ERROR!
pos.x += 1.0f; // Would violate const-correctness
});
}