GnP 3: Sync hostiles and VFX with ECS

In this chapter we give a brief overview of the Entity Component System and go through how to use it in order to sync hostiles and VFX.

ECS (Entity Component System)

In ConjureKit, we can define and attach a Component onto an Entity. Each Component can have a message attached as a byte array. We can also create a System class that listens to certain Component changes (adding, updating and deleting).

To sync the hostiles' lifetime and the hit effects (the effects shown on the ghosts and pumpkins when the players shoot them), we can use components. For example, we can use ^Component.Hostile^ to synchronize the hostile's lifetime, and we can listen to the ^Component.Hostile^ callback. If the component is added we spawn a new hostile, and if the component is deleted we destroy it.

For the hit effects, we can listen to the ^Component.HitFX^ callback for any component updates to play the particle effects. The components' payload can contain information, for example the position of the hit effects, that gets passed as a byte[] when the component is updated.

Sync hostiles and VFX

In this part, we'll implement Systems that will handle adding, updating, and deleting Components. First let's create a new subdirectory called Systems in the Scripts directory. Inside Systems, add a new C# script and call it ^HostilesSystem.cs^.

Here is the base implementation of ^HostilesSystem.cs^. It needs to be derived from the ^SystemBase^ class, which is an abstract class provided by ConjureKit for implementing Systems.

using System;
using System.Collections;
using System.Collections.Generic;
using Auki.ConjureKit;
using Auki.ConjureKit.ECS;
using ConjureKitShooter.Models;
using UnityEngine;

public class HostilesSystem : SystemBase
{
    private const string HostileComponent = "HOSTILE.COMPONENT";
    private const string HitFxComponent = "HIT.FX.COMPONENT";

    private uint _hostileComponentTypeId;
    private uint _hitFxComponentTypeId;

    public HostilesSystem(Session session) : base(session)
    {

    }

    public override string[] GetComponentTypeNames()
    {

    }

    public override void Update(IReadOnlyList<(EntityComponent component, bool localChange)> updated)
    {

    }

    public override void Delete(IReadOnlyList<(EntityComponent component, bool localChange)> deleted)
    {

    }
}

start-note

The base constructor will cache the session from the constructor argument into a protected var called ^_session^ which will hold the current connected session.

end-note

When implementing a System derived from ^SystemBase^, there are three abstract methods that need to be overridden, which are:

  • ^string[] GetComponentTypeNames()^: tells the System which Components it should listen to
  • ^void Update(IReadOnlyList<(EntityComponent component, bool localChange)> updated)^: gets notifications when Components are added or updated
  • ^void Delete(IReadOnlyList<(EntityComponent component, bool localChange)> deleted)^: gets notifications when Components are deleted

Let's set up ^string[] GetComponentTypeNames()^ by returning the expected ^string[]^ type with the component names we have defined:

public override string[] GetComponentTypeNames()
{
    return new[] {HostileComponent, HitFxComponent};
}

We'll get back to the other two methods in a moment.

As you can see in the override method above, we register the Components that we want the system to listen to using a string object. But we want to also cache the ^uint^ type of the Components' representation, which can only be done when the System has been successfully registered and the Components have been successfully initialized. To do that, we need to define a public method that's called upon successful registration of the System:

public void GetComponentsTypeId()
{
    _session.GetComponentTypeId(HostileComponent, u => _hostileComponentTypeId = u, error =>
    {
        Debug.LogError(error.TagString());
    });
    _session.GetComponentTypeId(HitFxComponent, u => _hitFxComponentTypeId = u, error =>
    {
        Debug.LogError(error.TagString());
    });
}

Next we want to create two new public methods for adding/spawning the hostile, and for deleting/destroying the hostile.

public void AddHostile(Entity entity, float speed, uint targetEntityId)
{
    // Define and initialize payload values
    var targetPos = _session.GetEntityPose(targetEntityId).position;
    var types = Enum.GetValues(typeof(HostileType));
    var payload = new HostileData()
    {
        Speed = speed,
        TargetPos = new SVector3(targetPos),
        TimeStamp = DateTime.UtcNow.Ticks,
        Type = (HostileType)types.GetValue(UnityEngine.Random.Range(0, types.Length))
    }.ToJsonByteArray();

    // Add the related component to each Hostile entity, along with the payload
    _session.AddComponent(_hostileComponentTypeId, entity.Id, payload, null,
        error => Debug.LogError(error.TagString()));
    _session.AddComponent(_hitFxComponentTypeId, entity.Id, _emptyByte, null,
        error => Debug.LogError(error.TagString()));
}

