Skip to content

Examples ~ General

Ullrich Praetz edited this page Jun 28, 2024 · 16 revisions

Examples using Friflo.Engine.ECS are part of the unit tests see: Tests/ECS/Examples

When testing the examples use a debugger to check entity state changes while stepping throw the code.


Screenshot: Entity state - enables browsing the entire store hierarchy.

Examples showing typical use cases of the Entity API


EntityStore

An EntityStore is a container for entities running as an in-memory database.
It is highly optimized for efficient storage fast queries and event handling.
In other ECS implementations this type is typically called World.

The store enables to

  • create entities
  • modify entities - add / remove components, tags, scripts and child entities
  • query entities with a specific set of components or tags
  • subscribe events like adding / removing components, tags, scripts and child entities

Multiple stores can be used in parallel and act completely independent from each other.
The example shows how to create a store. Mainly every example will start with this line.

public static void CreateStore()
{
    var store = new EntityStore();
}

Entity

An Entity has an identity - Id - and acts as a container for components, tags, script and child entities.
Entities are related to a single EntityStore and created with CreateEntity().

public static void CreateEntity()
{
    var store = new EntityStore();
    store.CreateEntity();
    store.CreateEntity();
    
    foreach (var entity in store.Entities) {
        Console.WriteLine($"entity {entity}");
    }
    // > entity id: 1  []       Info:  []  shows entity has no components, tags or scripts
    // > entity id: 2  []
}

Entities can be deleted with DeleteEntity().
Variables of type Entity mimic the behavior of reference types.
Using an entity method on a deleted entity throws a NullReferenceException.
To handled this case use entity.IsNull.

public static void DeleteEntity()
{
    var store   = new EntityStore();
    var entity  = store.CreateEntity();
    entity.DeleteEntity();
    var isDeleted = entity.IsNull;
    Console.WriteLine($"deleted: {isDeleted}");         // > deleted: True
}

Entities can be disabled.
Disabled entities are excluded from query results by default.
To include disabled entities in a query result use query.WithDisabled().

public static void DisableEntity()
{
    var store   = new EntityStore();
    var entity  = store.CreateEntity();
    entity.Enabled = false;
    Console.WriteLine(entity);                          // > id: 1  [#Disabled]
    
    var query    = store.Query();
    Console.WriteLine($"default - {query}");            // > default - Query: []  Count: 0
    
    var disabled = store.Query().WithDisabled();
    Console.WriteLine($"disabled - {disabled}");        // > disabled - Query: []  Count: 1
}

Component

Components are structs used to store data on entities.
Multiple components with different types can be added / removed to / from an entity.
If adding a component using a type already stored in the entity its value gets updated.

[ComponentKey("my-component")]
public struct MyComponent : IComponent {
    public int value;
}

public static void AddComponents()
{
    var store   = new EntityStore();
    var entity  = store.CreateEntity();
    
    // add components
    entity.AddComponent(new EntityName("Hello World!"));// EntityName is a build-in component
    entity.AddComponent(new MyComponent { value = 42 });
    Console.WriteLine($"entity: {entity}");             // > entity: id: 1  "Hello World!"  [EntityName, Position]
    
    // get component
    Console.WriteLine($"name: {entity.Name.value}");    // > name: Hello World!
    var value = entity.GetComponent<MyComponent>().value;
    Console.WriteLine($"MyComponent: {value}");         // > MyComponent: 42
    
    // Serialize entity to JSON
    Console.WriteLine(entity.DebugJSON);
}

Result of entity.DebugJSON:

{
    "id": 1,
    "components": {
        "name": {"value":"Hello World!"},
        "my-component": {"value":42}
    }
}

Unique entity

Add a UniqueEntity component to an entity to mark it as a "singleton" with a unique string id.
The entity can than be retrieved with EntityStore.GetUniqueEntity() to reduce code coupling.
It enables access to a unique entity without the need to pass an entity by external code.

public static void GetUniqueEntity()
{
    var store   = new EntityStore();
    store.CreateEntity(new UniqueEntity("Player"));     // UniqueEntity is a build-in component
    
    var player  = store.GetUniqueEntity("Player");
    Console.WriteLine($"entity: {player}");             // > entity: id: 1  [UniqueEntity]
}

Tag

Tags are structs similar to components - except they store no data.
They can be utilized in queries similar as components to restrict the amount of entities returned by a query.
If adding a tag using a type already attached to the entity the entity remains unchanged.

public struct MyTag1 : ITag { }
public struct MyTag2 : ITag { }

public static void AddTags()
{
    var store   = new EntityStore();
    var entity  = store.CreateEntity();
    
    // add tags
    entity.AddTag<MyTag1>();
    entity.AddTag<MyTag2>();
    Console.WriteLine($"entity: {entity}");             // > entity: id: 1  [#MyTag1, #MyTag2]
    
    // get tag
    var tag1 = entity.Tags.Has<MyTag1>();
    Console.WriteLine($"tag1: {tag1}");                 // > tag1: True
}

Query entities

As described in the intro queries are a fundamental feature of an ECS.
Friflo.Engine.ECS support queries by any combination of component types and tags.

See ArchetypeQuery - API for available query filters to reduce the number of entities / components returned by a query.

ArchetypeQuery and all its generic variants returned by store.Query() are designed for reuse.
So their references can be stored and used when needed to avoid unnecessary allocations.

public static void EntityQueries()
{
    var store   = new EntityStore();
    store.CreateEntity(new EntityName("entity-1"));
    store.CreateEntity(new EntityName("entity-2"), Tags.Get<MyTag1>());
    store.CreateEntity(new EntityName("entity-3"), Tags.Get<MyTag1, MyTag2>());
    
    // --- query components
    var queryNames = store.Query<EntityName>();
    queryNames.ForEachEntity((ref EntityName name, Entity entity) => {
        // ... 3 matches
    });
    
    // --- query components with tags
    var queryNamesWithTags  = store.Query<EntityName>().AllTags(Tags.Get<MyTag1, MyTag2>());
    queryNamesWithTags.ForEachEntity((ref EntityName name, Entity entity) => {
        // ... 1 match
    });
}

Some optional filter snippets used to shrink the result set returned by a query.

    .WithDisabled();                                // query result contains also disabled entities
    .AllTags(Tags.Get<MyTag1>());                   // query result contains only entities having all given tags
    .WithoutAnyTags(Tags.Get<MyTag1, MyTag2>());    // entities having any of the given tags are excluded from query result
    .AllComponents(ComponentTypes.Get<Position>);   // query result contains only entities having all given components

Archetype

An Archetype defines a specific set of components and tags for its entities.
At the same time it is also a container of entities with exactly this combination of components and tags.

The following comparison shows the difference in modeling types in ECS vs OOP.

ECS - Composition OOP - Polymorphism
Inheritance
ECS does not utilize inheritance.
It prefers composition over inheritance.

Common OPP is based on inheritance.
Likely result: A god base class responsible for everything. 😊
Code coupling
Data lives in components - behavior in systems.
New behaviors does not affect existing code.

Data and behavior are both in classes.
New behaviors may add dependencies or side effects.
Storage
An Archetype is also a container of entities.

Organizing containers is part of application code.
Changing a type
Supported by adding/removing tags or components.

Type is fixed an cannot be changed.
Component access / visibility
Having a reference to an EntityStore enables
unrestricted reading and changing of components.

Is controlled by access modifiers:
public, protected, internal and private.
Example
// No base class Animal in ECS
struct Dog : ITag { }
struct Cat : ITag { }


var store = new EntityStore();

var dogType = store.GetArchetype(Tags.Get<Dog>());
var catType = store.GetArchetype(Tags.Get<Cat>());
WriteLine(dogType.Name);            // [#Dog]

dogType.CreateEntity();
catType.CreateEntity();

var dogs = store.Query().AnyTags(Tags.Get<Dog>());
var all  = store.Query().AnyTags(Tags.Get<Dog, Cat>());

WriteLine($"dogs: {dogs.Count}");   // dogs: 1
WriteLine($"all: {all.Count}");     // all: 2
class Animal { }
class Dog : Animal { }
class Cat : Animal { }


var animals = new List<Animal>();

var dogType = typeof(Dog);
var catType = typeof(Cat);
WriteLine(dogType.Name);            // Dog

animals.Add(new Dog());
animals.Add(new Cat());

var dogs = animals.Where(a => a is Dog);
var all  = animals.Where(a => a is Dog or Cat);

WriteLine($"dogs: {dogs.Count()}"); // dogs: 1
WriteLine($"all: {all.Count()}");   // all: 2
Performance
Runtime complexity O() of queries for specific types
O(size of result set)

O(size of all objects)
Memory layout
Continuous memory in heap - high hit rate of L1 cache.

Randomly placed in heap - high rate of L1 cache misses.
Instruction pipelining
Minimize conditional branches in update loops.
Process multiple components at once using SIMD.


Virtual method calls prevent branch prediction.

Script

Scripts are similar to components and can be added / removed to / from entities.
Scripts are classes and can also be used to store data.
Additional to components they enable adding behavior in the common OOP style.

In case dealing only with a few thousands of entities Scripts are fine.
If dealing with a multiple of 10.000 components should be used for efficiency / performance.

public class MyScript : Script { public int data; }

public static void AddScript()
{
    var store   = new EntityStore();
    var entity  = store.CreateEntity();
    
    // add script
    entity.AddScript(new MyScript{ data = 123 });
    Console.WriteLine($"entity: {entity}");             // > entity: id: 1  [*MyScript]
    
    // get script
    var myScript = entity.GetScript<MyScript>();
    Console.WriteLine($"data: {myScript.data}");        // > data: 123
}

Child entities

A typical use case in Games or Editor is to build up a hierarchy of entities.
To add an entity as a child to another entity use Entity.AddChild().
In case the added child already has a parent it gest removed from the old parent.
The children of the added (moved) entity remain being its children.
If removing a child from its parent all its children are removed from the hierarchy.

