D20 RPG – Entity (ECS)

Have you been wondering how we are going to persist our object graph? In this lesson we take the next step in laying that foundation by creating a unique identifier for everything.

Overview

Several of my projects have used a pattern called Entity Component System (ECS) and are usually based on Adam Martin’s design. This project is also inspired by that pattern, though with a simpler implementation. Maybe you could think of this version as ECS “lite”. Some of the flexibility and power of the original pattern is exchanged in favor of ease of implementation.

Entity

An Entity is basically just a unique identifier that represents any “thing” in the game, from a hero, to monster, to equipment or spell or even some sort of status ailment or effect trigger. In this implementation, it is a struct with a single id field.

Component

Components are the bits of data that are associated with an Entity such as health points, a position on a map, or ability scores. In this project they will be associated with an Entity merely by using a Dictionary. So for example, I could have a “health” dictionary where the Key is an Entity and the Value is the amount of health that the Entity has. If the Dictionary doesn’t have an entry for a particular Entity then it just means that the given Entity does not have that type of Component.

System

Systems are the holders of any logic on what to do with the combinations of Components on an Entity. They will make sure that any game rules are enforced, and may work with other systems as needed.

Getting Started

Feel free to continue from where we left off, or download and use this project here. It has everything we did in the previous lesson ready to go.

Create a new folder in Assets/Scripts named Entity. Next create a new folder in Assets/Tests named Entity. All of the scripts we create in this lesson will be placed in one of those two folders or pre-existing folders.

Entity Model

An Entity is really just an Id, so technically I could implement it as an integer. However, I decided to create it as a custom struct because then I can make extensions (or in this case partial implementations) that are targeted more specifically than it would be if I had only used an integer.

Create a new C# script in Assets/Scripts/Entity named Entity. Copy the following:

[System.Serializable]
public partial struct Entity
{

}

Here we define a new struct named Entity. Keep in mind that structs are value types so they are passed by value, rather than by reference. The primary benefit of a struct is that it does not generate garbage that would later need to be collected.

We have added the partial keyword which means we can save and load an entity, and that I can even add functionality in separate files – such as to wrap the functionality of a system.

Note that we also tagged the struct as Serializable which is necessary so that it can be saved and loaded as part of our game data.

Continue to add the following snippets to our Entity struct:

public readonly int id;

public Entity(int id)
{
    this.id = id;
}

The id is the only field in the struct and is really all an Entity is. I made a constructor that accepts an id parameter for convenience.

public static readonly Entity None = new Entity(0);

A struct is a value-type, which means that you can’t have a null reference to an Entity. Sometimes those can be handy though, so in this case I decided to treat any Entity with id of 0 (the default value) as if it is a null reference. I created a static readonly None as a sort of shorthand.

public static bool operator ==(Entity lhs, Entity rhs) => lhs.id == rhs.id;

It will likely be a common thing to want to check and see if two entities are the same “thing” or not. Since they aren’t reference types, I can’t just check their address. Instead I need to verify that their id is the same value. For convenience, I went ahead and did an operator overload of the == equality operator.

public static bool operator !=(Entity lhs, Entity rhs) => !(lhs == rhs);

I might have been satisfied to stop there, but the compiler complained at me: Compiler Error CS0216: A user-defined == operator requires a user-defined != operator, and vice versa. So, I implemented the != operator as well.

So far so good, but if you try building now you will see a couple of warnings we need to fix:

  • warning CS0660: ‘Entity’ defines operator == or operator != but does not override Object.Equals(object o)
  • warning CS0661: ‘Entity’ defines operator == or operator != but does not override Object.GetHashCode()
public override bool Equals(object obj) => this.Equals((Entity)obj);

public override int GetHashCode() => id.GetHashCode();

With the above we have satisfied the requirements to make the warnings go away.

public bool Equals(Entity p) => this == p;

Finally, I added one additional Equals method where we can pass an Entity directly, instead of just object. But we’re done, it all compiles without warnings, so I am happy.

Entity System

