Keep Score and Update Game UI

Code and workflows to keep score between players in a multiplayer game on a LAN

What you'll develop on this page

The server will set and adjust player scores based on bullet collisions. It will also track the highest score.

Github branch link: https://github.com/moetsi/Unity-DOTS-Multiplayer-XR-Sample/tree/Score-Keeping

How we'll be keeping score

Scoring

1 point for shooting asteroids (as many as you can get through before disappear)

10 points for shooting a player

How the server will keep score

Server will be keeping score by updating ghosted HighScore ghosts and a single HighestScore ghost. When players join, a new object is created.

HighestScore is pre-spawned in ConvertedScene empty (does it have to be prefab?)

PlayerScores are created when a new player joins.

Recycling HighScore ghosts

NCEs re-use network ids, so we will need to keep track of this.

If a player joins the session and it is re-using a network id, we clear that player score

Server creating scores

We are going to start by setting up our HighestScore in ConvertedSubScene.

  • First, let's create the component we will be referencing and updating to keep track of the server's highest score

  • Create HighestScoreComponent in the Mixed/Components folder

  • Paste the code snippet below into HighestScoreComponent.cs:

using Unity.Networking.Transport;
using Unity.NetCode;
using Unity.Burst;
using Unity.Entities;
using Unity.Collections;

[GenerateAuthoringComponent]
public struct HighestScoreComponent : IComponentData
{
    [GhostField]
    public FixedString64Bytes playerName;

    [GhostField]
    public int highestScore;
}
  • Navigate to ConvertedSubScene and create an empty GameObject called HighestScore

  • Add a GhostAuthoringComponent (by clicking Add Component button in Inspector when HighestScore is selected in Hierarchy)

    • Name = HighestScore

    • Importance = 500

    • Supported Ghost Mode = Interpolated

    • Optimization Mode = Static

  • Next add HighestScoreComponent

  • When done with those steps, drag HighestScore GameObject from Hierarchy into Scripts and Prefabs

  • Even though we will not make another HighestScore, NetCode requires that the ghosted GameObject be a prefab

  • Now let's create the PlayerScore ghosts that we will be using to keep track of individual player scores

  • Create PlayerScoreAuthoringComponent in Server/Components

  • Paste the code snippet below into PlayerScoreAuthoringComponent.cs:

using Unity.Entities;

[GenerateAuthoringComponent]
public struct PlayerScoreAuthoringComponent : IComponentData
{
    public Entity Prefab;
}
  • Next let's create the component that will be storing the actual player data named PlayerScoreComponent in the Mixed/Components folder

  • Paste the code snippet below into PlayerScoreComponent.cs:

using Unity.Networking.Transport;
using Unity.NetCode;
using Unity.Burst;
using Unity.Entities;
using Unity.Collections;

[GenerateAuthoringComponent]
public struct PlayerScoreComponent : IComponentData
{
    [GhostField]
    public int networkId;
    [GhostField]
    public FixedString64Bytes playerName;
    [GhostField]
    public int currentScore;
    [GhostField]
    public int highScore;
}
  • Create an empty GameObject called PlayerScore in the Hierarchy (doesn't matter which one)

  • Add PlayerScoreComponent to PlayerScore

  • Add GhostAuthoringComponent

    • Name = PlayerScore

    • Importance = 500

    • Supported Ghost Modes = Interpolated

    • Optimization Mode = Static

  • Drag it into Scripts and Prefabs

  • Delete it from the hierarchy

  • Navigate to ConvertedSubScene and add PlayerScoreAuthoringComponent to the PrefabCollection GameObject

  • Drag the PlayerScore prefab from the Scripts and Prefabs folder into the "Prefab" field in the Player Score Authoring Component in Inspector when PrefabCollection is selected in Hierarchy

  • Now we have our ghosts ready

    • HighestScore is part of the ConvertedSubScene

    • PlayerScore is referenced as part of PrefabCollection

We are going to kick off the process of setting up scores by adding a new RPC to be sent in ClientLoadGameSystem.

We could add this flow to existing flows that are kicked off by ClientLoadGameSystem (like SendServerGameLoadedRpc), but we are going to keep it separate to keep this flow concerned only with score setup.

  • Let's create SendServerPlayerNameRpc in the Mixed/Commands folder

  • Paste the code snippet below into SendServerPlayerNameRpc.cs:

using AOT;
using Unity.Burst;
using Unity.Networking.Transport;
using Unity.NetCode;
using Unity.Entities;
using Unity.Collections;
using System.Collections;
using System;

public struct SendServerPlayerNameRpc : IRpcCommand
{
    public FixedString64Bytes playerName;
}
  • Now let's update ClientLoadGameSystem to send this RPC as part of the game loading process

  • Paste the code snippet below into ClientLoadGameSystem.cs:

using Unity.Entities;
using Unity.NetCode;
using UnityEngine;

//This will only run on the client because it updates in ClientSimulationSystemGroup (which the server does not have)
[UpdateInGroup(typeof(ClientSimulationSystemGroup))]
[UpdateBefore(typeof(RpcSystem))]
public partial class ClientLoadGameSystem : SystemBase
{
    private BeginSimulationEntityCommandBufferSystem m_BeginSimEcb;

    protected override void OnCreate()
    {
        //We will be using the BeginSimECB
        m_BeginSimEcb = World.GetOrCreateSystem<BeginSimulationEntityCommandBufferSystem>();

        //Requiring the ReceiveRpcCommandRequestComponent ensures that update is only run when an NCE exists and a SendClientGameRpc has come in
        RequireForUpdate(GetEntityQuery(ComponentType.ReadOnly<SendClientGameRpc>(), ComponentType.ReadOnly<ReceiveRpcCommandRequestComponent>()));   
        //This is just here to make sure the Sub Scene is streamed in before the client sets up the level data
        RequireSingletonForUpdate<GameSettingsComponent>();
        //We will make sure we have our ClientDataComponent so we can send the server our player name
        RequireSingletonForUpdate<ClientDataComponent>();
    }

    protected override void OnUpdate()
    {

        //We must declare our local variables before using them within a job (.ForEach)
        var commandBuffer = m_BeginSimEcb.CreateCommandBuffer();
        var rpcFromEntity = GetBufferFromEntity<OutgoingRpcDataStreamBufferComponent>();
        var gameSettingsEntity = GetSingletonEntity<GameSettingsComponent>();
        var getGameSettingsComponentData = GetComponentDataFromEntity<GameSettingsComponent>();
        var clientData = GetSingleton<ClientDataComponent>(); //We will use this to send the player name to server

        Entities
        .ForEach((Entity entity, in SendClientGameRpc request, in ReceiveRpcCommandRequestComponent requestSource) =>
        {
            //This destroys the incoming RPC so the code is only run once
            commandBuffer.DestroyEntity(entity);

            //Check for disconnects before moving forward
            if (!rpcFromEntity.HasComponent(requestSource.SourceConnection))
                return;

            //Set the game size (unnecessary right now but we are including it to show how it is done)
            getGameSettingsComponentData[gameSettingsEntity] = new GameSettingsComponent
            {
                levelWidth = request.levelWidth,
                levelHeight = request.levelHeight,
                levelDepth = request.levelDepth,
                playerForce = request.playerForce,
                bulletVelocity = request.bulletVelocity
            };


            //Here we create a new singleton entity for GameNameComponent
            //We could add this component to the singleton entity that has the GameSettingsComponent
            //but we will keep them separate in case we want to change workflows in the future and don't
            //want these components to be dependent on the same entity
            var gameNameEntity= commandBuffer.CreateEntity();
            commandBuffer.AddComponent(gameNameEntity, new GameNameComponent {
                GameName = request.gameName
            });

            //These update the NCE with NetworkStreamInGame (required to start receiving snapshots) and
            //PlayerSpawningStateComponent, which we will use when we spawn players
            commandBuffer.AddComponent(requestSource.SourceConnection, new PlayerSpawningStateComponent());
            commandBuffer.AddComponent(requestSource.SourceConnection, default(NetworkStreamInGame));
            
            //This tells the server "I loaded the level"
            //First we create an entity called levelReq that will have 2 necessary components
            //Next we add the RPC we want to send (SendServerGameLoadedRpc) and then we add
            //SendRpcCommandRequestComponent with our TargetConnection being the NCE with the server (which will send it to the server)
            var levelReq = commandBuffer.CreateEntity();
            commandBuffer.AddComponent(levelReq, new SendServerGameLoadedRpc());
            commandBuffer.AddComponent(levelReq, new SendRpcCommandRequestComponent {TargetConnection = requestSource.SourceConnection});

            // this tells the server "This is my name and Id" which will be used for player score tracking
            var playerReq = commandBuffer.CreateEntity();
            commandBuffer.AddComponent(playerReq, new SendServerPlayerNameRpc {playerName = clientData.PlayerName});
            commandBuffer.AddComponent(playerReq, new SendRpcCommandRequestComponent {TargetConnection = requestSource.SourceConnection});

        }).Schedule();

        m_BeginSimEcb.AddJobHandleForProducer(Dependency);
    }
}
  • Although there will be only one HighestScore entity, there will be a PlayerScore created for each unique NCE network id value

    • Remember that NetCode recycles network id's once players have disconnected

    • So a player may have its score tracked during gameplay, then leave the game, and then a new player may join with that same network id (i.e. it's been 'recycled')

    • So we have to set up a process that checks our existing PlayerScores network id values to see if they match the new SendServerPlayerNameRpc's NCE Network id

      • If they do match, we will reset that PlayerScore to 0 and update it with the sent RPCs provided name

  • Create SetupScoreSystem in the Server/Systems folder

  • Paste the code snippet below into SetupScoreSystem.cs:

using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine;
using Unity.Burst;
using Unity.Jobs;

//The server will be keeping score
//The client will only read the scores and update overlay
[UpdateInGroup(typeof(ServerSimulationSystemGroup))]
public partial class SetupScoreSystem : SystemBase
{
    //We will be making structural changes so we need a command buffer
    private BeginSimulationEntityCommandBufferSystem m_BeginSimEcb;

    //This will be the query for the highest score
    private EntityQuery m_HighestScoreQuery;

    //This will be the query for the player scores
    private EntityQuery m_PlayerScoresQuery;

    //This will be the prefab used to create PlayerScores
    private Entity m_Prefab;

    protected override void OnCreate()
    {
        //We set the command buffer
        m_BeginSimEcb = World.GetOrCreateSystem<BeginSimulationEntityCommandBufferSystem>();

        //This will be used to check if there is already HighestScore (initialization)
        m_HighestScoreQuery = EntityManager.CreateEntityQuery(ComponentType.ReadOnly<HighestScoreComponent>());

        //This will be used to check if there are already PlayerScores (initialization)
        m_PlayerScoresQuery = EntityManager.CreateEntityQuery(ComponentType.ReadOnly<PlayerScoreComponent>());

        //We are going to wait to initialize and update until the first player connects and sends their name
        RequireForUpdate(GetEntityQuery(ComponentType.ReadOnly<SendServerPlayerNameRpc>(), ComponentType.ReadOnly<ReceiveRpcCommandRequestComponent>()));        
    }

    protected override void OnUpdate()
    {
        //Here we set the prefab we will use
        if (m_Prefab == Entity.Null)
        {
            //We grab the converted PrefabCollection Entity's PlayerScoreAuthoringComponent
            //and set m_Prefab to its Prefab value
            m_Prefab = GetSingleton<PlayerScoreAuthoringComponent>().Prefab;
            //We then initialize by creating the first PlayerScore
            var initialPlayerScore = EntityManager.Instantiate(m_Prefab);
            //We set the initial player score to 1 so the first player will be assigned this PlayerScore
            EntityManager.SetComponentData<PlayerScoreComponent>(initialPlayerScore, new PlayerScoreComponent{
                networkId = 1
            });
            //we must "return" after setting this prefab because if we were to continue into the Job
            //we would run into errors because the variable was JUST set (ECS funny business)
            //comment out return and see the error
            return;
        }
        
        //We need to declare our local variables before the .ForEach()
        var commandBuffer = m_BeginSimEcb.CreateCommandBuffer();
        //We use this to check for disconnects
        var rpcFromEntity = GetBufferFromEntity<OutgoingRpcDataStreamBufferComponent>();
        //We are going to grab all existing player scores because we need to check if the new player has an old NetworkId
        var currentPlayerScoreEntities = m_PlayerScoresQuery.ToEntityArray(Allocator.TempJob);
        //We are going to need to grab the Player score from the entity
        var playerScoreComponent = GetComponentDataFromEntity<PlayerScoreComponent>();
        //We grab the prefab in case we need to create a new PlayerScore for a new NetworkId
        var scorePrefab = m_Prefab;
        //We are going to need to be able to grab the NetworkIdComponent from the RPC source to know what the player's NetworkId is
        var networkIdFromEntity = GetComponentDataFromEntity<NetworkIdComponent>();
        
        Entities
        .WithDisposeOnCompletion(currentPlayerScoreEntities)
        .ForEach((Entity entity, in SendServerPlayerNameRpc request, in ReceiveRpcCommandRequestComponent requestSource) =>
        {
            //Delete the rpc
            commandBuffer.DestroyEntity(entity);
            
            //Check for disconnects
            if (!rpcFromEntity.HasComponent(requestSource.SourceConnection))
                return;

            //Grab the NetworkIdComponent's Value
            var newPlayersNetworkId = networkIdFromEntity[requestSource.SourceConnection].Value;

            //We create a clean PlayerScore component with the player's name and the player's NetworkId value
            var newPlayerScore = new PlayerScoreComponent{
                networkId = newPlayersNetworkId,
                playerName = request.playerName,
                currentScore = 0,
                highScore = 0
            };

            //Now we are going to check all current PlayerScores and see if this NetworkId has been used before
            //If it has we set it to our new PlayerScoreComponent
            bool uniqueNetworkId = true;
            for (int i = 0; i < currentPlayerScoreEntities.Length; i++)
            {
                //We call the data componentData just to make it more legible on the if() line
                var componentData = playerScoreComponent[currentPlayerScoreEntities[i]];
                if(componentData.networkId == newPlayersNetworkId)
                {
                    commandBuffer.SetComponent<PlayerScoreComponent>(currentPlayerScoreEntities[i], newPlayerScore);
                    uniqueNetworkId = false;
                }
                
            }
            //If this NetworkId has not been used before we create a new PlayerScore
            if (uniqueNetworkId)
            {
                var playerScoreEntity = commandBuffer.Instantiate(scorePrefab);
                //We set the initial player score to 1 so the first player will be assigned this PlayerScore
                commandBuffer.SetComponent<PlayerScoreComponent>(playerScoreEntity, newPlayerScore);
            }
            
        }).Schedule();
    }
}
  • Let's get back to NavigationScene, hit Play, click Host a Game, and then go to DOTS Windows

  • Check out the ClientWorld (by selecting the drop down menu in the top left that currently has "Default World" selected, then choose ClientWorld)

    • Find our PlayerScore and HighestScore components

  • Great! We see that PlayerScore and HighestScore were created and that PlayerScore is updated to the players name

We have set up scoring

  • We created HighestScoreComponent

  • We created HighestScore prefab

  • We created PlayerScoreComponent

  • We created PlayerScoreAuthoringComponent

  • We created PlayerScore prefab

  • We created SendServerPlayerNameRpc

  • We updated ClientLoadGameSystem

  • We created SetupScoreSystem

Getting the Server to update scores

We are going to use a similar approach for updating scores as the one we used in ChangeMaterialAndDestroySystem when we added DestroyTags to entities bullets that had collisions.

When we have an OnEnter collision we will do analysis of who the bullet owner is and what it collided with. Based on the results we will update the bullet owner's PlayerScore and possibly the HighestScore.

  • Let's create AdjustScoresFromBulletCollisionsSystem in the Server/Systems folder

  • Paste the code snippet below into AdjustScoresFromBulletCollisionsSystem.cs:

using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Physics.Stateful;
using UnityEngine;
using Unity.NetCode;


[UpdateInGroup(typeof(ServerSimulationSystemGroup))]
public partial class AdjustPlayerScoresFromBulletCollisionSystem : SystemBase
{
    private EndSimulationEntityCommandBufferSystem m_CommandBufferSystem;
    private StatefulTriggerEventBufferSystem m_TriggerSystem;
    private EntityQueryMask m_NonTriggerMask;
    private EntityQuery m_PlayerScores;
    private EntityQuery m_HighestScore;

    protected override void OnCreate()
    {
        m_CommandBufferSystem = World.GetOrCreateSystem<EndSimulationEntityCommandBufferSystem>();
        m_TriggerSystem = World.GetOrCreateSystem<StatefulTriggerEventBufferSystem>();
        
        m_NonTriggerMask = EntityManager.GetEntityQueryMask(
            GetEntityQuery(new EntityQueryDesc
            {
                None = new ComponentType[]
                {
                    typeof(StatefulTriggerEvent)
                }
            })
        );
        //We set our queries
        m_PlayerScores = GetEntityQuery(ComponentType.ReadWrite<PlayerScoreComponent>());
        m_HighestScore = GetEntityQuery(ComponentType.ReadWrite<HighestScoreComponent>());
        //We wait to update until we have our converted entities
        RequireForUpdate(m_PlayerScores);
        RequireForUpdate(m_HighestScore);
    }

    protected override void OnUpdate()
    {
        // Need this extra variable here so that it can
        // be captured by Entities.ForEach loop below
        var nonTriggerMask = m_NonTriggerMask;

        //We grab all the player scores because we don't know who will need to be assigned points
        var playerScoreEntities = m_PlayerScores.ToEntityArray(Allocator.TempJob);
        //we will need to grab the PlayerScoreComponent from our player score entities to compare values
        var playerScoreComponent = GetComponentDataFromEntity<PlayerScoreComponent>();

        //We grab the 1 HighestScore engity
        var highestScoreEntities = m_HighestScore.ToEntityArray(Allocator.TempJob);
        //We will need to grab the HighestScoreComponent from our highest score entity to compare values
        var highestScoreComponent = GetComponentDataFromEntity<HighestScoreComponent>();

        //We are going to use this to pull the GhostOwnerComponent from the bullets to see who they belong to
        var ghostOwner = GetComponentDataFromEntity<GhostOwnerComponent>();
        
        //We need to dispose our entities
        Entities
        .WithDisposeOnCompletion(playerScoreEntities)
        .WithDisposeOnCompletion(highestScoreEntities)
        .WithName("ChangeMaterialOnTriggerEnter")
        .ForEach((Entity e, ref DynamicBuffer<StatefulTriggerEvent> triggerEventBuffer) =>
        {
            for (int i = 0; i < triggerEventBuffer.Length; i++)
            {
                //Here we grab our bullet entity and the other entity it collided with
                var triggerEvent = triggerEventBuffer[i];
                var otherEntity = triggerEvent.GetOtherEntity(e); 

                // exclude other triggers and processed events
                if (triggerEvent.State == StatefulEventState.Stay || !nonTriggerMask.Matches(otherEntity))
                {
                    continue;
                }

                //We want our code to run on the first intersection of Bullet and other entity
                else if (triggerEvent.State == StatefulEventState.Enter)
                {

                    //We grab the NetworkId of the bullet so we know who to assign points to
                    var bulletsPlayerNetworkId = ghostOwner[e].NetworkId;

                    //We start with 0 points to add
                    int pointsToAdd = 0;
                    if (HasComponent<PlayerTag>(otherEntity))
                    {
                        //Now we check if the bullet came from the same player
                        if (ghostOwner[otherEntity].NetworkId == bulletsPlayerNetworkId)
                        {
                            //If it is from the same player no points
                            return;
                        }
                        pointsToAdd += 10;
                    }

                    if (HasComponent<AsteroidTag>(otherEntity))
                    {
                        //Bullet hitting an Asteroid is 1 point
                        pointsToAdd += 1;
                    }
                    
                    //After updating the points to add we check the PlayerScore entities and find the one with the
                    //correct NetworkId so we can update the scores for the PlayerScoreComponent
                    //If the updated score is higher than the highest score it updates the highest score
                    for (int j = 0; j < playerScoreEntities.Length; j++)
                    {
                        //Grab the PlayerScore
                        var  currentPlayScoreComponent = playerScoreComponent[playerScoreEntities[j]];
                        if(currentPlayScoreComponent.networkId == bulletsPlayerNetworkId)
                        {
                            //We create a new component with updated values
                            var newPlayerScore = new PlayerScoreComponent{
                                networkId = currentPlayScoreComponent.networkId,
                                playerName = currentPlayScoreComponent.playerName,
                                currentScore = currentPlayScoreComponent.currentScore + pointsToAdd,
                                highScore = currentPlayScoreComponent.highScore
                                };
                            //Here we check if the player beat their own high score
                            if (newPlayerScore.currentScore > newPlayerScore.highScore)
                            {
                                newPlayerScore.highScore = newPlayerScore.currentScore;
                            }

                            //Here we check if the player beat the highest score
                            var currentHighScore = highestScoreComponent[highestScoreEntities[0]];
                            if (newPlayerScore.highScore > currentHighScore.highestScore)
                            {
                                //If it does we make a new HighestScoreComponent
                                var updatedHighestScore = new HighestScoreComponent {
                                    highestScore = newPlayerScore.highScore,
                                    playerName = newPlayerScore.playerName
                                };

                                //The reason why we don't go with:
                                //SetComponent<HighestScoreComponent>(highestScoreEntities[0],  updatedHighestScore);
                                //is because SetComponent<HighestScoreComponent>() gets codegen'd into ComponentDataFromEntity<HighestScoreComponent>()
                                //and you can't use 2 different ones or else you get an 'two containers may not be the same (aliasing)' error
                                highestScoreComponent[highestScoreEntities[0]] = updatedHighestScore;
                            }
                            // SetComponent<PlayerScoreComponent>(playerScoreEntities[j], newPlayerScore);
                            //The reason why we don't go with:
                            //SetComponent<PlayerScoreComponent>(playerScoreEntities[j],  newPlayerScore);
                            //is because SetComponent<PlayerScoreComponent>() gets codegen'd into ComponentDataFromEntity<PlayerScoreComponent>()
                            //and you can't use 2 different ones or else you get an 'two containers may not be the same (aliasing)' error
                            playerScoreComponent[playerScoreEntities[j]] = newPlayerScore;
                        }
                    }
                }
                else
                {
                    continue;
                }
            }
        }).Schedule();
    }
}
  • Okay once saved, let's hit play, host game, shoot around to destroy a couple asteroids, then head back to DOTS Windows to check out ClientWorld again to make sure that our PlayerScore and HighestScore are updating

  • Great! They are updating

  • Now let's hit "p" to self-destruct

  • Checkout the PlayerScore

  • Uh oh, the CurrentScore didn't go to 0 even though we self-destructed as a player

  • Let's update PlayerDestructionSystem to also reset the players score to 0

  • Paste the code snippet below into PlayerDestructionSystem.cs:

using Unity.Entities;
using Unity.Jobs;
using Unity.NetCode;
using Unity.Collections;

//We are going to update LATE once all other systems are complete
//because we don't want to destroy the Entity before other systems have
//had a chance to interact with it if they need to
[UpdateInWorld(TargetWorld.Server)]
[UpdateInGroup(typeof(LateSimulationSystemGroup))]
public partial class PlayerDestructionSystem : SystemBase
{
    private EndSimulationEntityCommandBufferSystem m_EndSimEcb;    

    private EntityQuery m_PlayerScores;
    private EntityQuery m_HighestScore;

    protected override void OnCreate()
    {
        //We grab the EndSimulationEntityCommandBufferSystem to record our structural changes
        m_EndSimEcb = World.GetOrCreateSystem<EndSimulationEntityCommandBufferSystem>();

        //We set our queries
        m_PlayerScores = GetEntityQuery(ComponentType.ReadWrite<PlayerScoreComponent>());
        m_HighestScore = GetEntityQuery(ComponentType.ReadWrite<HighestScoreComponent>());
    }
    
    protected override void OnUpdate()
    {
        //We add "AsParallelWriter" when we create our command buffer because we want
        //to run our jobs in parallel
        var commandBuffer = m_EndSimEcb.CreateCommandBuffer().AsParallelWriter();

        //We are going to need to update the NCE CommandTargetComponent so we set the argument to false (not read-only)
        var commandTargetFromEntity = GetComponentDataFromEntity<CommandTargetComponent>(false);

        JobHandle playerScoresDep;
        //We grab all the player scores because we don't know who will need to be assigned points
        var playerScoreEntities = m_PlayerScores.ToEntityArrayAsync(Allocator.TempJob, out playerScoresDep);
        //we will need to grab the PlayerScoreComponent from our player score entities to compare values
        var playerScoreComponent = GetComponentDataFromEntity<PlayerScoreComponent>();


        //We now any entities with a DestroyTag and an PlayerTag
        //We could just query for a DestroyTag, but we might want to run different processes
        //if different entities are destroyed, so we made this one specifically for Players
        //We query specifically for players because we need to clear the NCE when they are destroyed
        //In order to write over a variable that we pass through to a job we must include "WithNativeDisableParallelForRestricion"
        //It means "yes we know what we are doing, allow us to write over this variable"
        var playerDestructionJob = Entities
        .WithDisposeOnCompletion(playerScoreEntities)
        .WithReadOnly(playerScoreEntities)
        .WithNativeDisableParallelForRestriction(playerScoreComponent)
        .WithNativeDisableParallelForRestriction(commandTargetFromEntity)
        .WithAll<DestroyTag, PlayerTag>()
        .ForEach((Entity entity, int nativeThreadIndex, in PlayerEntityComponent playerEntity, in GhostOwnerComponent ghostOwnerComponent) =>
        {
            // Reset the CommandTargetComponent on the Network Connection Entity to the player
            //We are able to find the NCE the player belongs to through the PlayerEntity component
            var state = commandTargetFromEntity[playerEntity.PlayerEntity]; 
            state.targetEntity = Entity.Null;
            commandTargetFromEntity[playerEntity.PlayerEntity] = state;

            //Now we cycle through PlayerScores till we find the right onw
            for (int j = 0; j < playerScoreEntities.Length; j++)
            {
                //Grab the PlayerScore
                var  currentPlayScoreComponent = playerScoreComponent[playerScoreEntities[j]];
                //Check if the player to destroy has the same NetworkId as the current PlayerScore
                if(currentPlayScoreComponent.networkId == ghostOwnerComponent.NetworkId)
                {
                    //We create a new component with updated values
                    var newPlayerScore = new PlayerScoreComponent{
                        networkId = currentPlayScoreComponent.networkId,
                        playerName = currentPlayScoreComponent.playerName,
                        currentScore = 0,
                        highScore = currentPlayScoreComponent.highScore
                        };
                    // SetComponent<PlayerScoreComponent>(playerScoreEntities[j], newPlayerScore);
                    //The reason why we don't go with:
                    //SetComponent<PlayerScoreComponent>(playerScoreEntities[j],  newPlayerScore);
                    //is because SetComponent<PlayerScoreComponent>() gets codegen'd into ComponentDataFromEntity<PlayerScoreComponent>()
                    //and you can't use 2 different ones or else you get an 'two containers may not be the same (aliasing)' error
                    playerScoreComponent[playerScoreEntities[j]] = newPlayerScore;
                }
            }
            //Then destroy the entity
            commandBuffer.DestroyEntity(nativeThreadIndex, entity);

        }).ScheduleParallel(JobHandle.CombineDependencies(Dependency, playerScoresDep));

        //We set the system dependency
        Dependency = playerDestructionJob;
        //We then add the dependencies of these jobs to the EndSimulationEntityCOmmandBufferSystem
        //that will be playing back the structural changes recorded in this sytem
        m_EndSimEcb.AddJobHandleForProducer(Dependency);
    
    }
}
  • Once that's updated, hit Play, host game, shoot around again, destroy some asteroids, hit "p" to self-destruct and checkout our PlayerScoreComponent

We now have our scores updating based on bullet collisions

  • We created AdjustScoresFromBulletCollisionsSystem

  • We updated PlayerDestructionSystem

Update Game UI With UI Toolkit Data Binding

Now that we have our ghosted PlayerScores and HighestScore our client can access them to update their Game UI.

The server calls the shots so the logic we use will be simple. If your UI values do not equal ghost values, change your UI values to the ghost values.

Let's update GameOverlayUpdater to including querying for the PlayerScores and HighestScore.

  • Paste the code snippet below into GameOverlayUpdater.cs:

using UnityEngine;
using UnityEngine.UIElements;
using Unity.Entities;
using Unity.NetCode;
using Unity.Collections;
public class GameOverlayUpdater : MonoBehaviour
{
    //This is how we will grab access to the UI elements we need to update
    public UIDocument m_GameUIDocument;
    private VisualElement m_GameManagerUIVE;
    private Label m_GameName;
    private Label m_GameIp;
    private Label m_PlayerName;
    private Label m_CurrentScoreText;
    private Label m_HighScoreText;
    private Label m_HighestScoreText;
    //We will need ClientServerInfo to update our VisualElements with appropriate values
    public ClientServerInfo ClientServerInfo;
    private World m_ClientWorld;
    private ClientSimulationSystemGroup m_ClientWorldSimulationSystemGroup;
    //Will check for GameNameComponent
    private EntityQuery m_GameNameComponentQuery;
    private bool gameNameIsSet = false;
    //We need the PlayerScores and HighestScore as well as our NetworkId
    //We are going to set our NetworkId and then query the ghosts for the PlayerScore entity associated with us
    private EntityQuery m_NetworkConnectionEntityQuery;
    private EntityQuery m_PlayerScoresQuery;
    private EntityQuery m_HighestScoreQuery;
    private bool networkIdIsSet = false;
    private int m_NetworkId;
    private Entity ClientPlayerScoreEntity;
    public int m_CurrentScore;
    public int m_HighScore;
    public int m_HighestScore;
    public string m_HighestScoreName;
    void OnEnable()
    {
        //We set the labels that we will need to update
        m_GameManagerUIVE = m_GameUIDocument.rootVisualElement;
        m_GameName = m_GameManagerUIVE.Q<Label>("game-name");
        m_GameIp = m_GameManagerUIVE.Q<Label>("game-ip");
        m_PlayerName = m_GameManagerUIVE.Q<Label>("player-name");
        //Scores will be updated in a future section
        m_CurrentScoreText = m_GameManagerUIVE.Q<Label>("current-score");
        m_HighScoreText = m_GameManagerUIVE.Q<Label>("high-score");
        m_HighestScoreText = m_GameManagerUIVE.Q<Label>("highest-score");
    }
    // Start is called before the first frame update
    void Start()
    {
        //We set the initial client data we already have as part of ClientDataComponent
        m_GameIp.text = ClientServerInfo.ConnectToServerIp;
        m_PlayerName.text = ClientServerInfo.PlayerName;
        //If it is not the client, stop running this script (unnecessary)
        if (!ClientServerInfo.IsClient)
        {
            this.enabled = false;         
        }
        //Now we search for the client world and the client simulation system group
        //so we can communicated with ECS in this MonoBehaviour
        foreach (var world in World.All)
        {
            if (world.GetExistingSystem<ClientSimulationSystemGroup>() != null)
            {
                m_ClientWorld = world;
                m_ClientWorldSimulationSystemGroup = world.GetExistingSystem<ClientSimulationSystemGroup>();
                m_GameNameComponentQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<GameNameComponent>());
                //Grabbing the queries we need for updating scores
                m_NetworkConnectionEntityQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<NetworkIdComponent>());
                m_PlayerScoresQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<PlayerScoreComponent>());
                m_HighestScoreQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<HighestScoreComponent>());
            }
        }
    }
    // Update is called once per frame
    void Update()
    {
        //We do not need to continue if we do not have a GameNameComponent yet
        if(m_GameNameComponentQuery.IsEmptyIgnoreFilter)
            return;
        //If we have a GameNameComponent we need to update ClientServerInfo and then our UI
        //We only need to do this once so we have a boolean flag to prevent this from being ran more than once
        if(!gameNameIsSet)
        {
                ClientServerInfo.GameName = m_ClientWorldSimulationSystemGroup.GetSingleton<GameNameComponent>().GameName.ToString();
                m_GameName.text = ClientServerInfo.GameName;
                gameNameIsSet = true;
        }
        //Now we will handle updating scoring
        //We check if the scoring entities exist, otherwise why bother
        if(m_NetworkConnectionEntityQuery.IsEmptyIgnoreFilter || m_PlayerScoresQuery.IsEmptyIgnoreFilter || m_HighestScoreQuery.IsEmptyIgnoreFilter)
            return;
        //We set our NetworkId once
        if(!networkIdIsSet)
        {
            m_NetworkId = m_ClientWorldSimulationSystemGroup.GetSingleton<NetworkIdComponent>().Value;
            networkIdIsSet = true;
        }
        //Grab PlayerScore entities
        var playerScoresNative = m_PlayerScoresQuery.ToEntityArray(Allocator.TempJob);
        //For each entity find the entity with a matching NetworkId
        for (int j = 0; j < playerScoresNative.Length; j++)
        {
            //Grab the NetworkId of the PlayerScore entity
            var netId = m_ClientWorldSimulationSystemGroup.GetComponentDataFromEntity<PlayerScoreComponent>(true)[playerScoresNative[j]].networkId;
            //Check if it matches our NetworkId that we set
            if(netId == m_NetworkId)
            {
                //If it matches set our ClientPlayerScoreEntity
                ClientPlayerScoreEntity = playerScoresNative[j];
            }
        }
        //No need for this anymore
        playerScoresNative.Dispose();
        //Every Update() we get grab the PlayerScoreComponent from our set Entity and check it out with current values
        var playerScoreComponent = m_ClientWorldSimulationSystemGroup.GetComponentDataFromEntity<PlayerScoreComponent>(true)[ClientPlayerScoreEntity];
        //Check if current is different and update to ghost value
        if(m_CurrentScore != playerScoreComponent.currentScore)
        {
            //If it is make it match the ghost value
            m_CurrentScore = playerScoreComponent.currentScore;
            UpdateCurrentScore();
        }
        //Check if current is different and update to ghost value
        if(m_HighScore != playerScoreComponent.highScore)
        {
            //If it is make it match the ghost value
            m_HighScore = playerScoreComponent.highScore;
            UpdateHighScore();
        }            
        //We grab our HighestScoreComponent
        var highestScoreNative = m_HighestScoreQuery.ToComponentDataArray<HighestScoreComponent>(Allocator.TempJob);
        //We check if its current  value is different than ghost value
        if(highestScoreNative[0].highestScore != m_HighestScore)
        {
            //If it is make it match the ghost value
            m_HighestScore = highestScoreNative[0].highestScore;
            m_HighestScoreName = highestScoreNative[0].playerName.ToString();
            UpdateHighestScore();
        }
        highestScoreNative.Dispose();
    }
    void UpdateCurrentScore()
    {
        m_CurrentScoreText.text = m_CurrentScore.ToString();
    }
    void UpdateHighScore()
    {
        m_HighScoreText.text = m_HighScore.ToString();
    }
    void UpdateHighestScore()
    {
        m_HighestScoreText.text = m_HighestScoreName.ToString() + " - " + m_HighestScore.ToString();
    }
}
  • Now hit play, host game, and shoot around at the asteroids and see your score increase

Warning! This might not work as expected if there are thin-clients, since the UI-Code might find the score entity of one of the thin clients instead of the actual player.

  • Hit "p" to self-destruct and start again and see the score reset to 0

Our game UI updates based on updated ghost values

  • We updated GameOverlayUpdater

Github branch link:

git clone https://github.com/moetsi/Unity-DOTS-Multiplayer-XR-Sample/ git checkout 'Score-Keeping'

Last updated