Skip to main content

Convert AR game to multiplayer

In this tutorial, we'll show you how to convert a single-player AR game into a multiplayer game using ConjureKit.

The game features ghosts and pumpkins that spawn at a distance and fly toward the player, and the player's objective is to shoot them before getting hit.

Over the course of this tutorial, we'll go through how to use the Vikja module and the Entity Component System to add multiplayer functionality so that multiple people can join the same game, shoot the ghosts and pumpkins together, and see who can achieve the highest score.

Chapter index

Before you begin

Before getting started, set up your development environment by following the steps in the Quickstart guide up to and including Authenticate towards the registry.

Unity & package versions

  • Unity 2021.3.24f1
  • Auki Labs ARFoundation Integration v0.6.35
  • Auki Labs ConjureKit v0.6.44
  • Auki Labs ConjureKit Manna module v0.6.58
  • Auki Labs ConjureKit Vikja module v0.6.5
  • AR Foundation v4.2.9
  • ARKit XR Plugin v4.2.9

01 - Project setup

For the purpose of this tutorial, we have created a repository that you can use as a starting point for the project. All features of the gameplay have been implemented in single player mode. The repository commits have been tagged to mark the states of the project according to this tutorial's chapter index.

To get started, pull the project from GitHub and check out the "Initial project setup" commit:

git clone git@github.com:aukilabs/ConjureKit-Shooter-Game.git
cd ConjureKit-Shooter-Game
git checkout 07d285316bc8d4626ba444172be70bffa6cb679a

Upon opening the project in Unity, there will be an error message asking you to enter Safe Mode; you can safely ignore this. The cause of the error is the project's dependency on a couple of assets that are available for free in the Unity asset store. In order to fix this, please import these two assets:

