Refactoring a Game Engine to ECS: Lessons Learned

Building a game engine with the “obvious” approach seemed simple at first: GameObjects with arrays of components you can attach to them. Clean, intuitive, object-oriented.

It also didn’t scale.

This post walks through why that approach broke down, how I refactored the engine to an Entity Component System (ECS).

I’m not an ECS expert or an engine veteran - this is just a write-up of what I learned while refactoring my own engine. If you spot mistakes or have better ideas, feel free to point them out.

The Problem with the Classic GameObject Model

The original architecture looked roughly like this:

  • A GameObject
  • An array of components attached to it
  • Each component owns its own update logic

At first, this feels flexible. Want physics? Add a physics component. Want rendering? Add a mesh component.

Things started to fall apart once update order really began to matter.

Update Order Hell

Some systems “must” run before others:

  1. Input
  2. Movement
  3. Physics
  4. Rendering

With GameObjects owning components, the scene update loop slowly evolved into something like:

for each componentType in {Input, Physics, Render, ...}:
    for each GameObject:
        for each component in GameObject:
            if component.type == componentType:
                component.update()

This quickly turned into a mess. Cache usage was bad, with the CPU constantly jumping around memory while looping over components. Update order became harder and harder to reason about: adding a new component usually meant guessing where it should run and hoping nothing broke. Over time, components started depending on update order in subtle ways that weren’t obvious at all. At that point, the architecture felt like it was working against me.

Enter ECS (Entity Component System)

ECS flips the way you think about game objects. Instead of behavior living inside objects, behavior lives in systems, and data is organized for performance. An ECS is usually built around three ideas.

Entities Are Just IDs

An entity doesn’t do anything. It doesn’t own data or behavior.

It’s just an identifier.

using EntityID = uint32_t;

Components Are Pure Data

Components store data only. No virtual functions. No inheritance.

struct TransformComponent {
    vec3 position;
    quat rotation;
};

This separation is important: behavior lives in systems, not in components.

That said, having small helper methods can still make sense. For example, a transform component might expose simple direction helpers without owning any game logic:

glm::vec3 forward() const {
    return rot * glm::vec3(0, 0, -1);
}

glm::vec3 up() const {
    return rot * glm::vec3(0, 1, 0);
}

glm::vec3 right() const {
    return rot * glm::vec3(1, 0, 0);
}

Data Locality

Instead of storing components inside entities, ECS stores them by type. Each component type has its own array:

MeshComponents[]     = { nullptr, nullptr, mesh1, nullptr, mesh2 }
LightComponents[]    = { nullptr, light1, nullptr, nullptr, nullptr }
PhysicsComponents[]  = { nullptr, phys1, phys2, nullptr, nullptr }

If entity 1 has a light and physics, and entity 2 has mesh and physics.

When the physics system runs, it iterates only physics components, which are stored contiguously in memory. The CPU can just walk through memory instead of jumping all over the place.

The result is fewer cache misses, more predictable iteration, and much better performance overall.

Component Manager

The component manager acts as a central access point for all components, regardless of their type.

Internally, it stores one component storage per component type, indexed by std::type_index. This allows adding new component types without modifying the manager itself.

namespace ComponentManager {

	// Stores all component storages, one per component type
	inline std::unordered_map<std::type_index, std::unique_ptr<IComponentStorage>> componentStorages;

	template<typename T, typename... Args>
	T* addComponent(Entity t, Args&&... args) {
		auto* storage = getStorage<T>();
		return storage->add(t, std::forward<Args>(args)...);
	}

	template<typename T>
	void removeComponent(Entity t) {
		auto* storage = getStorage<T>();
		storage->remove(t);
	}

	template<typename T>
	T* getComponent(Entity t) {
		auto* storage = getStorage<T>();
		return storage->get(t);
	}
}

Component Storage

Each component type gets its own storage class. This is where the actual data lives.

  • A dense array of components
  • A parallel array of entities
  • A sparse array that maps entity IDs to indices in the dense array

This gives fast iteration and O(1) add, remove, and lookup.

template<typename T>
class ComponentStorage : public IComponentStorage {
public:
	template<typename... Args>
	T* add(Entity t, Args&&... args) {
		if (has(t))
			return &components[sparse[t]];

		components.emplace_back(std::forward<Args>(args)...);
		entities.push_back(t);

		if (t >= sparse.size())
			sparse.resize(t.getId() + 1, INVALID_INDEX);

		sparse[t] = components.size() - 1;
		return &components.back();
	}

	void remove(Entity t);
	T* get(Entity t);

private:
	std::vector<T> components;     // Dense component data
	std::vector<Entity> entities;  // Matching entity IDs
	std::vector<size_t> sparse;    // Entity ID -> index mapping
};

All component storages inherit from a common IComponentStorage interface, which allows them to be stored together in the component manager despite having different types.

Systems: Where the Logic Lives

If entities are just IDs and components are just data, systems are where behavior goes.

Each system operates on entities that have a specific set of components.


void update(Scene* scene) {
    // Iterate over all entities with a PhysicsBodyComponent
    auto bodies = scene->getComponentView<PhysicsBodyComponent>();

    for (auto [entity, phys] : bodies) {
        if (phys->state != PhysicsBodyComponent::State::Alive)
            continue;

        TransformComponent* transform = scene->getComponent<TransformComponent>(entity);
        if (!transform || !phys->controlledByPhysics)
            continue;

        switch (phys->role) {
        case PhysicsBodyComponent::PhysicsRole::CharacterController:
            // Sync PhysX controller position back to TransformComponent
            ...
            break;

        case PhysicsBodyComponent::PhysicsRole::Dynamic:
        case PhysicsBodyComponent::PhysicsRole::Kinematic:
        case PhysicsBodyComponent::PhysicsRole::Static:
            // Other physics body types
            ...
            break;
        }
    }
}

Explicit Update Order

Systems run in a clearly defined sequence:

  1. Input systems
  2. Movement systems
  3. Physics systems
  4. Transform synchronization
  5. Rendering systems

This removes the ambiguity and fragility of the old component-driven update loop.

Remaining Challenges

Parent–Child Relationships

Hierarchy doesn’t fit naturally into ECS.

For example:

  • A player entity at (10, 0, 5)
  • A sword that should stay offset by (1, 0, 0)

Common approaches include storing a parent entity ID in a component, adding a dedicated hierarchy system, or accepting some limited hierarchy outside of pure ECS. A parent component combined with a transform propagation system seems like the most flexible option.

For now, I decided to add a local transform to components that are physically attached to something else. This makes it possible to introduce child entities later in a more natural way - for example, a car entity with children like wheels or an engine. I’m not fully sure this approach will scale well in the long run, but it works nicely for now and keeps things simple. This is one of those areas where ECS forces trade-offs, and I’m still experimenting to see what feels right.

Final Thoughts

Refactoring to ECS was an interesting challenge. I realized that I usually jump straight into coding without really stopping to think about the structure first - and that approach clearly has limits.

The system isn’t perfect yet, but the foundation feels solid.

This refactor also changed how I approach code in general. I now spend much more time thinking about design and structure before writing anything. Typing code is easy - especially with AI tools - but getting the architecture right still takes real thought.