Create a new C# script in Assets/Scripts/Entity named EntitySystem. Copy the following:

public partial class Data
{
    public CoreSet<Entity> entities = new CoreSet<Entity>();
}

We begin by creating a local partial definition of Data which will hold a set of all the active entities in the game. Remember that a Set is a special collection type that has no order, but allows you to know immediately whether or not it contains a given element in the set. It is actually kind of like the keys of a Dictionary, just without having a value to map to.

Next, add the interface for our system:

public interface IEntitySystem : IDependency<IEntitySystem>
{
    Entity Create();
    void Destroy(Entity entity);
}

This simple system merely needs to know how to both Create and Destroy an Entity. The result of creating an Entity will be that our Data’s Set of Entities will have a new member. The result of destroying an Entity will be that our Data’s Set of Entities will remove a member.

Finally, add the concrete implementation:

public class EntitySystem : IEntitySystem
{
    Data Data { get { return IDataSystem.Resolve().Data; } }
    IRandomNumberGenerator RNG { get { return IRandomNumberGenerator.Resolve(); } }

    public Entity Create()
    {
        Entity result;
        do
        {
            result = new Entity(RNG.Range(int.MinValue, int.MaxValue));
        }
        while (result.id == 0 || Data.entities.Contains(result));
        Data.entities.Add(result);
        return result;
    }

    public void Destroy(Entity entity)
    {
        Data.entities.Remove(entity);
    }
}

At the top of the class I added a couple of private properties. One provides convenient access to the Data instance from our data system. The other provides convenient access to the system which generates random numbers. Note that both properties are wrapping the resolved system reference, so they will use whatever system has been injected.

The Create method begins by creating a result which it will configure within a do-while loop. This type of loop always runs at least once, but may continue running for as long as the condition returns true. The body of the loop assigns to result a new Entity with an id that was randomly generated. The loop condition verifies that the randomly generated id did not happen to be either ‘0’, or an id that is already taken. Remember that I don’t consider ‘0’ a valid id because it represents a none-entity (sort of like a null reference).

The Destroy method is far simpler, as it will simply remove a given entity from the set.

Unit Tests

As has been our pattern, we will create some unit tests for our new scripts. Before that though, we will want a couple of new mocks as well.

Mock Sequence Random Number Generator

When we test creating entities in our entity system, we will need to be able to mock the generation of a sequence of random numbers. This way we can test that upon encountering an invalid id, that the system will continue to try until it gets a valid id.

Create a new C# script in Assets/Tests/DiceRoll named MockSequenceRNG. Copy the following:

using System.Collections.Generic;

public class MockSequenceRNG : IRandomNumberGenerator
{
    public Queue<int> values;
    public int fallback;

    public MockSequenceRNG(params int[] values)
    {
        this.values = new Queue<int>(values);
        this.fallback = 0;
    }

    public int Range(int minInclusive, int maxExclusive)
    {
        if (values.Count > 0)
            return values.Dequeue();
        return fallback;
    }
}

This variation of our mock lets us pass an array of values that represent “fake” generated random numbers to return. The constructor accepts an array of such fake values. The values we provide are stored in another collection type called a Queue which provides values in a first-in first-out pattern. In other words, if I create a mock with the values [5, 2, 3], then the first time we call Range, it will return 5. The next call will return 2, and a third call would return 3.

In the event that a test only needs to specify a few of the generated numbers, and does not care about the rest, I also added a fallback that is returned whenever the queue of values has been emptied.

Mock Data System

We already have mocks for our Data Serializer and Data Store, so in theory we could use the real Data System in our unit tests without the worry of corrupting our save data or doing disk reads and writes. Most of the time though, all I really need is an in-memory Data to have been created. So rather than needing to inject multiple things to get the Data system up and running, let’s just create a single mock for the Data system itself.

Create a new C# script in Assets/Tests/Data named MockDataSystem. Copy the following:

public class MockDataSystem : IDataSystem
{
    public Data Data { get; private set; }

    public void Create()
    {
        Data = new Data();
    }

    public void Delete()
    {
        Data = null;
    }