public static void AddChildEntities()
{
    var store   = new EntityStore();
    var root    = store.CreateEntity();
    var child1  = store.CreateEntity();
    var child2  = store.CreateEntity();
    
    // add child entities
    root.AddChild(child1);
    root.AddChild(child2);
    
    Console.WriteLine($"child entities: {root.ChildEntities}"); // > child entities: Count: 2
}

Event

If changing an entity by adding or removing components, tags, scripts or child entities events are emitted.
An application can subscribe to these events like shown in the example.
Emitting these type of events increase code decoupling.
Without events these modifications need to be notified by direct method calls.
The build-in events can be subscribed on EntityStore and on Entity level like shown in the example below.

public static void AddEventHandlers()
{
    var store   = new EntityStore();
    var entity  = store.CreateEntity();
    entity.OnComponentChanged     += ev => { Console.WriteLine(ev); }; // > entity: 1 - event > Add Component: [MyComponent]
    entity.OnTagsChanged          += ev => { Console.WriteLine(ev); }; // > entity: 1 - event > Add Tags: [#MyTag1]
    entity.OnScriptChanged        += ev => { Console.WriteLine(ev); }; // > entity: 1 - event > Add Script: [*MyScript]
    entity.OnChildEntitiesChanged += ev => { Console.WriteLine(ev); }; // > entity: 1 - event > Add Child[0] = 2

    entity.AddComponent(new MyComponent());
    entity.AddTag<MyTag1>();
    entity.AddScript(new MyScript());
    entity.AddChild(store.CreateEntity());
}

Signal

Signals are similar to events. They are used to send and receive custom events on entity level in an application.
They have the same characteristics as events described in the section above.
The use of Signal's is intended for scenarios when something happens occasionally.
This avoids the need to check a state every frame.

public readonly struct MySignal { } 

public static void AddSignalHandler()
{
    var store   = new EntityStore();
    var entity  = store.CreateEntity();
    entity.AddSignalHandler<MySignal>(signal => { Console.WriteLine(signal); }); // > entity: 1 - signal > MySignal    
    entity.EmitSignal(new MySignal());
}

JSON Serialization

The entities stored in an EntityStore can be serialized as JSON using an EntitySerializer.

Writing the entities of a store to a JSON file is done with WriteStore().
Reading the entities of a JSON file into a store with ReadIntoStore().

public static void JsonSerialization()
{
    var store = new EntityStore();
    store.CreateEntity(new EntityName("hello JSON"));
    store.CreateEntity(new Position(1, 2, 3));

    // --- Write store entities as JSON array
    var serializer = new EntitySerializer();
    var writeStream = new FileStream("entity-store.json", FileMode.Create);
    serializer.WriteStore(store, writeStream);
    writeStream.Close();
    
    // --- Read JSON array into new store
    var targetStore = new EntityStore();
    serializer.ReadIntoStore(targetStore, new FileStream("entity-store.json", FileMode.Open));
    
    Console.WriteLine($"entities: {targetStore.Count}"); // > entities: 2
}

The JSON content of the file "entity-store.json" created with serializer.WriteStore()

[{
    "id": 1,
    "components": {
        "name": {"value":"hello JSON"}
    }
},{
    "id": 2,
    "components": {
        "pos": {"x":1,"y":2,"z":3}
    }
}]

Native AOT

Friflo.Engine.ECS supports Native AOT deployment.

Note: JSON serialization is currently not support by Native AOT.

Using Friflo.Engine.ECS does not require a source generator - aka Roslyn Analyzer.
A source generator could be used to register component types automatically.

Because of this component types used by an application must be registered on startup as shown below.

var aot = new NativeAOT();
aot.RegisterComponent<MyComponent>();
aot.RegisterTag      <MyTag1>();
aot.RegisterScript   <MyScript>();
var schema = aot.CreateSchema();

In case using an unregistered component a TypeInitializationException will be thrown. E.g.

 entity.AddComponent(new UnregisteredComponent());

On console to the exception log looks like

A type initializer threw an exception. To determine which type, inspect the InnerException's StackTrace property.
Stack Trace:
   at System.Runtime.CompilerServices.ClassConstructorRunner.EnsureClassConstructorRun(StaticClassConstructionContext*) + 0x14f
   at System.Runtime.CompilerServices.ClassConstructorRunner.CheckStaticClassConstructionReturnNonGCStaticBase(StaticClassConstructionContext*, IntPtr) + 0x1c
   at Friflo.Engine.ECS.Entity.AddComponent[T](T&) + 0x4e
   at MyApplication.UseUnregisteredComponent() + 0x7c

Unity update script

A query can be used within a MonoBehaviour script to update the position of moving objects.
Example for a move system in Unity.

public class MoveEntitySystem : MonoBehaviour
{
    private ArchetypeQuery<Position> query;

    void Start()
    {
        int entityCount = 1_000;
        var store = new EntityStore();
        // create entities with a Position component
        for (int n = 0; n < entityCount; n++) {
            store.Batch()
                .Add(new Position(n, 0, 0))
                .CreateEntity();    
        }
        query = store.Query<Position>();
    }

    void Update()
    {
        foreach (var (positions, entities) in query.Chunks)
        {
            // Update entity positions on each frame
            foreach (ref var position in positions.Span) {
                position.y++;
            }
        }
    }
}