Once the above assets are imported (note: don't forget to go through the quick setup process for DOTween), you should be able to play and test the game. If you want to try it in the Editor, you can press Alpha 1 to toggle mouse control, in order to emulate first person control for easier editor testing.

The project can also be built to a device without any additional changes to settings. Please refer to the relevant device-platform deployment documentation. You will see a "Missing Project ID" dialog, but you can click "Yes" to continue.

02 - ConjureKit implementation

In this part, we'll start implementing ConjureKit into the project, and listen to some of the callbacks. The packages we need should already be installed in the project pulled from GitHub, but if you need to, please refer to the Quickstart guide for instructions on how to set up ConjureKit.

note

Please also make sure the Managed Stripping Level is set to Minimal, under Project Settings > Player > Optimization.

Strip Engine to Minimal.png

First let's add the necessary packages to Main.cs:

using Auki.ConjureKit;
using Auki.ConjureKit.Manna;
using Auki.ConjureKit.Vikja;
using Auki.Integration.ARFoundation.Manna;
using Auki.Util;

Then we'll declare the fields that will hold references to ConjureKit and its modules:

private IConjureKit _conjureKit;
private Vikja _vikja;
private Manna _manna;
private FrameFeederBase _arCameraFrameFeeder;

These two fields will keep track of the session and its current state:

private State _currentState;
private Session _session;

Next we want to construct/initialize all the ConjureKit modules. We can do that in the Start() method of Main.cs:

private void Start()
{
Screen.sleepTimeout = SleepTimeout.NeverSleep;

_conjureKit = new ConjureKit(arCamera.transform, "YOUR_APP_KEY", "YOUR_APP_SECRET", AukiDebug.LogLevel.ERROR);
_manna = new Manna(_conjureKit);
_vikja = new Vikja(_conjureKit);

_arCameraFrameFeeder = _manna.GetOrCreateFrameFeederComponent();
_arCameraFrameFeeder.AttachMannaInstance(_manna);

EventInit();
    _conjureKit.Connect();
}
IMPORTANT

Make sure you replace YOUR_APP_KEY and YOUR_APP_SECRET with the values you got during registration.

Now let's create EventInit() in which we'll subscribe all the methods (that we'll declare shortly) to their respective events from ConjureKit:

private void EventInit()
{
_conjureKit.OnJoined += OnJoined;
_conjureKit.OnLeft += OnLeft;
_conjureKit.OnParticipantLeft += OnParticipantLeft;
_conjureKit.OnEntityDeleted += OnEntityDeleted;
_conjureKit.OnParticipantEntityCreated += OnParticipantEntityCreated;
_conjureKit.OnStateChanged += OnStateChange;

_manna.OnLighthouseTracked += OnLighthouseTracked;
_manna.OnCalibrationSuccess += OnCalibrationSuccess;
}

📝 For more information on these callbacks, please refer to ConjureKit core package and OnLighthouseTracked.

And then we'll declare these methods to listen to the respective events above:

#region ConjureKit Callbacks
private void OnJoined(Session session)
{

}

private void OnLeft(Session lastSession)
{

}

private void OnParticipantLeft(uint participantId)
{

}

private void OnEntityDeleted(uint entityId)
{

}

private void OnParticipantEntityCreated(Entity entity)
{

}

private void OnStateChange(State state)
{

}

private void OnLighthouseTracked(Lighthouse lighthouse, Pose pose, bool closeEnough)
{

}

private void OnCalibrationSuccess(Matrix4x4 calibrationMatrix)
{

}
#endregion

Now that we have prepared all the needed callbacks, let's add a way to update the Session ID and the ConjureKit State in UIManager.cs:

public void SetSessionId(string id)
{
sessionText.SetText(id);
}

public void UpdateState(string state)
{
stateText.SetText(state);
}

With the above methods declared, let's call them in the OnJoined callback:

#region ConjureKit Callbacks
private void OnJoined(Session session)
{
_myId = session.ParticipantId;
_session = session;

uiManager.SetSessionId(_session.Id);
}

and OnStateChange:

private void OnStateChange(State state)
{
_currentState = state;
uiManager.UpdateState(_currentState.ToString());
var sessionReady = _currentState is State.Calibrated or State.JoinedSession;
_arCameraFrameFeeder.enabled = sessionReady;
}

Now we should be able to see the Session ID and the ConjureKit State upon running the project. By enabling the _arCameraFrameFeeder when the current state is JoinedSession or Calibrated, Manna will start scanning once we are connected to a session, and will stop automatically if we are disconnected.

03 - Message broadcasting with Vikja

By leveraging the Vikja module, developers can broadcast messages to all of the players (or participants, in the language of ConjureKit) in the session. We'll use it to broadcast the game state, so that any game start or game over event will be synchronized across all participants in the session.

Here is the complete script for GameEventController.cs:

using System;
using Auki.ConjureKit;
using Auki.ConjureKit.Vikja;
using Auki.Util;
using ConjureKitShooter.Models;
using UnityEngine;


public class GameEventController
{
private IConjureKit _conjureKit;
private Vikja _vikja;

private uint _myEntityId;

private const string NotifyGameState = "NOTIFY.GAME.STATE";
private const string NotifySpawnerPos = "NOTIFY.SPAWNER.POS";

public Action OnGameOver, OnGameStart;
public Action<Pose> OnSpawnerMove;

#region Public Methods
public void Initialize(IConjureKit conjureKit, Vikja vikja)
{
_conjureKit = conjureKit;
_vikja = vikja;
_vikja.OnEntityAction += OnEntityAction;
_conjureKit.OnParticipantEntityCreated += SetMyEntityId;
}

public void SendGameState(bool start)
{
_vikja.RequestAction(_myEntityId, NotifyGameState , start.ToJsonByteArray(), null, null);
}

public void SendSpawnerPos(Pose pose)
{
_vikja.RequestAction(_myEntityId, NotifySpawnerPos, new SPose(pose).ToJsonByteArray(), action =>
{
OnSpawnerMove?.Invoke(action.Data.FromJsonByteArray<SPose>().ToUnityPose());
}, null);
}
#endregion

#region Private Methods
private void SetMyEntityId(Entity entity)
{
_myEntityId = entity.Id;
}
private void OnEntityAction(EntityAction obj)
{
switch (obj.Name)
{
case NotifyGameState:
var gameOn = obj.Data.FromJsonByteArray<bool>();
if (gameOn)
OnGameStart?.Invoke();
else
OnGameOver?.Invoke();
break;
case NotifySpawnerPos:
OnSpawnerMove?.Invoke(obj.Data.FromJsonByteArray<SPose>().ToUnityPose());
break;
}
}
#endregion
}

With the script above, anyone in the session can broadcast game state changes using SendGameState(bool start). Now let's instantiate GameEventController.cs in Main.cs:

private GameEventController _gameEventController = new();

And initialize it in the Start() method:

_gameEventController.Initialize(_conjureKit, _vikja);

Then assign the GameStart and GameOver callbacks in the OnJoined() method:

private void OnJoined(Session session)
{
_myId...
_ses...

_gameEventController.OnGameStart = GameStart;
_gameEventController.OnGameOver = GameOver;

uiMa...
}

Next we need to define the SendGameStart and SendGameOver events. We can do that by adding a new bool field called _spawner, and creating two new methods in Main.cs:

private bool _spawner;
private void SendGameStart()
{
_spawner = true;
OnGameStart?.Invoke();
_gameEventController.SendGameState(true);
GameStart();
}
private void SendGameOver()
{
_gameEventController.SendGameState(false);
GameOver();
}

Remove OnGameStart?.Invoke() from the GameStart() method, since we only want the host/spawner to invoke the event.

Now we want to pass the SendGameStart() method into the uiManager.Initialize() call inside the Start() method. Replace this line:

uiManager.Initalize(GameStart, PlaceSpawner, null, OnNameSet, ToggleAudio);

with this:

uiManager.Initalize(SendGameStart, PlaceSpawner, null, OnNameSet, ToggleAudio);

And whenever a player dies, we want to call SendGameOver() in the HitByHostile() method, so change the GameOver() call:

if (_health <= 0)
{
GameOver();
}

to this:

if (_health <= 0)
{
SendGameOver();
}

We have also implemented a method that will broadcast the new Spawner position if it's being moved, so let's change the PlaceSpawner() method in Main.cs from this:

private void PlaceSpawner()
{
if (!_planeHit) return;

var pose = ...
hostileController.transform.position = pose.position;
}

to this:

private void PlaceSpawner()
{
if (!_planeHit) return;

var pose = ...
_gameEventController.SendSpawnerPos(pose);
}

And add these lines inside the EventInit() method in Main.cs:

_gameEventController.OnSpawnerMove += pose =>
{
hostileController.transform.position = pose.position;
};

So whenever a participant changes the spawner (beam) position, Vikja will broadcast it and all participants in the session will receive the message and see their own spawner position updated.

Lastly, let's also call the GameOver() method whenever we've left a session:

private void OnLeft(Session lastSession)
{
GameOver();
}

We will need a way to toggle the QR code which other participants scan to join the session, so let's add this method to Main.cs:

private void ToggleLighthouse()
{
_isSharing = !_isSharing;
_manna.SetLighthouseVisible(_isSharing);
}

Then pass the ToggleLighthouse() method into the third argument of uiManager.Initialize() called in the Start() method:

uiManager.Initalize(SendGameStart, PlaceSpawner, ToggleLighthouse, OnNameSet, ToggleAudio);

Now we can test this, just to see if the Game start and Game over events are being correctly sent across participants in the session.

note

You can emulate multiple participants by using the Editor plus a phone. While the calibration might not be accurate due to the incorrect QR size when shown via Editor, you should be able to test the Game State synchronization at this point.

04 - 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).

graph TD style Component.Hostile fill:#222,stroke:red,color:white style Component.HitFX fill:#222,stroke:red,color:white style Entity1 fill:#222,stroke:red,color:white style Entity2 fill:#222,stroke:red,color:white Component.Hostile --> Entity1 Component.Hostile --> Entity2 Component.HitFX --> Entity1 Component.HitFX --> Entity2

Diagram 01. Relationship between components and entities.

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.

05 - 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)
{

}
}

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

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;
}
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.

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.

