Send Ghosts with NetCode Using Relevancy

Code and workflows to send ghosts and clean up logs using GhostRelevancyMode

What you'll develop on this page

We will implement a system where only the ghosts near a player are sent. We will also clean some unnecessary logs.

Github branch link: https://github.com/moetsi/Unity-DOTS-Multiplayer-XR-Sample/tree/GhostRelevancyMode-and-Clean-Up

Ghost Relevancy Sphere

Network transmission

So far we've been working with 200 Asteroids. This means our server sends updates for 200 entities, which is a lot of data streams for SnapShots.

Because data and bandwidth are limited it is important to be mindful of what updates are sent from the server to the client. You should always think: is the most important stuff getting to the client?

The [GhostField] attributes on our ghost IComponentData is the data that gets sent over between clients and server through SnapShots. Dynamic ghosts automatically have their rotation and translation sent over (clients can also send data through RPCs and Commands).

If you remember GhostAuthoringComponent it is possible to optimize ghosts to be "static" (like our HighestScore and PlayerScore). This means the server does not send updates on Translation and Rotation (because they are static).

Our Asteroid, Player, and Bullet prefabs all are dynamic so they send updates of their Rotation and Translation.

Think of a super large map with many ghosted objects-- do you think it's important for the client to get all of the snapshot data of objects that are way across the map? Probably not. It is not efficient for the player to receive the Translation and Rotation SnapShot updates of entities that will never ever encounter the player. That would be an inefficient use of networking.

  • Try it out - go to ConvertedSubScene, then the GameSettings GameObject, and change the Number of Asteroids to 2000

  • Next, change the Level Size to 100x100x100

  • Then press Play, Host a game and take a look at the Asteroids

The client is having a tough time getting enough SnapShot updates to make the asteroids appear to be moving smoothly.

Ghost Relevancy

We are going to use the concept of a Player Relevancy Sphere so that only ghosts that are within a certain radius of the player will be sent to the player.

The server will check for the relevancyRadius field in GameSettingsComponent in PlayerRelevancySphereSystem. If it exists, it will take note of the position of each client and only send ghosts within that distance.

  • Let's update GameSettingComponent to have an additional field, relevancyRadius

  • Paste the code snippet below into GameSettingsComponent.cs:

using Unity.Entities;

public struct GameSettingsComponent : IComponentData
{
    public float asteroidVelocity;
    public float playerForce;
    public float bulletVelocity;
    public int numAsteroids;
    public int levelWidth;
    public int levelHeight;
    public int levelDepth;
    public float relevancyRadius;
}
  • Now we must also update SetGameSettingsSystem to pass through this new field

  • Paste the code snippet below into SetGameSettingsSystem.cs:

using Unity.Entities;
using Unity.Mathematics;
using UnityEngine;

public class SetGameSettingsSystem : UnityEngine.MonoBehaviour, IConvertGameObjectToEntity
{
    public float asteroidVelocity = 10f;
    public float playerForce = 50f;
    public float bulletVelocity = 500f;
    public int numAsteroids = 200;
    public int levelWidth = 2048;
    public int levelHeight = 2048;
    public int levelDepth = 2048;
    public int relevencyRadius = 0;
    public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
    {
        var settings = default(GameSettingsComponent);
        settings.asteroidVelocity = asteroidVelocity;
        settings.playerForce = playerForce;
        settings.bulletVelocity = bulletVelocity;
        settings.numAsteroids = numAsteroids;
        settings.levelWidth = levelWidth;
        settings.levelHeight = levelHeight;
        settings.levelDepth = levelDepth;
        settings.relevancyRadius = relevencyRadius;
        dstManager.AddComponentData(entity, settings);
    }
}
  • Next, create a new System inside the Server/Systems folder and name it PlayerRelevancySphereSystem

  • Paste the code snippet below into PlayerRelevancySphereSystem.cs:

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

[UpdateInGroup(typeof(ServerSimulationSystemGroup))]
[UpdateBefore(typeof(GhostSendSystem))]
public partial class PlayerRelevancySphereSystem : SystemBase
{
    //This will be a struct we use only in this system
    //The ConnectionId is the entities NetworkId
    struct ConnectionRelevancy
    {
        public int ConnectionId;
        public float3 Position;
    }
    //We grab the ghost send system to use its GhostRelevancyMode
    GhostSendSystem m_GhostSendSystem;
    //Here we keep a list of our NCEs with NetworkId and position of player
    NativeList<ConnectionRelevancy> m_Connections;
    EntityQuery m_GhostQuery;
    EntityQuery m_ConnectionQuery;
    protected override void OnCreate()
    {
        m_GhostQuery = GetEntityQuery(ComponentType.ReadOnly<GhostComponent>());
        m_ConnectionQuery = GetEntityQuery(ComponentType.ReadOnly<NetworkIdComponent>());
        RequireForUpdate(m_ConnectionQuery);
        m_Connections = new NativeList<ConnectionRelevancy>(16, Allocator.Persistent);
        m_GhostSendSystem = World.GetExistingSystem<GhostSendSystem>();
        //We need the GameSettingsComponent so we need to make sure it streamed in from the SubScene
        RequireSingletonForUpdate<GameSettingsComponent>();
    }
    protected override void OnDestroy()
    {
        m_Connections.Dispose();
    }
    protected override void OnUpdate()
    {
        //We only run this if the relevancyRadius is not 0
        var settings = GetSingleton<GameSettingsComponent>();
        if ((int) settings.relevancyRadius == 0)
        {
            m_GhostSendSystem.GhostRelevancyMode = GhostRelevancyMode.Disabled;
            return;
        }
        //This is a special NetCode system configuration
        //This is saying that any ghost we put in this list is IRRELEVANT (it means ignore these ghosts)
        m_GhostSendSystem.GhostRelevancyMode = GhostRelevancyMode.SetIsIrrelevant;

        //We create a new list of connections ever OnUpdate
        m_Connections.Clear();
        var irrelevantSet = m_GhostSendSystem.GhostRelevancySet;
        //This is our irrelevantSet that we will be using to add to our list
        var parallelIsNotRelevantSet = irrelevantSet.AsParallelWriter();

        var maxRelevantSize = m_GhostQuery.CalculateEntityCount() * m_ConnectionQuery.CalculateEntityCount();

        var clearHandle = Job.WithCode(() => {
            irrelevantSet.Clear();
            if (irrelevantSet.Capacity < maxRelevantSize)
                irrelevantSet.Capacity = maxRelevantSize;
        }).Schedule(m_GhostSendSystem.GhostRelevancySetWriteHandle);

        //Here we grab the positions and networkids of the NCEs ComandTargetCommponent's targetEntity
        var connections = m_Connections;
        var transFromEntity = GetComponentDataFromEntity<Translation>(true);
        var connectionHandle = Entities
            .WithReadOnly(transFromEntity)
            .WithNone<NetworkStreamDisconnected>()
            .WithAll<NetworkStreamInGame>()
            .ForEach((in NetworkIdComponent netId, in CommandTargetComponent target) => {
            var pos = new float3();
            //If we havent spawned a player yet we will set the position to the location of the main camera
            if (target.targetEntity == Entity.Null)
                pos = new float3(0,1,-10);
            else 
                pos = transFromEntity[target.targetEntity].Value;
            connections.Add(new ConnectionRelevancy{ConnectionId = netId.Value, Position = pos});
        }).Schedule(Dependency);

        //Here we check all ghosted entities and see which ones are relevant to the NCEs based on distance and the relevancy radius
        Dependency = Entities
            .WithReadOnly(connections)
            .ForEach((Entity entity, in GhostComponent ghost, in Translation pos) => {
            for (int i = 0; i < connections.Length; ++i)
            {
                //Here we do a check on distance, and if the entity is a PlayerScore or HighestScore entity
                //If the ghost is out of the radius (and is not PlayerScore or HighestScore) then we add it to the "ignore this ghost" list (parallelIsNotRelevantSet)
                if (math.distance(pos.Value, connections[i].Position) > settings.relevancyRadius && !(HasComponent<PlayerScoreComponent>(entity) || HasComponent<HighestScoreComponent>(entity)))
                    parallelIsNotRelevantSet.TryAdd(new RelevantGhostForConnection(connections[i].ConnectionId, ghost.ghostId), 1);
            }
        }).ScheduleParallel(JobHandle.CombineDependencies(connectionHandle, clearHandle));

        m_GhostSendSystem.GhostRelevancySetWriteHandle = Dependency;
    }
}

You will notice that in PlayerRelevancySphereSystem we setm_GhostSendSystem.GhostRelevancyMode = GhostRelevancyMode.SetIsIrrelevant;

Which means that we are sending "ignore these" ghosts. Just as an FYI, we could have done "SetIsRelevant" and instead sent relevant ghosts (the inverse).

  • Now let's go back to GameSettings in ConvertedSubScene and update Relevancy Radius to 40, save, and return to NavigationScene

  • Now, hit Play, host a game, move around, and keep an eye out on the scene view

  • Self-destruct and move around

  • Only those ghosts that are in proximity to the player appear near the user

  • As the player moves you can see the "sphere" of asteroids appearing and disappearing

Now with these updates, you can have bigger maps in your games and host a lot more players. The server still runs calculations on bullets and asteroids the player does not see (out of its radius), so bullets still will collide with "far-away" ghosts that the player does not see when the bullet was fired.

Our game UI updates based on updated ghost values

  • We updated GameSettingsComponent

  • We updated SetGameSettingsSystem

  • We created PlayerRelevancySphereSystem

Clean-up

You might sometimes notice an error regarding GhostDistancePartitioningSystem when hitting Quit Game. There are also some errors that appear when we we quit the game while MainScene is running.

We're now going to handle quitting the application (Quit Game) more gracefully.

Part of accomplishing this is that we will cycle all worlds on our ClientServerConnectionHandler deleting queries during the OnDestroy(). We will also disable GhostDistancePartitioningSystem when we hit Quit Game.

  • Paste the code snippet below into ClientServerConnectionHandler.cs:

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Entities;
using Unity.NetCode;
using UnityEngine.UIElements;
using UnityEngine.SceneManagement;
using Unity.Collections;

public class ClientServerConnectionHandler : MonoBehaviour
{
    //This is the store of server/client info
    public ClientServerInfo ClientServerInfo;

    //These are the launch objects from Navigation scene that tells what to set up
    private GameObject[] launchObjects;

    //These will gets access to the UI views 
    public UIDocument m_GameUIDocument;
    private VisualElement m_GameManagerUIVE;

    //We will use these variables for hitting Quit Game on client or if server disconnects
    private ClientSimulationSystemGroup m_ClientSimulationSystemGroup;
    private World m_ClientWorld;
    private EntityQuery m_ClientNetworkIdComponentQuery;
    private EntityQuery m_ClientDisconnectedNCEQuery;

    //We will use these variables for hitting Quit Game on server
    private World m_ServerWorld;
    private EntityQuery m_ServerNetworkIdComponentQuery;

    void OnEnable()
    {
        //This will put callback on "Quit Game" button
        //This triggers the clean up function (ClickedQuitGame)
        m_GameManagerUIVE = m_GameUIDocument.rootVisualElement;
        m_GameManagerUIVE.Q("quit-game")?.RegisterCallback<ClickEvent>(ev => ClickedQuitGame());
    }

    void Awake()
    {
        launchObjects = GameObject.FindGameObjectsWithTag("LaunchObject");
        foreach(GameObject launchObject in launchObjects)
        {
            ///  
            //Checks for server launch object
            //If it exists it creates ServerDataComponent InitializeServerComponent and
            //passes through server data to ClientServerInfo
            // 
            if(launchObject.GetComponent<ServerLaunchObjectData>() != null)
            {
                //This sets the gameobject server data  in ClientServerInfo (mono)
                ClientServerInfo.IsServer = true;
                ClientServerInfo.GameName = launchObject.GetComponent<ServerLaunchObjectData>().GameName;
                ClientServerInfo.BroadcastIpAddress = launchObject.GetComponent<ServerLaunchObjectData>().BroadcastIpAddress;
                ClientServerInfo.BroadcastPort = launchObject.GetComponent<ServerLaunchObjectData>().BroadcastPort;

                //This sets the component server data in server world(dots)
                //ClientServerConnectionControl (server) will run in server world
                //it will pick up this component and use it to listen on the port
                foreach (var world in World.All)
                {
                    //we cycle through all the worlds, and if the world has ServerSimulationSystemGroup
                    //we move forward (because that is the server world)
                    if (world.GetExistingSystem<ServerSimulationSystemGroup>() != null)
                    {
                        var ServerDataEntity = world.EntityManager.CreateEntity();
                        world.EntityManager.AddComponentData(ServerDataEntity, new ServerDataComponent
                        {
                            GameName = ClientServerInfo.GameName,
                            GamePort = ClientServerInfo.GamePort
                        });
                        //Create component that allows server initialization to run
                        world.EntityManager.CreateEntity(typeof(InitializeServerComponent));

                        //For handling server disconnecting by hitting the quit button
                        m_ServerWorld = world;
                        m_ServerNetworkIdComponentQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<NetworkIdComponent>());

                    }
                }
            }

            // 
            //Checks for client launch object
            //If it exists it creates ClientDataComponent, InitializeServerComponent and
            // passes through client data to ClientServerInfo
            // 
            if(launchObject.GetComponent<ClientLaunchObjectData>() != null)
            {
                //This sets the gameobject data in ClientServerInfo (mono)
                ClientServerInfo.IsClient = true;
                ClientServerInfo.ConnectToServerIp = launchObject.GetComponent<ClientLaunchObjectData>().IPAddress;                
                ClientServerInfo.PlayerName = launchObject.GetComponent<ClientLaunchObjectData>().PlayerName;

                //This sets the component client data in server world (dots)
                //ClientServerConnectionControl (client) will run in client world
                //it will pick up this component and use it connect to IP and port
                foreach (var world in World.All)
                {
                    //We cycle through all the worlds, and if the world has ClientSimulationSystemGroup
                    //we move forward (because that is the client world)
                    if (world.GetExistingSystem<ClientSimulationSystemGroup>() != null)
                    {
                        var ClientDataEntity = world.EntityManager.CreateEntity();
                        world.EntityManager.AddComponentData(ClientDataEntity, new ClientDataComponent
                        {
                            PlayerName = ClientServerInfo.PlayerName,
                            ConnectToServerIp = ClientServerInfo.ConnectToServerIp,
                            GamePort = ClientServerInfo.GamePort
                        });
                        //Create component that allows client initialization to run
                        world.EntityManager.CreateEntity(typeof(InitializeClientComponent));

                        //We will now set the variables we need to clean up during QuitGame()
                        m_ClientWorld = world;
                        m_ClientSimulationSystemGroup = world.GetExistingSystem<ClientSimulationSystemGroup>();
                        m_ClientNetworkIdComponentQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<NetworkIdComponent>());
                        //This variable is used to check if the server disconnected
                        m_ClientDisconnectedNCEQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadWrite<NetworkStreamDisconnected>());

                    }
                }
            }
        }
    }

    // Start is called before the first frame update
    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
        //The client checks if the NCE has a NetworkStreamDisconnected component
        //If it does we act like they quit the game manually
        if(m_ClientDisconnectedNCEQuery.IsEmptyIgnoreFilter)
            return;
        else
            ClickedQuitGame();
    }

   //This function will navigate us to NavigationScene and connected with the clients/server about leaving
    void ClickedQuitGame()
    {
        //As a client if we were able to create an NCE we must add a request disconnect
        if (!m_ClientNetworkIdComponentQuery.IsEmptyIgnoreFilter)
        {
            var clientNCE = m_ClientSimulationSystemGroup.GetSingletonEntity<NetworkIdComponent>();
            m_ClientWorld.EntityManager.AddComponentData(clientNCE, new NetworkStreamRequestDisconnect());

        }

        //As a server if we were able to create an NCE we must add a request disconnect to all NCEs
        //We must to see if this was a host build
        if (m_ServerWorld != null)
        {
            //First we grab the array of NCEs
            var nceArray = m_ServerNetworkIdComponentQuery.ToEntityArray(Allocator.TempJob);
            for (int i = 0; i < nceArray.Length; i++)
            {
                //Then we add our NetworkStreamDisconnect component to tell the clients we are leaving
                m_ServerWorld.EntityManager.AddComponentData(nceArray[i], new NetworkStreamRequestDisconnect());
            }
            //Then we dispose of our array
            nceArray.Dispose();
        }

#if UNITY_EDITOR
        if(Application.isPlaying)
#endif
            SceneManager.LoadSceneAsync("NavigationScene");
#if UNITY_EDITOR
        else
            Debug.Log("Loading: " + "NavigationScene");
#endif
        if (ClientServerInfo.IsServer)
                    m_ServerWorld.GetExistingSystem<GhostDistancePartitioningSystem>().Enabled = false;
    }

    //When the OnDestroy method is called (because of our transition to NavigationScene) we
    //must delete all our entities and our created worlds to go back to a blank state
    //This way we can move back and forth between scenes and "start from scratch" each time
    void OnDestroy()
    {
        for (var i = 0; i < launchObjects.Length; i++)
        {
            Destroy(launchObjects[i]);
        }
        foreach (var world in World.All)
        {
            var entityManager = world.EntityManager;
            var uq = entityManager.UniversalQuery;
            world.EntityManager.DestroyEntity(uq);
        }

        World.DisposeAllWorlds();

        //We return to our initial world that we started with, defaultWorld
        var bootstrap = new NetCodeBootstrap();
        bootstrap.Initialize("defaultWorld"); 

    }
}
  • Now let's clean-up some more by removing some unhelpful logs from certain scripts

  • In SendServerGameLoadedRpc remove "Server acted on confirmed game load"

  • In GameUIManager.cs (the custom Visual Element) remove "Clicked quit game"

Now to wrap-up this entire section, we are left with these networking outputs:

  1. started listening for UDP broadcast (NavigationScene)

  2. server listening on port (MainScene server launch)

  3. client trying to connect to an IP and port (MainScene client launch)

  4. "Error receiving data from UDP client:" (When NavigationScene calls .Stop() on UdpConnection) - This is the UDP client receive thread being stopped - We can hide errors entirely for production

  • We showed how to use GhostRelevancy but for people that are just opening up this tutorial and don't know what is going on they might be confused why it is so "empty" intially so let's change back our GameSettings

    • level width, height, depth = 40

    • Num Asteroids = 200

    • Relevancy Radius = 0

  • Finally, we will add an assembly definition

We updated our project so that it can handle Quit Games a bit more gracefully

We also removed unnecessary logs

  • We updated ClientServerConnectionHandler

  • We removed log from SendServerGameLoadedRpc

  • We removed log from GameUIManager "Quit game"

Github branch link:

git clone https://github.com/moetsi/Unity-DOTS-Multiplayer-XR-Sample/ git checkout 'GhostRelevancyMode-and-Clean-Up'

Last updated