index

Game Engine Series I - The Entity Component System

· 8min

Welcome to the Game Engine Series.

In this series of blog posts, I’ll be diving into one of my favorite topics:
Game Engine Development

The idea came up when I was in the middle on developing my own game engine. While this blog serves as a purpose to teach and explain what I’ve learned to programmers, this blog is also a way for my to track my progress and refine my own understanding along the way.

Whether you’re just curious about how game engines work under the hood, or you’re building one yourself. I hope this series gives you useful insights, ideas and maybe even some inspiration for your future projects, or even your own game engine.

Since I want to teach you the principles of an ECS and make it beginner-friendly, I purposely stripped the code down and removed things like multithreading and more. These more advanced topics will be discussed in the future.

Core Concepts Explained

ECS stands for Entity Component System. It is a data-oriented design pattern that separates data (components) from behavior (systems), using entities as simple IDs to associate them.

  • Entity Consists of nothing but an ID. Think of it like a dumb label:
    Player = 1, Tree = 2, Boss1 = 523
  • Component - Components are attached to entities and represent what that entity is made of.
    Position { float x, y }, Health { int hp }, Level { uint8_t level }
  • System - This can be a physics system, rendering system, or whatever your game needs. These systems iterate over your stored entites and apply the systems to them.

Why use ECS over OOP?

Don’t get me wrong. ECS takes advantage of OOP as well, but the difference lies in the use of OOP. Traditionally, beginners learn one thing - inheritance (including polymorphism). I would say that it’s quite primitive to have one base class called Entity, and another class called Player, which is derived from the base class Entity, so it would look something like this:

class Entity {
public:
  Entity();
  virtual ~Entity();
  
  virtual void update() = 0;
  virtual void render() = 0;
}

class Player : public Entity {
private:
  // Some vars, objects or whatever

public:
  Player() = default;
  virtual ~Player() = default;
  
  void update() override { /* Update logic of player */ }
  void render() override { /* Render player */ }
}

Would you notice what’s wrong there? Imagine this shape:

  • Class B and class C both inherit from A.
  • Class D inherits from both B and C.

So now, if D tries to access a member from A, which copy does it get?

Here, let’s look at another example:

class Enemy {
public:
    void update() { /* generic update stuff */ }
};

class FlyingEnemy : public Enemy {
public:
    void update() { /* flying logic */ }
};

class ShootingEnemy : public Enemy {
public:
    void update() { /* shooting logic */ }
};

class FlyingShootingEnemy : public FlyingEnemy, public ShootingEnemy {
public:
    void update() {
        FlyingEnemy::update();
        ShootingEnemy::update();
        // shit's messy
    }
};
  • FlyingShootingEnemy now has two instances of Enemy in its inheritance tree.
  • If Enemy has members or state, they’re being duplicated, which leads to ambiguity.

The compiler gets confused, and so will you. Your project will end up in a virtual hell. But these aren’t the only problems you’ll face.

As your project scales, this architecture will slam you into several brick walls:

  • Virtual dispatches are relatively slow
    • Every virtual call to update() or render() goes through a so-called vtable. A vtable or virtual table in C++ is a lookup table of function pointers maintained by the compiler for each class that has virtual functions, meaning heavy use of virtual functions will end up with a bunch of overhead, that can be avoided by ditching that programming pattern.
    • The diamond inheritance problem mentioned above.
    • Poor caching
      • OOP often scatters data all over memory because each object carries it’s own data + vptr.
    • Hard to Extend Beheavior Dynamically
      • Would you like to add or remove abilities (e.g.: canShoot(), canWalk()) at runtime?
    • And much more…

See? I could go on about the issues with this approach, but these are the biggest brick walls you’ll hit.

The Solution

It means moving away from traditional Object-Oriented Programming (OOP) patterns like inheritance and polymorphism, and instead starting to think in Data-Oriented design / programming (DOD).

Instead of organizing your game logic around what objects are (e.g.: class Player : public Entity), data-oriented design focuses on what data you operate on and how to structure that data to make the CPU cache happy. It’s less about entities and more about the systems and data layout.

Here is roughly what I came up with:

  1. First I created a Component.hpp file, exclusively for the components:
#include <cstdint>
#include "raylib.h"

namespace DREAM {
    struct Health { int value = 100; };

    struct Attack { int value = 1; };

    struct Defense { int value = 1; };

    struct Level { uint8_t value = 1; };

    struct Position { Vector2 value = {0.0f, 0.0f}; };

    struct Velocity { Vector2 value = {0.0f, 0.0f}; };
}

Then I implemented following in the ComponentManager:

    using Entity = std::uint32_t;

    class InterfaceComponentArray {
    public:
        virtual ~InterfaceComponentArray() = default;
        virtual void entityDestroyed(Entity entity) = 0;
    };

You might get a rough understanding on what I am trying to achieve here. Do you see the assignment of Entity just being a simple ID/number?

The InterfaceComponentArray only knows about the method entityDestroyed(). It will make much more sense in a bit.

After this we define the ComponentArray class:

    template<typename T>
    class ComponentArray : public InterfaceComponentArray {
    private:
        std::unordered_map<Entity, T> m_componentMap;

    public:
        ComponentArray() = default;
        ~ComponentArray() override = default;

        void insertData(Entity entity, const T& component) {
          // Insert new data / component for entity
        }

        void removeData(Entity entity) {
          // Remove data
        }

        T& getData(Entity entity) {
          // Return data
        }

        bool hasData(Entity entity) const {
            // Check if entity has any data
        }

        void entityDestroyed(Entity entity) override {
            // Destroy entity's data
        }
    };

This provides a dedicated storage and dedicated API for each ComponentArray we create (e.g.: ComponentArray<Position>).

Theoretically, the ComponentManager would work like this, but the user might have a hard time using the API. That’s why we will create a ComponentManager class and wrap the ComponentArray methods in it:

class ComponentManager {
    private:
        std::unordered_map<std::type_index, std::shared_ptr<InterfaceComponentArray>> m_componentArrays;

        template<typename T>
        std::shared_ptr<ComponentArray<T>> getComponentArray() {
            const auto typeId = std::type_index(typeid(T));
            auto it = m_componentArrays.find(typeId);
            if (it == m_componentArrays.end()) {
                fmt::print(
                    fmt::emphasis::bold | fmt::fg(fmt::color::red),
                    "[Error] ComponentManager::getComponentArray -> Component type {} not registered.\n",
                    typeId.name()
                );
                throw std::runtime_error("Component type not registered");
            }
            return std::static_pointer_cast<ComponentArray<T>>(it->second);
        }

    public:
        ComponentManager() = default;
        ~ComponentManager() = default;

        template<typename T>
        void registerComponent() {
            const auto typeId = std::type_index(typeid(T));
            if (m_componentArrays.contains(typeId)) {
                fmt::print(
                    fmt::emphasis::bold | fmt::fg(fmt::color::yellow),
                    "[Warning] ComponentManager::registerComponent -> Component type {} already registered.\n",
                    typeId.name()
                );
                return;
            }
            m_componentArrays.emplace(typeId, std::make_shared<ComponentArray<T>>());
        }

        template<typename T>
        void addComponent(Entity entity, const T& component) {
            getComponentArray<T>()->insertData(entity, component);
        }

        template<typename T>
        void removeComponent(Entity entity) {
            getComponentArray<T>()->removeData(entity);
        }

        template<typename T>
        T& getComponent(Entity entity) {
            return getComponentArray<T>()->getData(entity);
        }

        template<typename T>
        bool hasComponent(Entity entity) {
            return getComponentArray<T>()->hasData(entity);
        }

        void destroyEntity(Entity entity) {
            for (auto& [_, componentArray] : m_componentArrays) {
                componentArray->entityDestroyed(entity);
            }
        }
    };

In this step we simply wrapped the ComponentArray methods around methods from the ComponentManager. After calling registerComponent(), a ComponentArray for the component of your need gets created. This map then gets stored in another map, which is located in the ComponentManager class.

Using this API could look something like this:

int main() {
  ComponentManager componentManager;
  
  // Register needed component types:
  componentManager.registerComponent<Position>(); 
  componentManager.registerComponent<Velocity>();
  componentManager.registerComponent<Health>(); 
  
  // Create some entities (For now just IDs, unless you have an EntityManager)
  Entity player = 1;
  Entity enemy = 2;
  
  // Attach components to your entities
  componentManager.addComponent(player, Position{ 100.0f, 200.0f });
  componentManager.addComponent(player, Velocity{ 1.5f, 0.0f });
  componentManager.addComponent(player, Health{ 50 });
  
   componentManager.addComponent(enemy, Position{ 150.0f, 100.0f });
  componentManager.addComponent(enemy, Velocity{ 1.5f, 0.0f });
  componentManager.addComponent(enemy, Health{ 20 });
  
  // Simple movement "system"
  // Imagine you have a list of all entities with both Position & Velocity
  for (Entity e : { player, enemy }) {
    if (componentManager.hasComponent<Position>(e) &&
        componentManager.hasComponent<Velocity>(e)) {
          auto& pos = componentManager.getComponent<Position>(e);
          auto& vel = componentManager.getComponent<Velocity>(e);
          pos.position.x += vel.velocity.x;
          pos.position.y += vel.velocity.y;
        }
  }
  
  componentManager.removeComponent<Velocity>(enemy);
  componentManager.destroyEntity(enemy);
}

That’s quite a lot to read and grasp, right? Don’t worry. ECS design can be tricky at first, but once you grasp the full concept, it will stick in your mind and I will guarantee you that it’s awesome.

All this code made it possible to work with entities in a flexible & drastically improved way. Also keep in mind that this code only should be used for reference and not blatant copying, because I left some parts out on purpose to avoid blasting you a couple of hundred lines of code in your face.

Common Pitfalls

You’ve probably encountered this already, but it’s worth repeating. Plenty of developers lose their way in the jungle by over-engineering their code and end up abusing ECS like they do with inheritance or polymorphism.

Also - don’t overuse components. Too many tiny ones can bloat your systems with excessive iterations and seriously hurt performance.

Conclusion

There we are at the end of today’s post. You probably need to digest the amount of information you’ve just obtained, but that’s totally fine. As mentioned previously, even though I showed you the basics of an ECS, learning a topic like this is messy at first, especially since it requires ‘rethinking’ how code can be structured so generically.

Hopefully, I will see you next time on future posts :)