unity. Lazy ECS

ECS- Entity-Component-System – quite a convenient way to design the architecture of games. Transferring data to components placed in entities and managed by systems allows you to get the necessary, even the most complex, behavior of objects by “juggling” components inside them. It was on him that our eyes fell when developing a small turn-based strategy on Unity.

For those who have not touched on this topic, I will briefly explain how ECS works in general: you divide your entire project into three aspects. First – entities – just a set of “dummies” (containers of components) that do not have any logic. They are only for storage. Components – the second aspect. These are objects containing data, however, also not having any logic (except for the simplest one, for example, the “HealthComponent” component can be able to return the current health as a percentage, no more). And the components are controlled systems (Systems), which implement all the logic based on the data received from the components. For example, a “HealthSystem” can check the current health of all “HealthComponents”, and initiate a death animation for an entity that has a “HealthComponent” hanging on it, whose health has dropped to zero.

After a little investigation by our brave team, it turned out that in the first place, most of the proposed ECS can be used in Unity, but they are not tied to it, which implies that they need to be configured in some way for normal operation. But why bother with customization if you can not deal with customization? On that, we decided to write our own lazy ECS specifically for Unity.

Most likely, the resulting system will not give you the code optimization that is so touted with every mention of ECS that I have seen. The main goal was to build a more or less collapsible project architecture.

So, we implemented, first of all, clearly not pure ECS, but we tried to get as close to it as Unity and our personal vision of this pattern allows.

A small spoiler. The resulting architecture looks something like this:

Part of the game architecture
Part of the game architecture

Let’s move on to implementation! Pick up the keyboard and start writing. Let’s start with the fact that systems and components exist somewhere in the ephemeral space, and there should be easy and fast enough access to them, so I would like to maintain arrays of links to them somewhere, and this “somewhere” we will have ECSInstance is a static class with two fields: a list of components and a list of systems. Its code might look like this:

public class ECSInstance
{
    private static ECSInstance instance;
  
    public List<ECSComponent> Components;
    public List<ECSSystem> Systems;
  
    private ECSInstance()
    {
        Components = new List<ECSComponent>();
        Systems = new List<ECSSystem>();
    }
    public static ECSInstance Instance()
    {
        if(instance == null)
            instance = new ECSInstance();
        return instance;
    }
}

It can now be accessed from anywhere in the code, and a single instance of it will persist and exist throughout the game. The singleton was a perfect fit for the pattern for this class. However, as you can see, these lists are empty and are not populated anywhere within the class. We will deal with this later, when we get to the consideration of the components. And, for now, it’s not very convenient to get specific components or systems from here, so we wrote two classes that help to cope with this. Get to know them, the first one is the ECSFilter class. It is needed for easy access to components:

public class ECSFilter
{
    public List<ECSComponent> Components;
  
    public ECSFilter(ECSComponent[] components) { Components = new List<ECSComponent>(components); }); }
    public ECSFilter(List<ECSComponent> components) { Components = components; }
    public ECSFilter() { Components = ECSInstance.Instance().Components; }
    
    public ECSFilter OfType<T>()
    {
        List<ECSComponent> new_list = new List<ECSComponent>();
        foreach(var c in Components)
            if(c.GetType() == typeof(T))
                new_list.Add(c);
        return new ECSFilter(new_list);
    }
  
    public ECSFilter WithoutType<T>()
    {
        List<ECSComponent> new_list = new List<ECSComponent>();
        foreach (var c in Components)
            if (c.GetType() != typeof(T))
                new_list.Add(c);
        return new ECSFilter(new_list);
    }

    public List<T> GetComponents<T>() where T: ECSComponent
    {
        List<T> new_list = new List<T>();
        foreach (var c in Components)
            if (c.GetType() == typeof(T))
                new_list.Add((T)c);
        return new_list;
    }
  
    public List<T> GetComponents<T>(Func<T, bool> predicate) where T: ECSComponent
    {
        List<T> new_list = new List<T>();
        foreach (var c in Components)
            if (c.GetType() == typeof(T))
                if(predicate.Invoke((T)c))
                    new_list.Add((T)c);
        return new_list;
    }
}

