How and why we wrote our ECS

In the last article, I described the technologies and approaches that we use when developing a new mobile fast paced shooter. Since it was a review and even a superficial article - today I’ll dig deeper and explain in detail why we decided to write our own ECS framework and did not use the existing ones. There will be code samples and a small bonus at the end.


What is ECS for example


Briefly, I already described what an Entity Component System is, and on Habré there are articles about ECS (mostly, however, translations of articles - see my review of the most interesting of them at the end of the article, as a bonus). And today I will tell you how we use ECS - using our code as an example.

The diagram above describes the essence of the Player , its components and their data, and the systems that work with the player and its components. The key object on the diagram is the player:


We describe this code. For a start we will get interfaces for components and systems. Components can have general auxiliary methods, the system has only one Execute method, which receives the state of the world at the input to processing:

public interface IComponent { // < > } public interface ISystem { void Execute(GameState gs); } 

For components, we create procurement classes that are used by our code generator to convert them into actually used component code. We will get blanks for Health , Damage and Invincible (for the other components it will be similar).

 [Component] public class Health { [Max(1000)] //  -  1000 public int Hp; // -   public Health(int hp) {} } [Component] public class Damage { [DontSend] //      ,      public uint Amount; // -  public Entity Victim; //    public Entity Source; //    public Damage(uint amount, Entity victim, Entity source) {} } [Component] public class Invincible //   ,  ,    { } 

Components determine the state of the world, therefore they contain only data, without methods. In this case, there is no data in Invincible , it is used in logic as a sign of invulnerability - if the player’s essence has this component, then the player is now invulnerable.

The Component attribute is used by the generator to find the procurement classes for the components. The attributes Max and DontSend are needed as hints when serializing and reducing the size of the state of the world transmitted over the network or saved to disk. In this case, the server will not serialize the Amount field and send it over the network (because clients do not use this parameter, it is needed only on the server). And the Hp field can be well packaged in several bits, given the maximum value of health.

We also have a blank Entity class, where we add information about all possible components of any entity, and the generator will already create a real class from it:

 public class Entity { public Health Health; public Damage Damage; public Invincible Invincible; // ... < > } 

After that, our generator will create a code for the Health , Damage, and Invincible classes of components that will already be used in the game logic:

 public sealed class Health : IComponent { public int Hp; public void Reset() { Hp = default(int); } // ... <  > } public sealed class Damage : IComponent { public int Amount; public Entity Victim; public Entity Source; public void Reset() { Amount = default(int); Victim = default(Entity); Source = default(Entity); } // ... <  > } public sealed class Invincible : IComponent { } 

As you can see, data remained in the classes and methods were added, for example, Reset . It is needed to optimize and reuse components in pools. Other methods are auxiliary, do not contain business logic - I will not give them for short code.

A class will also be generated for the state of the world, which contains a list of all components and entities:

 public sealed class GameState { //  public Table<Movement> Movements; public Table<Health> Healths; public Table<Damage> Damages; public Table<Transform> Transforms; public Table<Invincible> Invincibles; //   public Entity CreateEntity() { /* <> */ } public void Copy(GameState gs2) { /* <> */ } public Entity this[uint id] { /* <> */ } // ... <   > } 

And finally, the generated code for Entity :

 public sealed class Entity { public uint Id; //   public GameState GameState; //     //     : public Health Health { get { return GameState.Healths[Id]; } } public Damage Damage { get { return GameState.Damages[Id]; } } public Invincible Invincible { get { return GameState.Invincibles[Id]; } } // …     public Damage AddDamage() { return GameState.Damages.Insert(Id); } public Damage AddDamage(int total, Entity victim, Entity source) { var c = GameState.Damages.Insert(Id); c.Amount = total; c.Victim = victim; c.Source = source; return c; } public void DelDamage() { GameState.Damages.Delete(Id); } // … <     > } 

The Entity class is essentially just a component identifier. References to objects of the GameState world are used only in helper methods for the convenience of writing business logic code. Knowing the identifier of the component, we can use it to serialize the relationships between entities, implement links in components to other entities. For example, the Damage component contains a reference to the Victim entity to determine who caused the damage.

This is where the generated code ends. In general, we need a generator in order not to write auxiliary methods every time. We only describe the components as data, then all the work is done by the generator. Examples of helper methods:


Let us turn to the code systems. They define business logic. For example, we write the code of the system that charges the player:

 public sealed class DamageSystem : ISystem { void ISystem.Execute(GameState gs) { foreach (var damage in gs.Damages) { var invincible = damage.Victim.Invincible; if (invincible != null) continue; var health = damage.Victim.Health; if (health == null) continue; health.Hp -= damage.Amount; } } } 

The system goes through all Damage components in the world and looks at whether there is an Invincible component on a potentially damaged player ( Victim ). If he is - the player is invulnerable, no damage is calculated. Next, we obtain the victim's Health component and reduce the player’s health by the amount of damage.

Consider the key features of the systems:

  1. The system is usually a stateless class, it does not contain any internal data, it does not try to save it somewhere, except for world data transmitted from outside.
  2. Systems usually run through all components of a particular type and work with them. They are usually called by the type of component ( DamageDamageSystem ) or by the actions they perform ( RespawnSystem ).
  3. The system implements the minimum functionality. For example, if you go further, after the execution of the DamageSystem, another RemoveDamageSystem will remove all Damage components. On the next tick, another ApplyDamageSystem based on the player’s shooting can again hang the Damage component with new damage. And then the PlayerDeathSystem system will check the player’s health ( Health.Hp ) and, if it is less than or equal to 0, will destroy all the player’s components except Transform , and add the Dead flag component.

So, we get the following classes and relations between them:


Some facts about ECS


ECS has its pros and cons, as an approach to development and a way of representing the world of the game, so everyone decides for himself whether to use it or not. Let's start with the pros:




And now the cons:


Did you know that:


Our selection criteria for ECS framework


When we decided to make a game on ECS, we started looking for a ready-made solution and wrote out requirements for it based on the experience of one of the developers. And painted, as far as existing solutions meet our requirements. It was a year ago, at the moment something might have changed. As solutions we considered:


We compiled a table for comparison, where I also included our current solution (labeled it as ECS (now) ):


Red color - the decision does not support our requirement, orange - partially supports, green - supports completely.

For us, an analogy to the operations of accessing components, searching for entities in ECS were operations in the sql database. Therefore, we used concepts like table (table), join (join operation), indices (indices), etc.

We will write out our requirements and how relevant third-party libraries and frameworks are to them:


Non-binding requirements ( none of the solutions supported them at that time ):


As you can see from the table, on our own we wanted to implement all the requirements, except optional ones. In fact, at the moment we have not done:


At the same time, we still implemented one of the initially optional requirements:


Features of our development on ECS



Bonus


In the process of deciding whether Habra needs an article on ECS, I did a little research. , , , :

Source: https://habr.com/ru/post/413729/


All Articles