#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:

// 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
    });
}