06 - Sync participant scores and shoot VFX

In this final chapter we'll implement another System, a way to visualize scores during gameplay as well as sync a leaderboard at the end of a gameplay round. It also handles other participants' shooting FX. So let's create a new C# script inside the Systems directory and call it ParticipantsSystem.cs. This script will behave similarly to the HostilesSystem.cs script, so the app will be able to manage new components called SCORE.COMPONENT and SHOOT.FX.COMPONENT, and it can listen to the changes as well.

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

public class ParticipantsSystem: SystemBase
{
private byte[] _emptyData = new byte[1];

private uint _scoreComponentTypeId;
private uint _shootFxComponentTypeId;

public event Action<uint, ScoreData> OnParticipantScores;
public event Action<uint, ShootData> InvokeShootFx;

private const string ScoreComponent = "SCORE.COMPONENT";
private const string ShootFxComponent = "SHOOT.FX.COMPONENT";

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

}

/// <summary>
/// Method to generate the components type Id with type of uint
/// </summary>
public void GetComponentsTypeId()
{
_session.GetComponentTypeId(ScoreComponent, id => _scoreComponentTypeId = id,
error => Debug.LogError(error.TagString()));
_session.GetComponentTypeId(ShootFxComponent, id => _shootFxComponentTypeId = id,
error => Debug.LogError(error.TagString()));
}

