DEV Community

Cover image for Beyond OOP: Entity Component System schema for high performance
Piotr Borys
Piotr Borys

Posted on

Beyond OOP: Entity Component System schema for high performance

Object-Oriented Programming (OOP) taught a generation of software engineers to model complex domains by creating class hierarchies. In games and real-time systems, we start with simple abstractions: a base NPC class extended by Player, EasyEnemy, Companion, or ToughEnemy.

Over time, this model breaks down. A Companion might need inventory management from Player and pathfinding from ToughEnemy. Single inheritance forces you to choose between code duplication or pushing specialized methods up into a bloated "God Class."

Beyond code cleanliness, OOP suffers from a silent killer: poor CPU cache locality. Storing polymorphic objects as heap-allocated pointers (std::vector<std::unique_ptr<NPC>>) means your CPU spends more cycles chasing pointers through RAM than performing calculations.

Entity Component System (ECS) replaces deep inheritance trees with Data-Oriented Design (DOD):

  • Entity: A lightweight ID handle (no data, no logic).
  • Component: Plain-Old-Data (POD) structs stored contiguously in memory.
  • System: Stateless functions that operate over flat arrays of components.

1. Concrete Mapping: OOP vs. ECS

Instead of defining class types, entities are composed at runtime by attaching data components:

Entity Type Position Velocity Health Inventory AIBehavior
Player
EasyEnemy
ToughEnemy
Companion

Notice how Companion simply reuses Inventory and AIBehavior without touching Player or inheriting from a rigid base class.

2. Sample C++17 ECS Implementation

This minimal implementation uses sparse sets to store component data in contiguous vectors, enabling O(1) lookup while keeping iteration memory-dense and CPU cache-friendly.

#include <iostream>
#include <vector>
#include <unordered_map>
#include <typeindex>
#include <memory>
#include <cstdint>
#include <cassert>

using Entity = std::uint32_t;
constexpr Entity NULL_ENTITY = 0xFFFFFFFF;

// ============================================================================
// 1. DATA COMPONENTS (POD - Pure Data)
// ============================================================================
struct Position { float x{0.0f}, y{0.0f}; };
struct Velocity { float dx{0.0f}, dy{0.0f}; };
struct Health   { int current{100}, max{100}; };

struct Inventory {
    std::vector<int> itemIDs;
    int capacity{10};
};

enum class AIType { Easy, Tough, Companion };
struct AIBehavior {
    AIType type{AIType::Easy};
    float aggroRadius{10.0f};
};

// ============================================================================
// 2. SPARSE SET COMPONENT POOL
// ============================================================================
class IPool {
public:
    virtual ~IPool() = default;
    virtual void Remove(Entity entity) = 0;
};

template <typename T>
class ComponentPool : public IPool {
public:
    void Insert(Entity entity, T component) {
        if (entity >= m_Sparse.size()) {
            m_Sparse.resize(entity + 1, NULL_ENTITY);
        }
        m_Sparse[entity] = static_cast<Entity>(m_DenseData.size());
        m_DenseEntities.push_back(entity);
        m_DenseData.push_back(component);
    }

    void Remove(Entity entity) override {
        if (!Has(entity)) return;

        // Swap with the last element to maintain contiguous memory
        Entity indexToRemove = m_Sparse[entity];
        Entity lastEntity = m_DenseEntities.back();

        m_DenseData[indexToRemove] = m_DenseData.back();
        m_DenseEntities[indexToRemove] = lastEntity;

        m_Sparse[lastEntity] = indexToRemove;
        m_Sparse[entity] = NULL_ENTITY;

        m_DenseData.pop_back();
        m_DenseEntities.pop_back();
    }

    bool Has(Entity entity) const {
        return entity < m_Sparse.size() && m_Sparse[entity] != NULL_ENTITY;
    }

    T& Get(Entity entity) {
        assert(Has(entity) && "Entity does not have requested component!");
        return m_DenseData[m_Sparse[entity]];
    }

    // Direct access to contiguous memory for high-speed cache execution
    std::vector<T>& GetData() { return m_DenseData; }
    const std::vector<Entity>& GetEntities() const { return m_DenseEntities; }

private:
    std::vector<Entity> m_Sparse;          // Entity ID -> Index in Dense vector
    std::vector<Entity> m_DenseEntities;  // Index -> Entity ID
    std::vector<T>      m_DenseData;      // Contiguous Component Data
};

// ============================================================================
// 3. REGISTRY (Entity & Component Manager)
// ============================================================================
class Registry {
public:
    Entity CreateEntity() {
        return m_EntityCounter++;
    }

    template <typename T>
    void AddComponent(Entity entity, T component) {
        GetPool<T>()->Insert(entity, component);
    }

    template <typename T>
    T& GetComponent(Entity entity) {
        return GetPool<T>()->Get(entity);
    }

    template <typename T>
    bool HasComponent(Entity entity) {
        return GetPool<T>()->Has(entity);
    }

    template <typename T>
    ComponentPool<T>* GetPool() {
        std::type_index typeKey = typeid(T);
        auto it = m_Pools.find(typeKey);
        if (it == m_Pools.end()) {
            it = m_Pools.emplace(typeKey, std::make_unique<ComponentPool<T>>()).first;
        }
        return static_cast<ComponentPool<T>*>(it->second.get());
    }

private:
    Entity m_EntityCounter{0};
    std::unordered_map<std::type_index, std::unique_ptr<IPool>> m_Pools;
};