Due to its own collection, the filter can be applied to a set of components that are not related to ECSInstance, and is engaged in selecting from the components those that satisfy the conditions. The OfType and WithoutType methods return the filter itself, so they can be called in a chain:

...
ECSFilter filter = new ECSFilter();
List<ECSComponent> = filter.OfType<Moveable>().WithoutType<Selectable>().Components;
...

The second is the ECSService class. Still, its main task is to initialize systems and call their main functions, however, I decided to also give this class the ability to return the system the user needs (there was another option to put the logic for obtaining a specific system in ECSInstance, described above). I made this decision in case in the future I want to give ECSService more logic related to the interaction of systems. The code:

public class ECSService : MonoBehaviour
{
    void InitSystems()
    {
        var Systems = ECSInstance.Instance().Systems;
        
        Systems.Add(new InputSystem(this));
        Systems.Add(new SelectionSystem(this));
        Systems.Add(new MoveSystem(this));
        Systems.Add(new AttackSystem(this));
        //Systems.Add(...
    }

    void Awake()
    {
        InitSystems();
    }

    void Start()
    {
        foreach (var s in ECSInstance.Instance().Systems)
            s.Init();
    }

    void Update()
    {
        foreach (var s in ECSInstance.Instance().Systems)
            s.Run();
    }

    public T GetSystem<T>() where T: IECSSystem
    {
        foreach(var s in ECSInstance.Instance().Systems)
            if (s.GetType() == typeof(T))
                return (T)s;
        return null;
    }
}

As you can see, it is in this class that the execution of the frame-by-frame logic of each system “originates”. I am sure that the foreach loop over all systems with the execution of a function is not a very optimal solution, however, do not forget that our ECS is lazy :). When creating systems, the service leaves a link to itself inside them so that the system has access to its “brothers”. As far as I remember, in OOP this technique is called “dependency injection”.

That’s all the ECS implementation is based on. Now, with a pure heart, we can move on to the implementation of components and systems directly.

All our components are inherited from ECSComponent, which implements the addition of itself to the list upon creation, as well as the correct addition of a new component:

public class ECSComponent : MonoBehaviour
{
    private void Awake()
    {
        ECSInstance.Instance().Components.Add(this);
    }

    public void AddComponent<T>(T component) where T: ECSComponent
    {
        gameObject.AddComponent<T>();
        ECSInstance.Instance().Components.Add(component);
    }

    public void RemoveComponent<T>(T component) where T: ECSComponent
    {
        ECSInstance.Instance().Components.Remove(component);
        Destroy(component);
    }
}

This, of course, adds a bit of an inconvenience, as important components such as transform, renderer, and other standard Unity components are not in the list and cannot be accessed from there. However, by referring directly to the gameObject of our components, we can do just fine.

Finally, ECSSystem is the superclass of systems:

public class IECSSystem
{
    public ECSService Service;
  
    public IECSSystem(ECSService service) { Service = service; }
  
    public virtual void Run() { }
    public virtual void Init() { }
}

Nothing supernatural, just a couple of methods – one of which will be called at the start (Unity signal Start Monobehaviour objects), and the other – every frame. The service mentioned above is also visible here.

Well, that’s all, you just need to launch your future warrior / tree / one and a half lemonade in the form of a pacifier onto the map, write your first component (for example, movements) and write something like …

public override void Run()
{
    ECSFilter f = new ECSFilter();
    List<Movable> components = f.GetComponents<Movable>();
    foreach (var c in components)
        UpdateComponent(c);
}

If you appreciate this material, in the future I would like to release a separate article in which we consider the interaction of ECS and UI within the Unity project. We are definitely looking forward to your feedback. Good luck in your endeavors!

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *