Send Ghosts with NetCode
Code and workflows to send ghosts and clean up logs using GhostRelevancyMode

What you'll develop on this page

Join a LAN game and only receive ghosts within a certain radius
We will implement a system where only the ghosts near a player are sent. We will also clean some unnecessary logs.

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.
GhostAuthoringComponent on Asteroid prefab has a Dynamic Optimization Mode which sends Translation and Rotation data
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
Asteroid SnapShots not getting to client fast enough to make movement appear smooth
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:
1
using Unity.Entities;
2
3
public struct GameSettingsComponent : IComponentData
4
{
5
public float asteroidVelocity;
6
public float playerForce;
7
public float bulletVelocity;
8
public int numAsteroids;
9
public int levelWidth;
10
public int levelHeight;
11
public int levelDepth;
12
public float relevancyRadius;
13
}
Copied!
Updating GameSettingsComponent
    Now we must also update SetGameSettingsSystem to pass through this new field
    Paste the code snippet below into SetGameSettingsSystem.cs:
1
using Unity.Entities;
2
using Unity.Mathematics;
3
using UnityEngine;
4
5
public class SetGameSettingsSystem : UnityEngine.MonoBehaviour, IConvertGameObjectToEntity
6
{
7
public float asteroidVelocity = 10f;
8
public float playerForce = 50f;
9
public float bulletVelocity = 500f;
10
public int numAsteroids = 200;
11
public int levelWidth = 2048;
12
public int levelHeight = 2048;
13
public int levelDepth = 2048;
14
public int relevencyRadius = 0;
15
public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
16
{
17
var settings = default(GameSettingsComponent);
18
settings.asteroidVelocity = asteroidVelocity;
19
settings.playerForce = playerForce;
20
settings.bulletVelocity = bulletVelocity;
21
settings.numAsteroids = numAsteroids;
22
settings.levelWidth = levelWidth;
23
settings.levelHeight = levelHeight;
24
settings.levelDepth = levelDepth;
25
settings.relevancyRadius = relevencyRadius;
26
dstManager.AddComponentData(entity, settings);
27
}
28
}
Copied!
UpdatingSetGameSettingsSystem
    Next, create a new System inside the Server/Systems folder and name it PlayerRelevancySphereSystem
    Paste the code snippet below into PlayerRelevancySphereSystem.cs:
1
using Unity.NetCode;
2
using Unity.Entities;
3
using Unity.Mathematics;
4
using Unity.Collections;
5
using Unity.Transforms;
6
using Unity.Jobs;
7
8
[UpdateInGroup(typeof(ServerSimulationSystemGroup))]
9
[UpdateBefore(typeof(GhostSendSystem))]
10
public class PlayerRelevancySphereSystem : SystemBase
11
{
12
//This will be a struct we use only in this system
13
//The ConnectionId is the entities NetworkId
14
struct ConnectionRelevancy
15
{
16
public int ConnectionId;
17
public float3 Position;
18
}
19
//We grab the ghost send system to use its GhostRelevancyMode
20
GhostSendSystem m_GhostSendSystem;
21
//Here we keep a list of our NCEs with NetworkId and position of player
22
NativeList<ConnectionRelevancy> m_Connections;
23
EntityQuery m_GhostQuery;
24
EntityQuery m_ConnectionQuery;
25
protected override void OnCreate()
26
{
27
m_GhostQuery = GetEntityQuery(ComponentType.ReadOnly<GhostComponent>());
28
m_ConnectionQuery = GetEntityQuery(ComponentType.ReadOnly<NetworkIdComponent>());
29
RequireForUpdate(m_ConnectionQuery);
30
m_Connections = new NativeList<ConnectionRelevancy>(16, Allocator.Persistent);
31
m_GhostSendSystem = World.GetExistingSystem<GhostSendSystem>();
32
//We need the GameSettingsComponent so we need to make sure it streamed in from the SubScene
33
RequireSingletonForUpdate<GameSettingsComponent>();
34
}
35
protected override void OnDestroy()
36
{
37
m_Connections.Dispose();
38
}
39
protected override void OnUpdate()
40
{
41
//We only run this if the relevancyRadius is not 0
42
var settings = GetSingleton<GameSettingsComponent>();
43
if ((int) settings.relevancyRadius == 0)
44
{
45
m_GhostSendSystem.GhostRelevancyMode = GhostRelevancyMode.Disabled;
46
return;
47
}
48
//This is a special NetCode system configuration
49
//This is saying that any ghost we put in this list is IRRELEVANT (it means ignore these ghosts)
50
m_GhostSendSystem.GhostRelevancyMode = GhostRelevancyMode.SetIsIrrelevant;
51
52
//We create a new list of connections ever OnUpdate
53
m_Connections.Clear();
54
var irrelevantSet = m_GhostSendSystem.GhostRelevancySet;
55
//This is our irrelevantSet that we will be using to add to our list
56
var parallelIsNotRelevantSet = irrelevantSet.AsParallelWriter();
57
58
var maxRelevantSize = m_GhostQuery.CalculateEntityCount() * m_ConnectionQuery.CalculateEntityCount();
59
60
var clearHandle = Job.WithCode(() => {
61
irrelevantSet.Clear();
62
if (irrelevantSet.Capacity < maxRelevantSize)
63
irrelevantSet.Capacity = maxRelevantSize;
64
}).Schedule(m_GhostSendSystem.GhostRelevancySetWriteHandle);
65
66
//Here we grab the positions and networkids of the NCEs ComandTargetCommponent's targetEntity
67
var connections = m_Connections;
68
var transFromEntity = GetComponentDataFromEntity<Translation>(true);
69
var connectionHandle = Entities
70
.WithReadOnly(transFromEntity)
71
.WithNone<NetworkStreamDisconnected>()
72
.WithAll<NetworkStreamInGame>()
73
.ForEach((in NetworkIdComponent netId, in CommandTargetComponent target) => {
74
var pos = new float3();
75
//If we havent spawned a player yet we will set the position to the location of the main camera
76
if (target.targetEntity == Entity.Null)
77
pos = new float3(0,1,-10);
78
else
79
pos = transFromEntity[target.targetEntity].Value;
80
connections.Add(new ConnectionRelevancy{ConnectionId = netId.Value, Position = pos});
81
}).Schedule(Dependency);
82
83
//Here we check all ghosted entities and see which ones are relevant to the NCEs based on distance and the relevancy radius
84
Dependency = Entities
85
.WithReadOnly(connections)
86
.ForEach((Entity entity, in GhostComponent ghost, in Translation pos) => {
87
for (int i = 0; i < connections.Length; ++i)
88
{
89
//Here we do a check on distance, and if the entity is a PlayerScore or HighestScore entity
90
//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)
91
if (math.distance(pos.Value, connections[i].Position) > settings.relevancyRadius && !(HasComponent<PlayerScoreComponent>(entity) || HasComponent<HighestScoreComponent>(entity)))
92
parallelIsNotRelevantSet.TryAdd(new RelevantGhostForConnection(connections[i].ConnectionId, ghost.ghostId), 1);
93
}
94
}).ScheduleParallel(JobHandle.CombineDependencies(connectionHandle, clearHandle));
95
96
m_GhostSendSystem.GhostRelevancySetWriteHandle = Dependency;
97
}
98
}
Copied!
Creating PlayerRelevancySphereSystem
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
Updating GameSettings to have Relevancy Radius of 40
    Now, hit Play, host a game, move around, and keep an eye out on the scene view
    Self-destruct and move around
Navigating the game and the asteroids only "appear" in proximity to the player
    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:
1
using System;
2
using System.Collections;
3
using System.Collections.Generic;
4
using UnityEngine;
5
using Unity.Entities;
6
using Unity.NetCode;
7
using UnityEngine.UIElements;
8
using UnityEngine.SceneManagement;
9
using Unity.Collections;
10
11
public class ClientServerConnectionHandler : MonoBehaviour
12
{
13
//This is the store of server/client info
14
public ClientServerInfo ClientServerInfo;
15
16
//These are the launch objects from Navigation scene that tells what to set up
17
private GameObject[] launchObjects;
18
19
//These will gets access to the UI views
20
public UIDocument m_GameUIDocument;
21
private VisualElement m_GameManagerUIVE;
22
23
//We will use these variables for hitting Quit Game on client or if server disconnects
24
private ClientSimulationSystemGroup m_ClientSimulationSystemGroup;
25
private World m_ClientWorld;
26
private EntityQuery m_ClientNetworkIdComponentQuery;
27
private EntityQuery m_ClientDisconnectedNCEQuery;
28
29
//We will use these variables for hitting Quit Game on server
30
private World m_ServerWorld;
31
private EntityQuery m_ServerNetworkIdComponentQuery;
32
33
void OnEnable()
34
{
35
//This will put callback on "Quit Game" button
36
//This triggers the clean up function (ClickedQuitGame)
37
m_GameManagerUIVE = m_GameUIDocument.rootVisualElement;
38
m_GameManagerUIVE.Q("quit-game")?.RegisterCallback<ClickEvent>(ev => ClickedQuitGame());
39
}
40
41
void Awake()
42
{
43
launchObjects = GameObject.FindGameObjectsWithTag("LaunchObject");
44
foreach(GameObject launchObject in launchObjects)
45
{
46
///
47
//Checks for server launch object
48
//If it exists it creates ServerDataComponent InitializeServerComponent and
49
//passes through server data to ClientServerInfo
50
//
51
if(launchObject.GetComponent<ServerLaunchObjectData>() != null)
52
{
53
//This sets the gameobject server data in ClientServerInfo (mono)
54
ClientServerInfo.IsServer = true;
55
ClientServerInfo.GameName = launchObject.GetComponent<ServerLaunchObjectData>().GameName;
56
ClientServerInfo.BroadcastIpAddress = launchObject.GetComponent<ServerLaunchObjectData>().BroadcastIpAddress;
57
ClientServerInfo.BroadcastPort = launchObject.GetComponent<ServerLaunchObjectData>().BroadcastPort;
58
59
//This sets the component server data in server world(dots)
60
//ClientServerConnectionControl (server) will run in server world
61
//it will pick up this component and use it to listen on the port
62
foreach (var world in World.All)
63
{
64
//we cycle through all the worlds, and if the world has ServerSimulationSystemGroup
65
//we move forward (because that is the server world)
66
if (world.GetExistingSystem<ServerSimulationSystemGroup>() != null)
67
{
68
var ServerDataEntity = world.EntityManager.CreateEntity();
69
world.EntityManager.AddComponentData(ServerDataEntity, new ServerDataComponent
70
{
71
GameName = ClientServerInfo.GameName,
72
GamePort = ClientServerInfo.GamePort
73
});
74
//Create component that allows server initialization to run
75
world.EntityManager.CreateEntity(typeof(InitializeServerComponent));
76
77
//For handling server disconnecting by hitting the quit button
78
m_ServerWorld = world;
79
m_ServerNetworkIdComponentQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<NetworkIdComponent>());
80
81
}
82
}
83
}
84
85
//
86
//Checks for client launch object
87
//If it exists it creates ClientDataComponent, InitializeServerComponent and
88
// passes through client data to ClientServerInfo
89
//
90
if(launchObject.GetComponent<ClientLaunchObjectData>() != null)
91
{
92
//This sets the gameobject data in ClientServerInfo (mono)
93
ClientServerInfo.IsClient = true;
94
ClientServerInfo.ConnectToServerIp = launchObject.GetComponent<ClientLaunchObjectData>().IPAddress;
95
ClientServerInfo.PlayerName = launchObject.GetComponent<ClientLaunchObjectData>().PlayerName;
96
97
//This sets the component client data in server world (dots)
98
//ClientServerConnectionControl (client) will run in client world
99
//it will pick up this component and use it connect to IP and port
100
foreach (var world in World.All)
101
{
102
//We cycle through all the worlds, and if the world has ClientSimulationSystemGroup
103
//we move forward (because that is the client world)
104
if (world.GetExistingSystem<ClientSimulationSystemGroup>() != null)
105
{
106
var ClientDataEntity = world.EntityManager.CreateEntity();
107
world.EntityManager.AddComponentData(ClientDataEntity, new ClientDataComponent
108
{
109
PlayerName = ClientServerInfo.PlayerName,
110
ConnectToServerIp = ClientServerInfo.ConnectToServerIp,
111
GamePort = ClientServerInfo.GamePort
112
});
113
//Create component that allows client initialization to run
114
world.EntityManager.CreateEntity(typeof(InitializeClientComponent));
115
116
//We will now set the variables we need to clean up during QuitGame()
117
m_ClientWorld = world;
118
m_ClientSimulationSystemGroup = world.GetExistingSystem<ClientSimulationSystemGroup>();
119
m_ClientNetworkIdComponentQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<NetworkIdComponent>());
120
//This variable is used to check if the server disconnected
121
m_ClientDisconnectedNCEQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadWrite<NetworkStreamDisconnected>());
122
123
}
124
}
125
}
126
}
127
}
128
129
// Start is called before the first frame update
130
void Start()
131
{
132
133
}
134
135
// Update is called once per frame
136
void Update()
137
{
138
//The client checks if the NCE has a NetworkStreamDisconnected component
139
//If it does we act like they quit the game manually
140
if(m_ClientDisconnectedNCEQuery.IsEmptyIgnoreFilter)
141
return;
142
else
143
ClickedQuitGame();
144
}
145
146
//This function will navigate us to NavigationScene and connected with the clients/server about leaving
147
void ClickedQuitGame()
148
{
149
//As a client if we were able to create an NCE we must add a request disconnect
150
if (!m_ClientNetworkIdComponentQuery.IsEmptyIgnoreFilter)
151
{
152
var clientNCE = m_ClientSimulationSystemGroup.GetSingletonEntity<NetworkIdComponent>();
153
m_ClientWorld.EntityManager.AddComponentData(clientNCE, new NetworkStreamRequestDisconnect());
154
155
}
156
157
//As a server if we were able to create an NCE we must add a request disconnect to all NCEs
158
//We must to see if this was a host build
159
if (m_ServerWorld != null)
160
{
161
//First we grab the array of NCEs
162
var nceArray = m_ServerNetworkIdComponentQuery.ToEntityArray(Allocator.TempJob);
163
for (int i = 0; i < nceArray.Length; i++)
164
{
165
//Then we add our NetworkStreamDisconnect component to tell the clients we are leaving
166
m_ServerWorld.EntityManager.AddComponentData(nceArray[i], new NetworkStreamRequestDisconnect());
167
}
168
//Then we dispose of our array
169
nceArray.Dispose();
170
}
171
172
#if UNITY_EDITOR
173
if(Application.isPlaying)
174
#endif
175
SceneManager.LoadSceneAsync("NavigationScene");
176
#if UNITY_EDITOR
177
else
178
Debug.Log("Loading: " + "NavigationScene");
179
#endif
180
if (ClientServerInfo.IsServer)
181
m_ServerWorld.GetExistingSystem<GhostDistancePartitioningSystem>().Enabled = false;
182
}
183
184
//When the OnDestroy method is called (because of our transition to NavigationScene) we
185
//must delete all our entities and our created worlds to go back to a blank state
186
//This way we can move back and forth between scenes and "start from scratch" each time
187
void OnDestroy()
188
{
189
for (var i = 0; i < launchObjects.Length; i++)
190
{
191
Destroy(launchObjects[i]);
192
}
193
foreach (var world in World.All)
194
{
195
var entityManager = world.EntityManager;
196
var uq = entityManager.UniversalQuery;
197
world.EntityManager.DestroyEntity(uq);
198
}
199
200
World.DisposeAllWorlds();
201
202
//We return to our initial world that we started with, defaultWorld
203
var bootstrap = new NetCodeBootstrap();
204
bootstrap.Initialize("defaultWorld");
205
206
}
207
}
Copied!
Updating ClientServerConnectionHandler
    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"
Cleaning up logs
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 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 modified 7mo ago