// ============================================================================
// 4. STATELESS SYSTEMS
// ============================================================================
namespace MovementSystem {
    void Update(Registry& registry, float dt) {
        auto* posPool = registry.GetPool<Position>();
        auto* velPool = registry.GetPool<Velocity>();

        // Cache-friendly loop over contiguous memory buffers
        const auto& entities = velPool->GetEntities();
        const auto& velocities = velPool->GetData();

        for (size_t i = 0; i < entities.size(); ++i) {
            Entity entity = entities[i];
            if (posPool->Has(entity)) {
                auto& pos = posPool->Get(entity);
                const auto& vel = velocities[i];

                pos.x += vel.dx * dt;
                pos.y += vel.dy * dt;
            }
        }
    }
}

namespace AISystem {
    void Update(Registry& registry) {
        auto* aiPool = registry.GetPool<AIBehavior>();
        const auto& entities = aiPool->GetEntities();
        auto& aiData = aiPool->GetData();

        for (size_t i = 0; i < entities.size(); ++i) {
            Entity e = entities[i];
            switch (aiData[i].type) {
                case AIType::Easy:
                    std::cout << "[AI] Entity " << e << " (EasyEnemy): Wandering casually.\n";
                    break;
                case AIType::Tough:
                    std::cout << "[AI] Entity " << e << " (ToughEnemy): Aggressively flanking player.\n";
                    break;
                case AIType::Companion:
                    std::cout << "[AI] Entity " << e << " (Companion): Following player and offering support.\n";
                    break;
            }
        }
    }
}

// ============================================================================
// 5. EXECUTION & VERIFICATION
// ============================================================================
int main() {
    Registry registry;

    // 1. Create Player
    Entity player = registry.CreateEntity();
    registry.AddComponent(player, Position{0.0f, 0.0f});
    registry.AddComponent(player, Velocity{1.5f, 0.0f});
    registry.AddComponent(player, Health{100, 100});
    registry.AddComponent(player, Inventory{{101, 102}, 20});

    // 2. Create Tough Enemy
    Entity toughEnemy = registry.CreateEntity();
    registry.AddComponent(toughEnemy, Position{10.0f, 5.0f});
    registry.AddComponent(toughEnemy, Velocity{-0.5f, -0.5f});
    registry.AddComponent(toughEnemy, Health{250, 250});
    registry.AddComponent(toughEnemy, Inventory{{201}, 5});
    registry.AddComponent(toughEnemy, AIBehavior{AIType::Tough, 15.0f});

    // 3. Create Companion
    Entity companion = registry.CreateEntity();
    registry.AddComponent(companion, Position{1.0f, 0.0f});
    registry.AddComponent(companion, Velocity{1.2f, 0.0f});
    registry.AddComponent(companion, Health{150, 150});
    registry.AddComponent(companion, Inventory{{301, 302, 303}, 15});
    registry.AddComponent(companion, AIBehavior{AIType::Companion, 8.0f});

    std::cout << "=== INITIAL STATE CREATED ===\n\n";

    // Simulate 1 Frame tick
    float dt = 0.016f; // ~60 FPS

    std::cout << "--- Executing AISystem ---\n";
    AISystem::Update(registry);

    std::cout << "\n--- Executing MovementSystem ---\n";
    MovementSystem::Update(registry, dt);

    std::cout << "\nPlayer Position after movement: (" 
              << registry.GetComponent<Position>(player).x << ", " 
              << registry.GetComponent<Position>(player).y << ")\n";

    return 0;
}
Enter fullscreen mode Exit fullscreen mode

3. Why ECS Extends Beyond Game Engines

While ECS originated in video games to solve object composition and frame-budget limits, its underlying paradigm - Data-Oriented Design (DOD) - is equally critical in other performance-sensitive domains:

  • Robotics & Autonomous Systems
    Modern robots process dozens of heterogeneous sensors (LiDAR points, IMU telemetry, camera frames, motor feedback) at high frequencies. Modeling a robot platform via OOP inheritance leads to synchronization lock-ups. ECS allows sensor data to stream into continuous arrays where perception, planning, and motor-control systems run as parallel data pipelines.

  • High-Frequency Financial Systems
    Order-matching engines and market-data aggregators process millions of financial instruments per second. Using ECS, order entities contain dynamic state tags (Active, MarginCall, PendingCancel). Systems iterate through contiguous pools of bid/ask values without pointer indirection, minimizing instruction cache misses and latency spikes.

  • CAD & Mechanical Simulations
    Engineering applications must simulate millions of structural nodes subject to heat, tension, and fluid dynamics. By modeling nodes as entities with components like ThermalState, Vector3DForce, or MaterialStress, finite-element solvers sweep through contiguous arrays using SIMD vector instructions for optimal hardware usage.

Key Takeaways

  1. Composition over Inheritance: Eliminate monolithic base classes. Add or remove behaviors at runtime simply by attaching or detaching components.
  2. CPU Cache Optimization: Storing components in flat arrays allows hardware prefetchers to load memory lines efficiently, eliminating O(N) pointer chasing.
  3. Stateless Logic: Systems remain clean and decoupled - they don't care what an entity is, only that it has the components required for processing.

Top comments (0)