public override string[] GetComponentTypeNames()
{
return new[] {ScoreComponent, ShootFxComponent};
}

public override void Update(IReadOnlyList<(EntityComponent component, bool localChange)> updated)
{
foreach (var c in updated)
{
if (c.component.ComponentTypeId == _scoreComponentTypeId)
{
var data = c.component.Data.FromJsonByteArray<ScoreData>();
OnParticipantScores?.Invoke(c.component.EntityId, data);
}

if (c.component.ComponentTypeId == _shootFxComponentTypeId)
{
if (c.localChange) return;

var data = c.component.Data.FromJsonByteArray<ShootData>();
if (data == null) return;
InvokeShootFx?.Invoke(c.component.EntityId, data);
}
}
}

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

}

public void GetAllScoresComponent(Action<List<EntityComponent>> onComplete)
{
_session.GetComponents(_scoreComponentTypeId, result =>
{
onComplete?.Invoke(result);
}, error =>
{
Debug.LogError(error);
onComplete?.Invoke(null);
});
}

public void AddParticipantComponent(uint entityId, string name)
{
var data = new ScoreData()
{
name = name
}.ToJsonByteArray();
var ec = _session.GetEntityComponent(entityId, _scoreComponentTypeId);
if (ec != null)
{
_session.UpdateComponent(_scoreComponentTypeId, entityId, data);
return;
}
_session.AddComponent(_scoreComponentTypeId, entityId, data, null, Debug.LogError);
_session.AddComponent(_shootFxComponentTypeId, entityId, _emptyData, null, Debug.LogError);
}

public void UpdateParticipantScoreComponent(uint entityId, int score)
{
var prevData = _session.GetEntityComponent(entityId, _scoreComponentTypeId);
var data = prevData.Data.FromJsonByteArray<ScoreData>();
data.score = score;
_session.UpdateComponent(_scoreComponentTypeId, entityId, data.ToJsonByteArray());
}

public void SyncShootFx(uint entityId, Vector3 pos, Vector3 hit)
{
var data = new ShootData()
{
StartPos = new SVector3(pos),
EndPos = new SVector3(hit)
}.ToJsonByteArray();
_session.UpdateComponent(_shootFxComponentTypeId, entityId, data);
}
}

Let's add these lines to ParticipantsController.cs:

using Auki.ConjureKit;
using ConjureKitShooter.UI;

As well as these new fields:

[SerializeField] private ParticipantNameUi participantNameUiPrefab;
[SerializeField] private LineRenderer shootFxPrefab;

private IConjureKit _conjureKit;

private readonly Dictionary<uint, ParticipantComponent> _participantComponents = new();
private Dictionary<uint, (string, int)> _scoreCache = new();
private Main _main;
private WaitForSeconds _delay;
private Session _session;
private Transform _camera;

Now we should update the SetListener() method, and add RemoveListener() as well. Instead of passing Main to the SetListener() method like it was previously, now we'll pass ParticipantsSystem to SetListener() so that all participants can listen for score changes using the System and Components:

public void SetListener(ParticipantsSystem participantsSystem)
{
participantsSystem.OnParticipantScores += OnParticipantScores;
participantsSystem.InvokeShootFx += ShowParticipantShootLine;
}

public void RemoveListener(ParticipantsSystem participantsSystem)
{
participantsSystem.OnParticipantScores -= OnParticipantScores;
participantsSystem.InvokeShootFx -= ShowParticipantShootLine;
}

Let's also declare the ShowParticipantShootLine() method so the code above doesn't throw an error:

private void ShowParticipantShootLine(uint id, ShootData data)
{

}

As well as the Initialize() method which will initialize all the needed fields in this controller. It also handles all the prefabs' lifetimes whenever ConjureKit connects to a session and disconnects.

public void Initialize(IConjureKit conjureKit, Transform camera)
{
_conjureKit = conjureKit;
_delay = new WaitForSeconds(0.2f);
_camera = camera;

_conjureKit.OnJoined += session => _session = session;
_conjureKit.OnLeft += session =>
{
_scoreCache.Clear();
foreach (var c in _participantComponents)
{
if (c.Value == null) continue;
Destroy(c.Value.NameUi.gameObject);
Destroy(c.Value.LineRenderer.gameObject);
}
_participantComponents.Clear();
};
}

Next we want to implement a way to get all previous components from ParticipantSystem.cs:

public void GetAllPreviousComponents(ParticipantsSystem participantsSystem)
{
participantsSystem.GetAllScoresComponent(result =>
{
foreach (var ec in result)
{
var data = ec.Data.FromJsonByteArray<ScoreData>();
OnParticipantScores(ec.EntityId, data);
}
});
}

We also need to update OnParticipantJoins() so it will spawn all needed game objects whenever a participant joins:

private void OnParticipantJoins(uint id, string participantName)
{
if (_participantComponents.TryGetValue(id, out var c))
{
c.NameUi.SetName(participantName);
return;
}

var lRenderer = Instantiate(shootFxPrefab, transform);
var nameSign = Instantiate(participantNameUiPrefab, transform);

nameSign.SetName(participantName);

lRenderer.enabled = false;
nameSign.gameObject.SetActive(false);

_participantComponents.Add(id, new ParticipantComponent(lRenderer, nameSign));
_scoreCache.TryAdd(id, (participantName, 0));
}

Next let's modify the OnParticipantScores() method which updates the players' score displays:

private void OnParticipantScores(uint id, ScoreData data)
{
if (!_participantComponents.ContainsKey(id))
{
OnParticipantJoins(id, data.name);
return;
}

_participantComponents[id].Score = data.score;
_participantComponents[id].NameUi.SetName(data.name);
_participantComponents[id].NameUi.SetScore(data.score.ToString("0000000"));
_scoreCache[id] = (data.name, data.score);
}

We also need to declare a method to handle participants leaving the session, so the controller can remove the Components associated with that player. Let's name the method OnParticipantLeft(uint id):

public void OnParticipantLeft(uint id)
{
if (!_participantComponents.ContainsKey(id))
return;

Destroy(_participantComponents[id].LineRenderer.gameObject);
Destroy(_participantComponents[id].NameUi.gameObject);

_participantComponents.Remove(id);
}

Next, create a couple of methods that will handle the Participant's Score Board:

private void UpdateScoreBoardPosition(uint id, Vector3 pos, Vector3 cameraPos)
{
if (!_participantComponents.ContainsKey(id))
return;

var nameSign = _participantComponents[id].NameUi.transform;

nameSign.gameObject.SetActive(true);

var offsetPos = pos + (0.6f * Vector3.up);
var direction = -(cameraPos - offsetPos);

var distance = direction.magnitude;

nameSign.position = offsetPos;
nameSign.rotation = Quaternion.LookRotation(direction);

nameSign.transform.localScale = Mathf.Clamp(distance, 0.2f, 1.2f) * Vector3.one;
}

