Skip to content

Entity-component system (ECS)

Entity-component systems are typically used for games where you achieve certain behaviors/qualities for entities via composition of components.

  • Entity: typically just a bundle of components and maybe an ID.
  • Component: typically just data and incredibly simple functions to act on that data. E.g. in a game, a LifeComponent might only store life and maxLife, and the simple functions to act on that data may be things like getPercentLife or restoreToFull.
  • System: logic tying everything together. It usually concerns itself with specific entities, e.g. a FireSystem may only be interested with entities that have an OnFireComponent.

Components do not know which entity they belong to. In other words, a LifeComponent doesn’t have an Entity reference within it. It’s up to the system to know how to act on a particular entity.

  • It’s easy to understand what an entity can do simply by its components. E.g. a monster in your game may have HealthComponent, DamageComponent, and AiComponent. The player may have the same but with InputComponent swapped for AiComponent.
  • You can increase cache coherence if you store component data in contiguous memory.
  • Systems can be made to act in parallel even if they’re acting on the same entities.
  • Data and logic are not co-located, which means that when you add something like a TargetLocationComponent, you also need to add a TargetSeekingSystem. It’s tempting to just cram this logic directly into an entity, which would then make it harder to reuse.
  • You usually need “tag” components, which are components with no data, e.g. IsPlayerComponent. This is because entities otherwise have no concept of what they represent. This isn’t exactly a con, but it can be strange to have to manifest a class out of what would otherwise just be a bool.

Godot decided not to use an ECS-first approach. You can still compose behaviors by adding nodes that act on their parents, meaning you don’t need an ECS if all you want is different behaviors on a particular node. E.g. there could be a MoveRandomlyComponent which does something like GetParent().Position += randomVector. These components could even register themselves with some central system so that you can fetch all entities with a certain type of component.

For Skeleseller, I decided to make an ECS library rather than always use Godot nodes. I don’t fully remember the reasoning behind this, and if I were to redo the project, I may have tried prototyping using Godot nodes.

Component is an abstract base class that looks like this:

public abstract class Component
{
protected Component()
{
Name = GetType().Name;
}
/// <summary>
/// Name of this component, which is used as an identifier in the tree. Automatically inferred from derived type.
/// </summary>
[JsonIgnore]
public string Name { get; set; }
/// <summary>
/// Allows a component to stay in-sync with the scene tree. For example, perhaps there's a component that represents
/// being on fire. When it's added to an entity, we would want to add a particle system to the entity.
/// </summary>
public virtual void AddedToEntity(Node entity)
{
// Method intentionally left empty.
}
public virtual void RemovedFromEntity(Node entity)
{
// Method intentionally left empty.
}
}

Any Godot node should be able to have components. There are a few ways to accomplish this:

  • Add a ComponentHolder which inherits from Node to each entity’s scene tree.
  • Attach an Entity script to all of your custom nodes, then have that script store your components.

I went with the ComponentHolder and use static functions in Ecs.cs to interact with entities and add/remove/get components:

public static partial class Ecs
{
/// <summary>
/// Invoked whenever a component is added to an entity. Systems can use this detect entities that they care about
/// without having to check every single frame if such an entity exists (e.g. a FooSystem may not care about an
/// entity until a FooComponent is added).
/// </summary>
public static event Action<Node, Component>? OnComponentAdded;
public static event Action<Node, Component>? OnComponentRemoved;
/// <exception cref="ArgumentNullException"/>
public static T AddComponent<T>(Node entity, T component)
where T : Component
{
ArgumentNullException.ThrowIfNull(entity);
ArgumentNullException.ThrowIfNull(component);
ComponentHolder container = EnsureComponentHolderExists(entity);
T? existingComponent = container.GetComponent<T>();
if (existingComponent is not null)
{
throw new InvalidOperationException($"Entity already has a component of type {typeof(T).Name}");
}
return (T)AddComponentToEntity(entity, component);
}
/// <exception cref="ArgumentNullException"/>
public static T GetComponent<T>(Node entity)
where T : Component
{
ArgumentNullException.ThrowIfNull(entity);
ComponentHolder container = GetComponentHolder(entity);
ArgumentNullException.ThrowIfNull(container);
T? component = container.GetComponent<T>();
ArgumentNullException.ThrowIfNull(component);
return component;
}
private static ComponentHolder GetComponentHolder(Node entity)
{
return entity.GetNodeOrNull<ComponentHolder>(ComponentNodeName);
}
}

This system does provide any cache-coherence benefits since we have to look up the ComponentHolder every time.