Skip to content

Entity Component System

Dunkansdk edited this page Dec 8, 2025 · 2 revisions

Entity-Component-System Architecture

Table of Contents

Overview

The another-dunkan-engine implements a custom Entity-Component-System (ECS) architecture designed for high performance and type safety. Unlike traditional object-oriented approaches, ECS separates:

  • Entities - Unique identifiers that represent game objects
  • Components - Pure data containers (no logic)
  • Systems - Logic that operates on entities with specific component combinations

This separation enables:

  • Cache-friendly data access - Components stored in contiguous memory
  • Flexible composition - Entities defined by component combinations, not inheritance
  • Compile-time type safety - Template metaprogramming eliminates runtime type checks

Architecture Components

classDiagram
    class EntityManager {
        -vector~Entity~ m_entities
        -ComponentStorage m_components
        +create_entity() Entity&
        +add_component~T~(Entity&, args...) T&
        +get_component~T~(Entity&) T&
        +foreach~Components, Tags~(callback)
        +kill(Entity&)
        +refresh()
    }
    
    class Entity {
        -uint64_t m_component_mask
        -uint64_t m_tag_mask
        -tuple~Keys...~ m_component_keys
        -size_t id
        -bool alive
        +add_component~T~(key)
        +has_component~T~() bool
        +get_component_key~T~() key_type
        +has_tag~T~() bool
        +is_alive() bool
        +kill()
    }
    
    class ComponentStorage {
        -tuple~Slotmaps...~ m_component_tuple
        -tuple~Singletons...~ m_singleton_tuple
        +get_storage~T~() Slotmap~T~&
        +get_singleton_storage~T~() T&
    }
    
    class Slotmap~T~ {
        -array~key_type~ m_index
        -array~T~ m_data
        -array~index_type~ m_erase
        +push_back(T) key_type
        +operator[](key_type) T&
        +erase(key_type) bool
        +is_valid(key_type) bool
    }
    
    EntityManager o-- Entity : contains
    EntityManager *-- ComponentStorage : owns
    ComponentStorage *-- Slotmap~T~ : owns multiple
    Entity ..> Slotmap~T~ : references via keys
Loading

EntityManager

The central hub that orchestrates all entity and component operations. Template parameters define the system's capabilities:

template <
    typename COMPONENT_LIST,      // Typelist of regular components
    typename SINGLETON_LIST,      // Typelist of singleton components
    typename TAG_LIST = Typelist<>,  // Typelist of tags (zero-size markers)
    std::size_t CAPACITY = 10     // Max components per type in slotmaps
>
struct EntityManager;

Engine Configuration (game/types.hpp):

using Components = ADE::META_TYPES::Typelist<
    LightComponent,
    PhysicsComponent,
    RenderComponent,
    ShadowComponent
>;

using SingletonComponents = ADE::META_TYPES::Typelist<
    CameraComponent,
    ConfigurationComponent
>;

using EntityManager = ADE::EntityManager<Components, SingletonComponents, Tags, 1024>;

Entity

A lightweight handle containing:

  • Component Mask - Bitfield indicating which components are attached
  • Tag Mask - Bitfield indicating which tags are applied
  • Component Keys - Generational indices for component lookup
  • ID - Unique identifier
  • Alive Flag - Marks entity for deferred deletion
struct Entity {
    uint64_t m_component_mask{};
    uint64_t m_tag_mask{};
    tuple<key_type<PhysicsComponent>, key_type<RenderComponent>, ...> m_component_keys{};
    size_t id{next_id++};
    bool alive{true};
};

ComponentStorage

A type-safe wrapper around a tuple of slotmaps. Uses template metaprogramming to:

  1. Transform Typelist<A, B, C>tuple<Slotmap<A>, Slotmap<B>, Slotmap<C>>
  2. Map component types to compile-time indices
  3. Provide zero-overhead component access
template <typename COMPONENT>
constexpr auto& get_storage() noexcept {
    constexpr auto id = component_info::template id<COMPONENT>();
    return std::get<id>(m_component_tuple);
}

Entity Lifecycle

stateDiagram-v2
    [*] --> Created : create_entity()
    Created --> Active : add_component()
    Active --> Active : add_component()\nerase_component()
    Active --> MarkedForDeath : kill()
    MarkedForDeath --> Destroyed : refresh()
    Destroyed --> [*]
    
    note right of Active
        Entity is usable
        Components can be added/removed
        Systems can process it
    end note
    
    note right of MarkedForDeath
        alive = false
        Still in m_entities vector
        Systems skip it
        Waiting for cleanup
    end note
Loading

Lifecycle Operations

Creation:

auto& entity = entity_manager.create_entity();
// Entity added to m_entities vector
// Component mask = 0 (no components)
// alive = true

Component Addition:

auto& physics = entity_manager.add_component<PhysicsComponent>(entity, x, y, z);
// 1. Component stored in slotmap
// 2. Key saved in entity.m_component_keys
// 3. Bit set in entity.m_component_mask

Component Access:

auto& physics = entity_manager.get_component<PhysicsComponent>(entity);
// 1. Check entity has component (assert)
// 2. Get key from entity
// 3. Lookup in slotmap using key

Deletion (Deferred):

entity_manager.kill(entity);
// 1. entity.alive = false
// 2. All component keys erased from slotmaps
// 3. Entity remains in vector (marked for removal)

entity_manager.refresh();
// Removes all dead entities from m_entities vector

Component Masks

Component masks enable O(1) checks for component presence without memory indirection.

graph LR
    subgraph "Component Typelist"
        C0[PhysicsComponent #40;id=0#41;]
        C1[RenderComponent #40;id=1#41;]
        C2[LightComponent #40;id=2#41;]
        C3[ShadowComponent #40;id=3#41;]
    end
    
    subgraph "64-bit Component Mask"
        B0[...0]
        B1[0]
        B2[1]
        B3[1]
        B4[0]
        B5[1]
    end
    
    C0 -.-> B5
    C1 -.-> B4
    C2 -.-> B3
    C3 -.-> B2
    
    style B3 fill:#90EE90
    style B5 fill:#90EE90
    style B2 fill:#FFB6C1
    style B4 fill:#FFB6C1
Loading

Mask Generation (compile-time):

template <typename COMPONENT>
consteval static uint64_t mask() noexcept {
    return 1 << component_info::template id<COMPONENT>();
}

// PhysicsComponent -> mask = 0b00001 (bit 0)
// RenderComponent  -> mask = 0b00010 (bit 1)
// LightComponent   -> mask = 0b00100 (bit 2)

Mask Operations:

// Add component
entity.m_component_mask |= component_info::mask<PhysicsComponent>();

// Has component
bool has = entity.m_component_mask & component_info::mask<RenderComponent>();

// Remove component
entity.m_component_mask ^= component_info::mask<LightComponent>();

Automatic Mask Type Selection: Based on the number of components, the smallest suitable integer type is chosen:

Component Count Mask Type Memory Usage
≤ 8 uint8_t 1 byte
≤ 16 uint16_t 2 bytes
≤ 32 uint32_t 4 bytes
≤ 64 uint64_t 8 bytes

This optimization is performed at compile-time via template metaprogramming.

Tag System

Tags are zero-size markers for categorizing entities without additional memory overhead. They use a separate mask but no data storage.

// Define tags
using Tags = ADE::META_TYPES::Typelist<PlayerTag, EnemyTag, ProjectileTag>;

// Add tag to entity
entity.add_tag<PlayerTag>();

// Check for tag
if (entity.has_tag<EnemyTag>()) {
    // Process enemy logic
}

Memory Comparison:

  • Component: Data stored in slotmap + key in entity + mask bit
  • Tag: Only a mask bit (no data, no key)

Iteration Patterns

foreach - Filtered Iteration

Iterate over entities matching specific component and tag requirements:

sequenceDiagram
    participant System
    participant EntityManager
    participant Entity
    participant ComponentStorage
    
    System->>EntityManager: foreach<Components, Tags>(callback)
    loop For each entity
        EntityManager->>Entity: is_alive()?
        Entity-->>EntityManager: true
        EntityManager->>Entity: has_component<C1>()?
        Entity-->>EntityManager: check mask (true)
        EntityManager->>Entity: has_component<C2>()?
        Entity-->>EntityManager: check mask (true)
        EntityManager->>ComponentStorage: get_component<C1>(entity)
        ComponentStorage-->>EntityManager: C1&
        EntityManager->>ComponentStorage: get_component<C2>(entity)
        ComponentStorage-->>EntityManager: C2&
        EntityManager->>System: callback(entity, C1&, C2&)
    end
Loading

Usage Example:

// Iterate over entities with PhysicsComponent and RenderComponent
entity_manager.foreach<
    META_TYPES::Typelist<PhysicsComponent, RenderComponent>,  // Required components
    META_TYPES::Typelist<>                                     // Required tags
>([](Entity& entity, PhysicsComponent& physics, RenderComponent& render) {
    // Components passed by reference
    physics.x += physics.velocity_x * dt;
    render.position = glm::vec3(physics.x, physics.y, physics.z);
});

Implementation Details:

template <typename... C, typename... T>
void foreach_impl(auto&& process, 
                  META_TYPES::Typelist<C...>, 
                  META_TYPES::Typelist<T...>) {
    for (auto& entity : m_entities) {
        if (entity.is_alive()) {
            // Fold expression: entity.has_component<C1>() && entity.has_component<C2>() && ...
            auto has_components = (true && ... && entity.template has_component<C>());
            
            if (has_components) {
                // Fold expression: get_component<C1>(entity), get_component<C2>(entity), ...
                process(entity, get_component<C>(entity)...);
            }
        }
    }
}

forall - Unfiltered Iteration

Iterate over all entities (no component filtering):

entity_manager.forall([](Entity& entity) {
    std::cout << "Entity ID: " << entity.get_id() << std::endl;
});

Memory Layout

The ECS architecture optimizes for cache coherency by storing component data contiguously:

graph TD
    subgraph "EntityManager"
        EV[vector#60;Entity#62;]
        CS[ComponentStorage]
    end
    
    subgraph "Entities Vector #40;scattered in memory#41;"
        E0[Entity 0<br/>mask: 0b101<br/>keys: #123;P:3, R:5#125;]
        E1[Entity 1<br/>mask: 0b011<br/>keys: #123;P:1, R:2#125;]
        E2[Entity 2<br/>mask: 0b100<br/>keys: #123;P:4#125;]
    end
    
    subgraph "ComponentStorage Tuple"
        direction LR
        SM_P[Slotmap#60;Physics#62;]
        SM_R[Slotmap#60;Render#62;]
        SM_L[Slotmap#60;Light#62;]
    end
    
    subgraph "Slotmap#60;PhysicsComponent#62; #40;packed, cache-friendly#41;"
        P0["#91;0#93; Entity1.Physics"]
        P1["#91;1#93; Entity2.Physics"]
        P2["#91;2#93; Entity0.Physics"]
    end
    
    subgraph "Slotmap#60;RenderComponent#62; #40;packed, cache-friendly#41;"
        R0["#91;0#93; Entity0.Render"]
        R1["#91;1#93; Entity1.Render"]
    end
    
    EV --> E0
    EV --> E1
    EV --> E2
    CS --> SM_P
    CS --> SM_R
    CS --> SM_L
    
    SM_P --> P0
    SM_P --> P1
    SM_P --> P2
    
    SM_R --> R0
    SM_R --> R1
    
    E0 -.key.-> P2
    E0 -.key.-> R0
    E1 -.key.-> P0
    E1 -.key.-> R1
    E2 -.key.-> P1
    
    style P0 fill:#E8F5E9
    style P1 fill:#E8F5E9
    style P2 fill:#E8F5E9
    style R0 fill:#E3F2FD
    style R1 fill:#E3F2FD
Loading

Key Observations:

  1. Entities - Stored in a vector, may not be cache-friendly during iteration
  2. Components - Stored in packed arrays within slotmaps, excellent cache locality
  3. Indirection - Entities store keys (generational indices), not direct pointers
  4. Safety - Keys include generation numbers to detect use-after-free

Performance Considerations

Strengths

Component Iteration - Iterating over all components of a type is cache-optimal

// All PhysicsComponents stored contiguously in memory
for (auto& physics : physics_slotmap) {
    // Excellent cache performance
}

Compile-time Polymorphism - No vtables, no virtual function overhead

// Component types resolved at compile-time
entity_manager.foreach<Typelist<A, B>>(...);  // Zero runtime type checks

Flexible Composition - Add/remove components dynamically without inheritance hierarchies

Tradeoffs

⚠️ Entity Iteration - Iterating entities to find those with specific components requires:

  1. Checking component masks (fast)
  2. Indirection through keys (moderate overhead)

⚠️ Entity Storage - Entities stored in std::vector, not as cache-friendly as component arrays

⚠️ Deletion Cost - kill() + refresh() is deferred, trading immediate deletion for batched cleanup

Optimization Tips

  1. Batch Operations - Call refresh() once per frame, not after every kill()
  2. Reserve Capacity - Pre-allocate entity vector: m_entities.reserve(1000)
  3. Minimize Components - Fewer component types = smaller masks, less memory
  4. POD Components - Keep components trivially copyable for optimal performance
  5. System Ordering - Process systems in order of data dependencies for better cache usage

Singleton Components

For global state (camera, configuration), use singleton components:

// Access singleton (no entity needed)
auto& camera = entity_manager.get_singleton_component<CameraComponent>();
camera.position = glm::vec3(0, 10, 0);

Storage Difference:

  • Regular Component: One instance per entity, stored in slotmap
  • Singleton Component: One instance total, stored directly in tuple

Example: Physics System

void PhysicsSystem::update(EntityManager& em, float dt) {
    // Iterate entities with physics components
    em.foreach<
        META_TYPES::Typelist<PhysicsComponent>,
        META_TYPES::Typelist<>
    >([dt](Entity& entity, PhysicsComponent& physics) {
        // Update position based on velocity
        physics.x += physics.velocity_x * dt;
        physics.y += physics.velocity_y * dt;
        physics.z += physics.velocity_z * dt;
        
        // Apply gravity
        physics.velocity_y -= 9.81f * dt;
    });
}

Related Documentation


Next: Learn about the Slotmap Implementation that makes component storage fast and safe.