private void UpdateParticipantsScoreBoard()
{
if (_participantComponents.Count <= 0) return;

foreach (var c in _participantComponents)
{
if (c.Value == null) continue;

var entity = _session.GetEntity(c.Key);

if (entity == null || entity.ParticipantId == _session.ParticipantId)
continue;

var pos = _session.GetEntityPose(c.Key).position;
UpdateScoreBoardPosition(c.Key, pos, _camera.position);
}
}

private void Update()
{
UpdateParticipantsScoreBoard();
}

The UpdateScoreBoardPosition() method will try to find the score board GameObject from the _participantComponents collection, and will reposition that GameObject to the position value that is passed to the method as an argument (with an offset of 60 cm on the Y axis, so it will be above the player), and then align the rotation so it faces the camera.

The UpdateParticipantsScoreBoard() method will loop through all the _participantComponents collection values and try to get the Entity based on the key value of the collection (which is an uint), and then pass the Entity Id, Entity Position, and Camera position on each loop to update each participant's score board positions.

Since ParticipantsController.cs is a Monobehaviour, we can call the UpdateParticipantsScoreBoard() method inside the built-in Update() method. This way it will be evaluated in every frame and the score boards will follow their assigned participants.

Next let's add a way to show each participant's Shoot Line Fx. We'll begin by adding this namespace since it's needed by Coroutines:

using System.Collections;

Then let's update the ShowParticipantShootLine() method and create the Coroutine method which we'll call ShowShootLine():

private void ShowParticipantShootLine(uint id, ShootData data)
{
if (!_participantComponents.ContainsKey(id))
return;

StartCoroutine(ShowShootLine(id, data.StartPos.ToVector3(), data.EndPos.ToVector3()));
}

IEnumerator ShowShootLine(uint id, Vector3 pos, Vector3 hit)
{
var lineRenderer = _participantComponents[id].LineRenderer;
lineRenderer.enabled = true;
lineRenderer.positionCount = 2;
lineRenderer.SetPositions(new[]{pos, hit});

yield return _delay;

lineRenderer.positionCount = 0;
lineRenderer.enabled = false;
}

ShowParticipantShootLine() is already subscribed to the participantSystem.InvokeShootFx callback, so whenever the callback gets invoked, ShowParticipantShootLine() will be notified, and will receive ShootData for the Shoot Fx (which contains the positions for the LineRenderer). With that data, we can invoke the ShowShootLine() coroutine which will show the line for 200ms.

The last thing that we need to implement in ParticipantsController.cs is a way to reset the score, so let's create a new method for it:

public void Restart()
{
foreach (var c in _participantComponents)
{
c.Value.NameUi.SetScore(0.ToString("0000000"));
_scoreCache[c.Key] = (c.Value.NameUi.GetName(), 0);
}
}

We also want to remove the clearing of _scoreCache whenever we are getting the score entries:

public void GetScoreEntries(Action<Dictionary<uint, (string, int)>> valueCallback)
{
...
_scoreCache.Clear(); //remove this line
}

Next we need to update Main.cs so that ParticipantsController.cs gets initialized correctly. There's an error now with SetListener() which we'll fix by replacing the SetListener() call in Start():

private void Start()
{
...

uiManager.OnChangeState += OnChangeGameState;
participantsController.SetListener(this);

...
}

With Initialize():

private void Start()
{
...

uiManager.OnChangeState += OnChangeGameState;
participantsController.Initialize(_conjureKit, arCamera.transform);

...
}

We will also need to initialize the Participants System inside the OnJoined() callback, as well as call participantsController.SetListener(). First let's add a field that will hold the reference to the system:

private ParticipantsSystem _participantsSystem;

Now update the OnJoined() method:

