Entity-component system (ECS)
Overview
Section titled OverviewEntity-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 storelife
andmaxLife
, and the simple functions to act on that data may be things likegetPercentLife
orrestoreToFull
. - System: logic tying everything together. It usually concerns itself with specific entities, e.g. a
FireSystem
may only be interested with entities that have anOnFireComponent
.
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.
Benefits
Section titled Benefits- 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
, andAiComponent
. The player may have the same but withInputComponent
swapped forAiComponent
. - 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 aTargetSeekingSystem
. 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 abool
.
Usage with Godot
Section titled Usage with GodotGodot 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 fromNode
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.