public void DeleteHostile(uint entityId)
{
    _session.DeleteComponent(_hostileComponentTypeId, entityId, null,
        error => Debug.LogError(error.TagString()));
}

To allow for adding/updating Components without passing any payload (for example when we add ^Hit.Fx.Component^ to the hostile in the example above), we'll define an empty byte[] object:

private readonly byte[] _emptyByte = Array.Empty<byte>();

In order to be able to invoke the HitFx and broadcast it, let's create another public method:

public void SyncHitFx(uint entityId, Vector3 hitPos)
{
    var hostileEntity = _session.GetEntity(entityId);
    if (hostileEntity == null) return;

    var data = new HitData()
    {
        EntityId = entityId,
        Pos = new SVector3(hitPos),
    };
    var jsonData = data.ToJsonByteArray();
    _session.UpdateComponent(_hitFxComponentTypeId, entityId, jsonData);
}

Now that we have methods to invoke/broadcast the events (adding, deleting, and syncing HitFx), we need to implement the listener as well. This is where we'll use the other two override methods:

public override void Update(IReadOnlyList<(EntityComponent component, bool localChange)> updated)
{
    foreach (var c in updated)
    {

    }
}

public override void Delete(IReadOnlyList<(EntityComponent component, bool localChange)> deleted)
{
    foreach (var c in deleted)
    {

    }
}

We will want to loop through the ^updated^ and the ^deleted^ arguments in their respective method. Both of these arguments are collections of type ^<EntityComponent, bool>^. The ^EntityComponent^ will be an object that holds the Type Id (^uint^) of the Component, the owner Entity Id (^uint^), and the payload (^byte[]^). The ^bool^ value indicates whether the EntityComponent we are currently iterating on belongs to an Entity we've created, and we can apply different logic based on this bool value.

Inside the ^Update()^ override, we want to loop through the collection and spawn the hostile with the payload we received from each ^EntityComponent^ in the collection:

public override void Update(IReadOnlyList<(EntityComponent component, bool localChange)> updated)
{
    foreach (var c in updated)
    {
        if (c.component.ComponentTypeId == _hostileComponentTypeId)
        {
            var entity = _session.GetEntity(c.component.EntityId);
            var payload = c.component.Data.FromJsonByteArray<HostileData>();
            var spawnData = new SpawnData()
            {
                startPos = _session.GetEntityPose(entity.Id).position,
                targetPos = payload.TargetPos.ToVector3(),
                linkedEntity = entity,
                speed = payload.Speed,
                timestamp = payload.TimeStamp,
                type = payload.Type
            };
            InvokeSpawnHostile?.Invoke(spawnData);
            continue;
        }

        if (c.component.ComponentTypeId == _hitFxComponentTypeId)
        {
            if (c.component.Data == _emptyByte) return;

            var data = c.component.Data.FromJsonByteArray<HitData>();

            if (data == null)
                data = new HitData(){ EntityId = c.component.EntityId };

            InvokeHitFx?.Invoke(data);
        }
    }
}

Now that we've added a method to act upon each ^EntityComponent^ received in the callback, we'll need to differentiate the action depending on the ^ComponentTypeId^ of the Component. The HitFx will not be invoked if the payload is an empty byte array, and will be invoked if the byte array contains data. This is because the ^override Update()^ method gets called whenever there is an add component or update component event, and because we are sending an empty byte array when initializing ^HIT.FX.COMPONENT^, we can assume that if the payload is an empty byte array, then we should not play the particle effects related to it. Only when we receive the call with some data in the payload will we invoke the HitFx using an event.

Since each Hostile Component is only added once in its lifetime, we don't need to apply similar logic to that one.

Next we need to add the actions we've invoked in the code above, as well as one for the Destroy event:

public event Action<SpawnData> InvokeSpawnHostile;
public event Action<HitData> InvokeHitFx;
public event Action<uint> InvokeDestroyHostile;

And for the ^Delete()^ override method, we just want to invoke the Destroy event:

public override void Delete(IReadOnlyList<(EntityComponent component, bool localChange)> deleted)
{
    foreach (var c in deleted)
    {
        if (c.component.ComponentTypeId == _hostileComponentTypeId)
        {
            var entityId = c.component.EntityId;
            InvokeDestroyHostile?.Invoke(entityId);
        }
    }
}

Now we're done with ^HostilesSystem.cs^, so next we'll initialize the System and make sure other classes that handle the hostiles spawning use this System instead.

In ^Main.cs^, let's add a field for ^HostilesSystem.cs^:

private HostilesSystem _hostilesSystem;

Then we'll initialize it inside the ^OnJoined(Session)^ method:

private void OnJoined(Session session)
{
    _myI...
    _se...

    ~~
    _hostilesSystem = new HostilesSystem(_session);
    _session.RegisterSystem(_hostilesSystem, () =>
    {
        _hostilesSystem.GetComponentsTypeId();
    });
    ~~

    _gam...
    _gameE...

    uiMan...
}

Using ^_session.RegisterSystem()^, we can pass an Action to the second argument to invoke the ^GetComponentsTypeId()^ method from ^HostilesSystem.cs^. This will cache the Component Type Id. Note that getting the Component Type Id is only possible after successful System registration.

We also want to clear the System when the user is disconnected, which we can do in the ^OnLeft(Session)^ method by setting the object to ^null^:

private void OnLeft(Session lastSession)
{
    GameOver();
    _hostilesSystem = null;
}

Before we can test the System we need to make some adjustments to ^HostileController.cs^, so let's open that script. In addition to adding the ^Auki.ConjureKit^ namespace and a couple of new fields, we will need to modify the ^Initialize()^ method so it caches the current ^session^ when connected:

using Auki.ConjureKit;
private Session _session;
private HostilesSystem _hostilesSystem;
~~
public void Initialize(IConjureKit conjureKit, Main main)
~~
{
    _ma...

    ~~
    conjureKit.OnJoined += session => _session = session;
    conjureKit.OnLeft += session => _session = null;
    ~~
    
    main.OnGameSta...
    main.OnGameE...

    _minInt...
    _maxInt...
    _ufoSp...

    _play...
}

Next, let's create two new methods in ^HostileController.cs^ to listen to the events from ^HostilesSystem.cs^:

public void SetListener(HostilesSystem hostilesSystem)
{
    _hostilesSystem = hostilesSystem;
    _hostilesSystem.InvokeSpawnHostile += SpawnHostileInstance;
    _hostilesSystem.InvokeDestroyHostile += DestroyHostileListener;
    _hostilesSystem.InvokeHitFx += SyncHitFx;
}

public void RemoveListener()
{
    _hostilesSystem.InvokeSpawnHostile -= SpawnHostileInstance;
    _hostilesSystem.InvokeDestroyHostile -= DestroyHostileListener;
    _hostilesSystem.InvokeHitFx -= SyncHitFx;
}

Now that we can get ^SpawnData^ from the ^_hostilesSystem.InvokeSpawnHostile^ event, we need to update ^SpawnHostileInstance()^ so it uses the Entity Id instead of the ^_totalSpawnCount^ value for each of the ^_spawnedHostiles^ keys. We can also remove the incrementation of ^_totalSpawnCount^. ^_totalSpawnCount^ was the value used to identify each Hostile entity from the collection in the initial single-player mode. We want to also pass the ^SyncHitFx^ method as an action to ^hostile.Initialize^:

private void SpawnHostileInstance(SpawnData data)
{
    var timeCompensation = ((DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - data.timestamp) / 1000f);

    var hostile = Instantiate(GetHostile(data.type), data.startPos, Quaternion.identity);
    hostile.Initialize(
        ~~
        data.linkedEntity.Id,
        ~~
        data.targetPos,
        data.speed,
        ~~
        _hostilesSystem.SyncHitFx,
        ~~
        _main.OnHit,
        InvokeRemoveHostileInstance,
        timeCompensation);
    hostile.transform.SetParent(hostileGroup);
    ~~
    _spawnedHostiles.Add(data.linkedEntity.Id, hostile);
    _totalSpawnCount++; // Remove this line
    ~~
}

^DestroyHostileListener()^ and ^SyncHitFx()^ will now throw an error since we haven't implemented them yet, so let's do that:

private void DestroyHostileListener(uint entityId)
{
    if (!_spawnedHostiles.ContainsKey(entityId))
    {
        return;
    }

    // Destroy the hostile instance
    _spawnedHostiles[entityId].DestroyInstance();
    _spawnedHostiles.Remove(entityId);

    // Check if the entity belongs to this local participant
    var hostileEntity = _session.GetEntity(entityId);
    if (hostileEntity == null || hostileEntity.ParticipantId != _session.ParticipantId)
        return;

    // If it is, then delete the Entity
    _session.DeleteEntity(entityId, null);
}
private void SyncHitFx(HitData data)
{
    if (!_spawnedHostiles.ContainsKey(data.EntityId))
    {
        // Skip if no hostile exists with the above entity Id
        return;
    }

    // Skip if the hit position is zero (default)
    if (data.Pos.ToVector3() == Vector3.zero) return;

    // Trigger the spawn hit fx on the related hostile
    _spawnedHostiles[data.EntityId].SpawnHitFx(data);
}

In addition to the changes above, we will need to update ^HostileScript.cs^. First we want to add a couple of lines to the ^Initialize^ method:

public void Initialize(
    uint id,
    Vector3 target,
    float speed,
    ~~
    Action<uint,Vector3> onHit,
    ~~
    Action onPlayerHit,
    Action<uint> onDestroy,
    float timeCompensation = 0f)
{
    _rb = GetComponent<Rigidbody>();
    _audio = GetComponent<AudioSource>();

    _entityId = id;
    _targetPos = target;
    _speed = speed;

    ~~
    _onHit = onHit;
    ~~
    _onPlayerHit = onPlayerHit;
    _onDestroy = onDestroy;

    var velocity = _speed * (_targetPos - _rb.position).normalized;
    _rb.position += timeCompensation * velocity;

    spawnParticle.Play();
    PlaySound(spawnSfx);
}

And second, the compiler will complain that it can't find the ^SpawnHitFx(HitData)^ method in ^HostileScript.cs^, so let's add that:

public void SpawnHitFx(HitData obj)
{
    if (hitParticles == null)
        return;

    var particleT = hitParticles.transform;
    particleT.position = obj.Pos.ToVector3();
    particleT.rotation = Quaternion.LookRotation(particleT.position - transform.position);
    hitParticles.Play();
    PlaySound(hitSfx);
}

Plus the ^ConjureKitShooter.Models^ namespace:

using ConjureKitShooter.Models;

The method above will act as a Hit Fx synchronization listener (in order to show the Hit Fx to all other participants as well). Now we need to broadcast the Add Hostile event, so that any participant can see and be able shoot the hostiles. Let's remove this part from the ^Update()^ method of ^HostileController.cs^:

private void Update()
{
    if (!_isSp...
    if (!(Time.ti...

    var po...
    ~~
    var targetPos = _player.position;
    ~~
    _spawnTi...

    ~~
    var types = Enum.GetValues(typeof(HostileType));

    var spawnData = new SpawnData()
    {
         startPos = pos,
         speed =  _ufoSpeed,
         targetPos = targetPos,
         timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
         type = (HostileType)types.GetValue(Random.Range(0, types.Length))
    };

    SpawnHostileInstance(spawnData);
    ~~

    _spaw...

    ....
}

We don't need the above code anymore since it's now being handled by ^HostilesSystem.cs^. Instead, we'll insert these lines:

_spawnTime = Time.time + Random.Range(_minInterval, _maxInterval);

~~
var targetEntityId = _main.GetRandomParticipantEntityId();

var pose = new Pose(pos, Quaternion.identity);
_session.AddEntity(pose, entity =>
{
    _hostilesSystem.AddHostile(entity, _hostileSpeed, targetEntityId);
}, Debug.LogError);
~~

_spawnCount++;

Now ^_main.GetRandomParticipantEntityId()^ will throw an error since we haven't declared it yet. What we want to get from that method is the Entity Id of a random participant, so we can set the current position of that Entity (which is the phone position) as the target direction of the newly spawned hostile. This will allow different hostiles to fly toward different participants in the session. So let's declare the method in ^Main.cs^:

public uint GetRandomParticipantEntityId()
{
    return 0;
}

We also need a list of every participant that joins and leaves. Fortunately we've already prepared the methods that listen to the ConjureKit callbacks, so in ^Main.cs^, let's add a new collection:

private List<Entity> _participantEntities = new();

Next we'll declare a new method that will update the Participant Entities in the session:

private void UpdateParticipantsEntity()
{
    var participants = _session.GetParticipants();

    if (participants.Count == _participantEntities.Count) return;

    _participantEntities.Clear();

    foreach (var participant in _session.GetParticipants())
    {
        foreach (var entity in _session.GetParticipantEntities(participant.Id))
        {
            if (entity.Flag == EntityFlag.EntityFlagParticipantEntity)
            {
                _participantEntities.Add(entity);
            }
        }
    }
}

The method above will loop through all the Entities that belong to each of the participants in the Session, and check for any Entity that has the flag ^EntityFlagParticipantEntity^. If found, add that specific Entity into the ^_participantEntities^ collection that we've just declared above. Then we'll update ^GameStart()^ and ^OnEntityDeleted()^ so that they call ^UpdateParticipantsEntity()^:

private void GameStart()
{
    _score = 0;
    _health = maxHealth;

    healthBar.UpdateHealth(_health/(float)maxHealth);
    healthBar.ShowHealthBar(true);
    uiManager.ChangeUiState(GameState.GameOn);
    ~~
    UpdateParticipantsEntity();
    ~~
}
private void OnEntityDeleted(uint entityId)
{
    UpdateParticipantsEntity();

    if (_participantEntities.Count < 2)
    {
        GameOver();
    }
}

Now we should be able to get a random participant's Entity Id, so that Hostile Controller can pick a random player as the target. We'll add logic to do that in the ^GetRandomParticipantEntityId()^ method:

public uint GetRandomParticipantEntityId()
{
    var id = _participantEntities[UnityEngine.Random.Range(0, _participantEntities.Count)].Id;
    return id;
}

start-note

We need to explicitly type the namespace for the Random class above because ^Main.cs^ is using the ^UnityEngine^ and the ^System^ namespaces and both contain a class called Random.

end-note

^SetListener()^ and ^RemoveListener()^ from ^HostileController.cs^ need to be called in the ^OnJoined^ and ^OnLeft^ callbacks in ^Main.cs^:

private void OnJoined(Session session)
{
    ....
    _gameEventController.OnGameOver = GameOver;

    ~~
    hostileController.SetListener(_hostilesSystem);
    ~~
    uiManager.SetSessionId(_session.Id);
}

private void OnLeft(Session lastSession)
{
    ~~
    hostileController.RemoveListener();
    ~~
    GameOver();
    ....
}

Next, we need to fix an error in the ^Start()^ method that arose due to the changes we made in the ^Initialize()^ method of ^HostileController.cs^. We'll update the initialization call to:

hostileController.Initialize(_conjureKit, this);

Lastly, we'll broadcast Hostile Destroyed events which is quite straightforward. All we need to do is add a line to the ^InvokeRemoveHostileInstance()^ method in ^HostileController.cs^:

    private void InvokeRemoveHostileInstance(uint entityId)
    {
        ~~
        _hostilesSystem.DeleteHostile(entityId);
        ~~
        _spawned....
    }

At this stage, we should be able to test the Hostile lifetime sync by using two devices (or one device plus the Unity Editor). Notice that when a game has started, both participants will be able to see the Hostile spawned on their own devices, and be able to shoot the Hostile. The other participant will see when the Hostile is destroyed.

Need a refresher on the essentials?

Check out more lessons, DIY kits and essentials reading material at the developer learning centre homepage.

Want to build on the posemesh and need a helping hand?

Apply for a grant of AUKI tokens to get your project off the ground, and work directly with the Auki Labs team to get your creation to market. Successful applicants may be granted up to 100k USD worth of AUKI tokens, and development and marketing support from the Auki Labs team.