    public bool HasFile()
    {
        return false;
    }

    public void Load()
    {
        
    }

    public void Save()
    {
        
    }
}

At the moment, all we care about is that I can Create a new instance of our game Data. For good measure I also implemented Delete, though I doubt I will need it. Later, there may be additional uses for the other methods, such as marking flags that the various methods are called when expected, but I can add that when and if it is needed.

Entity System Tests

Create a new C# test script inside the Assets/Tests/Entity folder and name the script EntitySystemTests. Copy the following code into the script:

using NUnit.Framework;
using UnityEngine;

public class EntitySystemTests
{
    MockDataSystem mockDataSystem = new MockDataSystem();
    EntitySystem sut = new EntitySystem();
}

Each of our tests will need both a mock data system and the entity system itself, so I created those as fields in the class. Continue adding the next snippets to our class.

[SetUp]
public void SetUp()
{
    IDataSystem.Register(mockDataSystem);
    mockDataSystem.Create();
}

Our Setup method is used to Register our mock data system and then to Create the Data object in our Data system.

[TearDown]
public void TearDown()
{
    IRandomNumberGenerator.Reset();
    IDataSystem.Reset();
}

Our TearDown method will clear registrations for the IRandomNumberGenerator and IDataSystem. Normally I prefer to keep things paired – clear things that are assigned in Setup for example, but in this case, I want the various tests to have different rolled values for the random number generator, so I wait to Register it per test.

[Test]
public void Create_Succeeds()
{
    IRandomNumberGenerator.Register(new MockFixedRNG(1));

    var entity = sut.Create();

    Assert.AreEqual(1, entity.id);
    Assert.True(mockDataSystem.Data.entities.Contains(entity));
}

For our first test we want to make sure that an Entity is created successfully. The conditions that determine success in this case are that the entity id was provided by the random number generator and that the resulting Entity was also added to our game Data’s Set of entities.

[Test]
public void Create_ZeroId_RollsAgain()
{
    IRandomNumberGenerator.Register(new MockSequenceRNG(0, 1));

    var entity = sut.Create();

    Assert.AreEqual(1, entity.id);
}

Next I wanted to add explicit Tests around the potential failure conditions. This test validates that our system will not return an Entity with an id of 0 which is reserved to represent a null or unassigned “reference”. When arranging the test, we Register a MockSequenceRNG that generates a 0 first (an invalid id) followed by a 1 (a valid id).

[Test]
public void Create_DuplicateId_RollsAgain()
{
    IRandomNumberGenerator.Register(new MockSequenceRNG(1, 2));
    mockDataSystem.Data.entities.Add(new Entity(1));

    var entity = sut.Create();

    Assert.AreEqual(2, entity.id);
}

The other potential failure condition is that every time we call Create we want a new unique id for our Entity. This Test validates that our system will not return an Entity with an id that matches an existing Entity in our game Data’s Set of entities. When arranging the test, I insert an Entity with an id of 1 into the game Data. Then we register our mock sequence random number generator and configure it so that it will generate a 1 first (only invalid because it is already taken) and then a 2 (a valid un-used id).

[Test]
public void Destroy_Succeeds()
{
    var entity = new Entity(1);
    mockDataSystem.Data.entities.Add(entity);

    sut.Destroy(entity);

    Assert.IsFalse(mockDataSystem.Data.entities.Contains(entity));
}

Our final test validates that our system can destroy an Entity successfully. This means that our game Data’s Set of entities will no longer contain the specified Entity.

That’s it – feel free to run the tests and see all the new green check marks!

Summary

In this lesson we learned about a pattern called the Entity Component System. We implemented the first part of the pattern, the Entity. We provided a model for the Entity and a System that can create and destroy Entities. Entities are tracked by the game data and can be saved and loaded. Finally we created some new mocks and unit tests to validate everything is working as expected.

If you got stuck along the way, feel free to download the finished project for this lesson here.

If you find value in my blog, you can support its continued development by becoming my patron. Visit my Patreon page here. Thanks!

Leave a Reply

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