private void OnJoined(Session session)
{
...
_session.RegisterSystem(_hostilesSystem, () =>
{
_hostilesSystem.GetComponentsTypeId();
});

_participantsSystem = new ParticipantsSystem(_session);
_session.RegisterSystem(_participantsSystem, () =>
{
_participantsSystem.GetComponentsTypeId();
participantsController.GetAllPreviousComponents(_participantsSystem);
});

_gameEventController.OnGameStart = GameStart;
_gameEventController.OnGameOver = GameOver;
_participantsSystem.OnParticipantScores += UpdateParticipantsEntity;

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

Next, create an overload method for UpdateParticipantsEntity() that takes uint and ScoreData as arguments, so we can subscribe the method to the ParticipantsSystem's OnParticipantScores event callback. This allows us to update the remaining Participants' entities if there are changes in the Session (e.g. if players leave, etc):

private void UpdateParticipantsEntity(uint id, ScoreData data)
{
UpdateParticipantsEntity();
}

We'll also update the OnLeft() callback:

private void OnLeft(Session lastSession)
{
hostileController.RemoveListener();
participantsController.RemoveListener(_participantsSystem);
_participantsSystem.OnParticipantScores -= UpdateParticipantsEntity;
_participantEntities.Clear();
_spawnedGun.Clear();
GameOver();
_hostilesSystem = null;
_session = null;
}

Now _spawnedGun.Clear() will show an error, but we'll fix that in a moment. For now, let's update the OnEntityDeleted() callback:

private void OnEntityDeleted(uint entityId)
{
participantsController.OnParticipantLeft(entityId);
UpdateParticipantsEntity();

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

We also want to reset the score whenever the game restarts by calling participantsController.Restart(), so let's update the GameStart() method accordingly:

private void GameStart()
{
....
uiManager.ChangeUiState...
participantsController.Restart();
UpdateParticipantsEntity....
}

Next let's update and fix GunScript.cs. First remove the Initialize() call in the Start() method:

private void Start()
{
lineRenderer.positionCount = 0;
lineRenderer.enabled = false;

_audio = GetComponent<AudioSource>();

Initialize(); // Remove
}

Then declare a new field to reference ParticipantsSystem:

private ParticipantsSystem _participantsSystem;

Update the Initialize() method:

public void Initialize(ParticipantsSystem participantsSystem, uint entityId)
{
_participantsSystem = participantsSystem;
_myEntityId = entityId;
_delay = new WaitForSeconds(0.2f);
}

Let's also add a new Clear() method to clear the _participantsSystem value. This step should also fix the error we had previously:

public void Clear()
{
_participantsSystem = null;
}

We'll also update the ShowShootLine() coroutine so it sends a "Shooting Sync Event":

IEnumerator ShowShootLine(uint entityId, Vector3 pos, Vector3 hit)
{
_participantsSystem?.SyncShootFx(entityId, pos, hit);
_audio.PlayRandomPitch(shootSfx, 0.18f);
....
}

Let's go back to Main.cs. We need to update the OnParticipantEntityCreated() callback, but first let's add a field that will hold the current user's device entity:

private Entity _myEntity;

Then update the OnParticipantEntityCreated() method so it caches the user's device entity and initializes the gun. Broadcasting the addition of the Participant Components happens via _participantSystem:

private void OnParticipantEntityCreated(Entity entity)
{
_myEntity = entity;
_spawnedGun.Initialize(_participantsSystem, entity.Id);
_participantsSystem.AddParticipantComponent(entity.Id, _myName);
}

We also want to update the ShootLogic() method in Main.cs so it invokes the Participant System's UpdateParticipantScoreComponent() method. Remove this line:

private void ShootLogic()
{
...

if (hostile.Hit(hitInfo.point))
{
_score += 10;
OnParticipantScore?.Invoke(0, new ScoreData(){name = _myName, score = _score});
uiManager.UpdateScore(_score);
}
...
}

And replace it with this:

private void ShootLogic()
{
...

if (hostile.Hit(hitInfo.point))
{
_score += 10;
_participantsSystem.UpdateParticipantScoreComponent(_myEntity.Id, _score);
uiManager.UpdateScore(_score);
}
...
}
tip

Lastly, we'll need a reconnect mechanism whenever the participant is disconnected from the session. We can do this inside the OnStateChange() callback by running the Connect() method whenever the state is Disconnected:

private void OnStateChange(State state)
{
....

if (state == State.Disconnected)
{
_conjureKit.Connect();
}
}

Now let's go back to Unity Editor. Since we changed ParticipantsController.cs, let's assign a couple of variables in the scene: ParticipantsController

Now the game should be completed, and can be played by multiple participants.

The full project can be found on GitHub.