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 (2)

Collapse
 
matthew_faithfull profile image
Matthew Faithfull

I find myself conflicted. This is a well written article presenting a good example of a good way to do something useful.
However the idea that this is in someway not OOP or 'Beyond OOP' is simply not true.
Statements such as:

"Single inheritance forces you to choose between code duplication or pushing specialized methods up into a bloated 'God Class.'"

certainly don't help. Being the exact opposite of what in fact occurs. Many large, bad, class hierarchies end up with an empty base class precisely because they don't in fact have anything in common. Specialization is always pushed down (that's literally what it means) and code duplication is almost always less than any other approach as base class functions get heavily recycled within derived classes. (If they don't you're doing it wrong).

Composition over inheritance has always been good OOP and remains so.
Indexed homogenous containers have always been the fastest way to handle large number of Objects. The techniques shown, such as swapping Objects to the end to erase them, were available in class libraries in the 1990s. We should use them more.
OOP neither prevents nor addresses this at all as it's a, storage neutral, mental model for thinking about programming. One that the author is using whether they believe so or not.

Note for example that you can have N polymorphic derived types of the same base stored contiguously in memory with no loss of performance over the method shown. The constraint has nothing to do with OOP or even inheritance. The constraint is that they must be the same size. It's about the data not the v-table.

So on the one hand this article mildly annoys me because the valid criticism of a particular kind of naïve and slow implementation, dressed up as the fashionable criticism of OOP, is misleading. While on the other hand it's encouraging to see OOP's critics using it anyway, and doing it well.

My only concern is that a generation of programmers risk being mislead that OOP is bad, by people literally using OOP. Because if they truly reject OOP entire (and many will) we go back to the 1970s and lose a lifetime of hard won gains.

Collapse
 
utak3r profile image
Piotr Borys

Fair enough. Maybe it's true I've missed the point of view of new generations who could misread my thoughts on this subject. Thanks for pointing this out.