DOTS NetCode and Prefabs

Code and workflows to turn asteroid entity prefabs into NetCode "ghosts"

What you'll develop on this page

We will update our Asteroid prefab, effectively "turning it into" a NetCode ghost so that spawning and destroying are handled by the server.

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

Asteroids spawn and movement

First, some background:

NetCode refers to networked entities as "Ghosts" 👻. Ghosts must be declared before runtime, there is no way to currently update our ghost list from inside a system; it must be done through authoring.

Add a "GhostAuthoringComponent" to the prefabs (this is a special NetCode component). You can think of this as "registering" our Asteroid, Player, and Bullet prefab.

Ghost snapshots

A ghost is a networked object that the server simulates. During every frame, the server sends a snapshot of the current state of all ghosts to the client. The client presents them, but cannot directly control or affect them because the server owns them.

The ghost snapshot system synchronizes entities which exist on the server to all clients. To make it perform properly, the server processes per ECS chunk rather than per entity. On the receiving side the processing is done per entity. This is because it is not possible to process per chunk on both sides, and the server has more connections than clients.

Ghost authoring component

The ghost authoring component is based on specifying ghosts as Prefabs with the GhostAuthoringComponent on them. The GhostAuthoringComponent has a small editor which you can use to configure how NetCode synchronizes the Prefab.

You must set the Name, Importance, Supported Ghost Mode, Default Ghost Mode and Optimization Mode property on each ghost. Unity uses the Importance property to control which entities are sent when there is not enough bandwidth to send all. A higher value makes it more likely that the ghost will be sent.

You can select from three different Supported Ghost Mode types:

  • All - this ghost supports both being interpolated and predicted.

  • Interpolated - this ghost only supports being interpolated, it cannot be spawned as a predicted ghost.

  • Predicted - this ghost only supports being predicted, it cannot be spawned as a interpolated ghost.

You can select from three different Default Ghost Mode types:

  • Interpolated - all ghosts Unity receives from the server are treated as interpolated.

  • Predicted - all ghosts Unity receives from the server are treated as predicted.

  • Owner predicted - the ghost is predicted for the client that owns it, and interpolated for all other clients. When you select this property, you must also add a GhostOwnerComponent and set its NetworkId field in your code. Unity compares this field to each clients’ network ID to find the correct owner.

You can select from two different Optimization Mode types:

  • Dynamic - the ghost will be optimized for having small snapshot size both when changing and when not changing.

  • Static - the ghost will not be optimized for having small snapshot size when changing, but it will not be sent at all when it is not changing.

To override the default client instantiation you can create a classification system updating after ClientSimulationSystemGroup and before GhostSpawnClassificationSystem which goes through the GhostSpawnBuffer buffer on the singleton entity with GhostSpawnQueueComponent and change the SpawnType.

Unity uses attributes in C# to configure which components and fields are synchronized as part of a ghost. You can see the current configuration in the GhostAuthoringComponent by selecting Update component list, but you cannot modify it from the inspector.

To change which versions of a Prefab a component is available on you use PrefabType in a GhostComponentAttribute on the component. PrefabType can be on of the these types:

  • InterpolatedClient - the component is only available on clients where the ghost is interpolated.

  • PredictedClient - the component is only available on clients where the ghost is predicted.

  • Client - the component is only available on the clients, both when the ghost is predicted and interpolated.

  • Server - the component is only available on the server.

  • AllPredicted - the component is only available on the server and on clients where the ghost is predicted.

  • All - the component is available on the server and all clients.

For example, if you add [GhostComponent(PrefabType=GhostPrefabType.Client)] to RenderMesh, the ghost won’t have a RenderMesh when it is instantiated on the server, but it will have it when instantiated on the client.

A component can set OwnerPredictedSendType in the GhostComponentAttribute to control which clients the component is sent to when it is owner predicted. The available modes are:

  • Interpolated - the component is only sent to clients which are interpolating the ghost.

  • Predicted - the component is only sent to clients which are predicting the ghost.

  • All - the component is sent to all clients.

If a component is not sent to a client NetCode will not modify the component on the client which did not receive it.

A component can also set SendDataForChildEntity to true or false in order to control if the component it sent when it is part of a child entity of a ghost with multiple entities.

A component can also set SendToOwner in the GhostComponentAttribute to specify if the component should be sent to client who owns the entity. The available values are:

  • SendToOwner - the component is only sent to the client who own the ghost

  • SendToNonOwner - the component is sent to all clients except the one who owns the ghost

  • All - the component is sent to all clients.

For each component you want to serialize, you need to add an attribute to the values you want to send. Add a [GhostField] attribute to the fields you want to send in an IComponentData. Both component fields and properties are supported. The following conditions apply in general for a component to support serialization:

  • The component must be declared as public.

  • Only public members are considered. Adding a [GhostField] to a private member has no effect.

  • The GhostField can specify Quantization for floating point numbers. The floating point number will be multiplied by this number and converted to an integer in order to save bandwidth. Specifying a Quantization is mandatory for floating point numbers and not supported for integer numbers. To send a floating point number unquantized you have to explicitly specify [GhostField(Quantization=0)].

  • The GhostField Composite flag controls how the delta compression computes the change fields bitmask for non primitive fields (struct). When set to true the delta compression will generate only 1 bit to indicate if the struct values are changed or not.

  • The GhostField SendData flag can be used to instruct code-generation to not include the field in the serialization data if is set to false. This is particularly useful for non primitive members (like structs), which will have all fields serialized by default.

  • The GhostField also has a Smoothing property which controls if the field will be interpolated or not on clients which are not predicting the ghost. Possible values are:

    • Clamp - use the latest snapshot value

    • Interpolate - interpolate the data between the two snapshot values and if no data is available for the next tick, clamp to the latest value.

    • InterpolateAndExtrapolate - interpolate the GhostField value between snapshot values, and if no data is available for the next tick, the next value is linearly extrapolated using the previous two snapshot values. Extrapolation is limited (i.e. clamped) via ClientTickRate.MaxExtrapolationTimeSimTicks.

  • GhostField MaxSmoothingDistance allows you to disable interpolation when the values change more than the specified limit between two snapshots. This is useful for dealing with teleportation for example.

  • Finally the GhostField has a SubType property which can be set to an integer value to use special serialization rules supplied for that specific field.

Entity spawning

When the client side receives a new ghost, the ghost type is determined by a set of classification systems and then a spawn system spawns it. There is no specific spawn message, and when the client receives an unknown ghost ID, it counts as an implicit spawn.

Because the client interpolates snapshot data, Unity cannot spawn entities immediately, unless it was preemptively spawned, such as with spawn prediction. This is because the data is not ready for the client to interpolate it. Otherwise, the object would appear and then not get any more updates until the interpolation is ready.

Therefore normal spawns happen in a delayed manner. Spawning is split into three main types as follows:

  • Delayed or interpolated spawning (Asteroids Prefab). The entity is spawned when the interpolation system is ready to apply updates. This is how remote entities are handled, because they are interpolated in a straightforward manner.

  • Predicted spawning for the client predicted player object (Player Prefab). The object is predicted so the input handling applies immediately. Therefore, it doesn't need to be delay spawned. While the snapshot data for this object arrives, the update system applies the data directly to the object and then plays back the local inputs which have happened since that time, and corrects mistakes in the prediction.

  • Predicted spawning for player spawned objects (Bullet Prefab). These are objects that the player input spawns, like in-game bullets or rockets that the player fires.

The spawn code needs to run on the client, in the client prediction system. The spawn should use the predicted client version of the ghost prefab and add a PredictedGhostSpawnRequestComponent to it. Then, when the first snapshot update for the entity arrives it will apply to that predict spawned object (no new entity is created). After this, the snapshot updates are applied the same as in the predicted spawning for client predicted player object model. To create the prefab for predicted spawning, you should use the utility method GhostCollectionSystem.CreatePredictedSpawnPrefab.

You need to implement some specific code to handle the predicted spawning for player spawned objects. You need to create a system updating in the ClientSimulationSystemGroup after GhostSpawnClassificationSystem. The system needs to go through the GhostSpawnBuffer buffer stored on a singleton with a GhostSpawnQueueComponent. For each entry in that list it should compare to the entries in the PredictedGhostSpawn buffer on the singleton with a PredictedGhostSpawnList component. If the two entries are the same the classification system should set the PredictedSpawnEntity property in the GhostSpawnBuffer and remove the entry from GhostSpawnBuffer.

NetCode spawns entities on the client with a Prefab stored in the NetCode spawns entities on clients when there is a Prefab available for it. Pre spawned ghosts will work without any special consideration since they are referenced in a sub scene, but for manually spawned entities you must make sure that the prefabs exist on the client. You make sure that happens by having a component in a scene which references the prefab you want to spawn.

From NetCode Ghost snapshots documentation

Prefabs in parenthesis in spawning types added my Moetsi

From the 3 types of spawning described in the section above, asteroids are delayed, interpolated, or spawning (re-read the section above if you missed it). The server will spawn the asteroids, and when the interpolation system is ready to apply updates, the client will then spawn them as well.

To the client, asteroids just "appear" (because NetCode syncs ghosted entities) and their movement is updated through Snapshots.

The client is not running any Physics code for the asteroid movement; the asteroids will move because of updated snapshots from the server. The server runs the systems that take in the PhysicsVelocity and updates the asteroids positions, these updates are then sent to the clients.

If it sounds like we are being repetitive, we are. It is important to understand the concept of interpolated ghosts to make sense of our implementations 💪.

If the concepts 'interpolated spawning' and 'predicted spawning' are making your head spin, read and watch the explainer content we provided in the "Overview" page of this DOTS NetCode section.

Now, let's implement:

  • Select the Asteroid prefab and add a GhostAuthoringComponent. Fill out the following fields:

    • Name = "Asteroid"

    • Importance = "100"

    • Supported Ghost Modes = All

    • Default Ghost Mode = Interpolated

    • Optimization Mode = Dynamic

  • Click "Update component list" to see the components that will be ghosted

    • The list of components that appear have a marker S/IC/PC

      • This stands for:

        • "Server" (the component is only available on the server)

        • "Interpolated Client" (the component is only available on clients where the ghost is interpolated)

        • "Predicted Client" (the component is only available on clients where the ghost is predicted)

      • You might be curious why there is a "RenderMesh" component, yet rendering is not needed on the server? (we can update to remove this but we don't want to get so complicated so quickly)

    • When we add the GhostAuthoringComponent to our Player prefab and our Bullet prefab we will see how these values change

    • At the bottom of the component list you'll find the Rotation and Translation components. Expand these components:

      • Here you can see different fields configured for quantization and interpolation

    • You can see how "customizable" what data sent where can get

      • This has been a large focus in recent releases

If any of the fields above are confusing, check out the background information above or read it straight from the source in NetCode's Ghost snapshots documentation

We need to update the systems that interacted with asteroids. Let's start with AsteroidSpawnSystem.

  • Find AsteroidSpawnSystem and replace the current code with the code snippet below:

using System.Diagnostics;
using Unity.Entities;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Transforms;
using UnityEngine;
using Unity.Burst;
using Unity.Physics;
using Unity.NetCode;

//Asteroid spawning will occur on the server
[UpdateInGroup(typeof(ServerSimulationSystemGroup))]
public partial class AsteroidSpawnSystem : SystemBase
{
    //This will be our query for Asteroids
    private EntityQuery m_AsteroidQuery;

    //We will use the BeginSimulationEntityCommandBufferSystem for our structural changes
    private BeginSimulationEntityCommandBufferSystem m_BeginSimECB;

    //This will be our query to find GameSettingsComponent data to know how many and where to spawn Asteroids
    private EntityQuery m_GameSettingsQuery;

    //This will save our Asteroid prefab to be used to spawn Asteroids
    private Entity m_Prefab;

    //This is the query for checking network connections with clients
    private EntityQuery m_ConnectionGroup;

    protected override void OnCreate()
    {
        //This is an EntityQuery for our Asteroids, they must have an AsteroidTag
        m_AsteroidQuery = GetEntityQuery(ComponentType.ReadWrite<AsteroidTag>());

        //This will grab the BeginSimulationEntityCommandBuffer system to be used in OnUpdate
        m_BeginSimECB = World.GetOrCreateSystem<BeginSimulationEntityCommandBufferSystem>();

        //This is an EntityQuery for the GameSettingsComponent which will drive how many Asteroids we spawn
        m_GameSettingsQuery = GetEntityQuery(ComponentType.ReadWrite<GameSettingsComponent>());

        //This says "do not go to the OnUpdate method until an entity exists that meets this query"
        //We are using GameObjectConversion to create our GameSettingsComponent so we need to make sure 
        //The conversion process is complete before continuing
        RequireForUpdate(m_GameSettingsQuery);

        //This will be used to check how many connected clients there are
        //If there are no connected clients the server will not spawn asteroids to save CPU
        m_ConnectionGroup = GetEntityQuery(ComponentType.ReadWrite<NetworkStreamConnection>());
    }
    
    protected override void OnUpdate()
    {
        //Here we check the amount of connected clients
        if (m_ConnectionGroup.IsEmptyIgnoreFilter)
        {
            // No connected players, just destroy all asteroids to save CPU
            EntityManager.DestroyEntity(m_AsteroidQuery);
            return;
        }

        //Here we set the prefab we will use
        if (m_Prefab == Entity.Null)
        {
            //We grab the converted PrefabCollection Entity's AsteroidAuthoringComponent
            //and set m_Prefab to its Prefab value
            m_Prefab = GetSingleton<AsteroidAuthoringComponent>().Prefab;
            //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;
        }

        //Because of how ECS works we must declare local variables that will be used within the job
        //You cannot "GetSingleton<GameSettingsComponent>()" from within the job, must be declared outside
        var settings = GetSingleton<GameSettingsComponent>();

        //Here we create our commandBuffer where we will "record" our structural changes (creating an Asteroid)
        var commandBuffer = m_BeginSimECB.CreateCommandBuffer();

        //This provides the current amount of Asteroids in the EntityQuery
        var count = m_AsteroidQuery.CalculateEntityCountWithoutFiltering();

        //We must declare our prefab as a local variable (ECS funny business)
        var asteroidPrefab = m_Prefab;

        //We will use this to generate random positions
        var rand = new Unity.Mathematics.Random((uint)Stopwatch.GetTimestamp());

        Job
        .WithCode(() => {
            for (int i = count; i < settings.numAsteroids; ++i)
            {
                // this is how much within perimeter asteroids start
                var padding = 0.1f;

                // we are going to have the asteroids start on the perimeter of the level
                // choose the x, y, z coordinate of perimeter
                // so the x value must be from negative levelWidth/2 to positive levelWidth/2 (within padding)
                var xPosition = rand.NextFloat(-1f*((settings.levelWidth)/2-padding), (settings.levelWidth)/2-padding);
                // so the y value must be from negative levelHeight/2 to positive levelHeight/2 (within padding)
                var yPosition = rand.NextFloat(-1f*((settings.levelHeight)/2-padding), (settings.levelHeight)/2-padding);
                // so the z value must be from negative levelDepth/2 to positive levelDepth/2 (within padding)
                var zPosition = rand.NextFloat(-1f*((settings.levelDepth)/2-padding), (settings.levelDepth)/2-padding);
                
                //We now have xPosition, yPostiion, zPosition in the necessary range
                //With "chooseFace" we will decide which face of the cube the Asteroid will spawn on
                var chooseFace = rand.NextFloat(0,6);
                
                //Based on what face was chosen, we x, y or z to a perimeter value
                //(not important to learn ECS, just a way to make an interesting prespawned shape)
                if (chooseFace < 1) {xPosition = -1*((settings.levelWidth)/2-padding);}
                else if (chooseFace < 2) {xPosition = (settings.levelWidth)/2-padding;}
                else if (chooseFace < 3) {yPosition = -1*((settings.levelHeight)/2-padding);}
                else if (chooseFace < 4) {yPosition = (settings.levelHeight)/2-padding;}
                else if (chooseFace < 5) {zPosition = -1*((settings.levelDepth)/2-padding);}
                else if (chooseFace < 6) {zPosition = (settings.levelDepth)/2-padding;}

                //we then create a new translation component with the randomly generated x, y, and z values                
                var pos = new Translation{Value = new float3(xPosition, yPosition, zPosition)};

                //on our command buffer we record creating an entity from our Asteroid prefab
                var e = commandBuffer.Instantiate(asteroidPrefab);

                //we then set the Translation component of the Asteroid prefab equal to our new translation component
                commandBuffer.SetComponent(e, pos);

                //We will now set the PhysicsVelocity of our asteroids
                //here we generate a random Vector3 with x, y and z between -1 and 1
                var randomVel = new Vector3(rand.NextFloat(-1f, 1f), rand.NextFloat(-1f, 1f), rand.NextFloat(-1f, 1f));
                //next we normalize it so it has a magnitude of 1
                randomVel.Normalize();
                //now we set the magnitude equal to the game settings
                randomVel = randomVel * settings.asteroidVelocity;
                //here we create a new VelocityComponent with the velocity data
                var vel = new PhysicsVelocity{Linear = new float3(randomVel.x, randomVel.y, randomVel.z)};
                //now we set the velocity component in our asteroid prefab
                commandBuffer.SetComponent(e, vel);

            }
        }).Schedule();

        //This will add our dependency to be played back on the BeginSimulationEntityCommandBuffer
        m_BeginSimECB.AddJobHandleForProducer(Dependency);
    }
}
  • This system is now set to run on the server only

  • We have included a check for connected clients and a delete-all of asteroids if there are none collected to save CPU

  • Let's move the AsteroidSpawnSystem file into Server/Systems folder

  • Now, let's hit play and see what happens

  • Holy moly that's a lot of errors, let's focus on this one:

  • One of the errors is that the client is running the two systems (1) AsteroidsOutOfBoundsSystem and (2) AsteroidsDestructionSystem and deleting the server-spawned interpolated ghosts (not good!)

    • Only the server can decide what is destroyed and the ultimate "state" of the game

      • This is the "authoritative" part of NetCode's authoritative server client-predicted model

    • We need to update those two systems to only run on the server

  • You will also notice there is now a bigger delay before the asteroids "appear"

    • That is because now the asteroid snapshots must be sent to the client before they are spawned

    • The wait for the data transfer creates the delay

  • Let's start with updating AsteroidsOutOfBoundsSystem by updating the code in AsteroidsOutOfBoundsSystem.cs to:

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


//We cannot use [UpdateInGroup(typeof(ServerSimulationSystemGroup))] because we already have a group defined
//So we specify instead what world the system must run, ServerWorld
[UpdateInWorld(TargetWorld.Server)]
//We are adding this system within the FixedStepSimulationGroup
[UpdateInGroup(typeof(FixedStepSimulationSystemGroup))]
[UpdateBefore(typeof(EndFixedStepSimulationEntityCommandBufferSystem))] 
public partial class AsteroidsOutOfBoundsSystem : SystemBase
{
    //We are going to use the EndFixedStepSimECB
    //This is because when we use Unity Physics our physics will run in the FixedStepSimulationSystem
    //We are dipping our toes into placing our systems in specific system groups
    //The FixedStepSimGroup has its own EntityCommandBufferSystem we will use to make the structural change
    //of adding the DestroyTag
    private EndFixedStepSimulationEntityCommandBufferSystem m_EndFixedStepSimECB;
    
    protected override void OnCreate()
    {
        //We grab the EndFixedStepSimECB for our OnUpdate
        m_EndFixedStepSimECB = World.GetOrCreateSystem<EndFixedStepSimulationEntityCommandBufferSystem>();
        
        //We want to make sure we don't update until we have our GameSettingsComponent
        //because we need the data from this component to know where the perimeter of our cube is
        RequireSingletonForUpdate<GameSettingsComponent>();
    }

    protected override void OnUpdate()
    {
        //We want to run this as parallel jobs so we need to add "AsParallelWriter" when creating
        //our command buffer
        var commandBuffer = m_EndFixedStepSimECB.CreateCommandBuffer().AsParallelWriter();

        //We must declare our local variables that we will use in our job
        var settings = GetSingleton<GameSettingsComponent>();

        //This time we query entities with components by using "WithAll" tag
        //This makes sure that we only grab entities with an AsteroidTag component so we don't affect other entities
        //that might have passed the perimeter of the cube  
        Entities
        .WithAll<AsteroidTag>()
        .ForEach((Entity entity, int entityInQueryIndex, in Translation position) =>
        {
            //We check if the current Translation value is out of bounds
            if (Mathf.Abs(position.Value.x) > settings.levelWidth/2 ||
                Mathf.Abs(position.Value.y) > settings.levelHeight/2 ||
                Mathf.Abs(position.Value.z) > settings.levelDepth/2)
            {
                //If it is out of bounds wee add the DestroyTag component to the entity and return
                commandBuffer.AddComponent(entityInQueryIndex, entity, new DestroyTag());
                return;
            }

        }).ScheduleParallel();

        //We add the dependencies to the CommandBuffer that will be playing back these structural changes (adding a DestroyTag)
        m_EndFixedStepSimECB.AddJobHandleForProducer(Dependency);
    
    }
}
  • Next, update AsteroidsDestructionSystem by updating the code in AsteroidsDestructionSystem.cs to:

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

//We cannot use [UpdateInGroup(typeof(ServerSimulationSystemGroup))] because we already have a group defined
//So we specify instead what world the system must run, ServerWorld
[UpdateInWorld(TargetWorld.Server)]
//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
[UpdateInGroup(typeof(LateSimulationSystemGroup))]
public partial class AsteroidsDestructionSystem : SystemBase
{
    private EndSimulationEntityCommandBufferSystem m_EndSimEcb;    

    protected override void OnCreate()
    {
        //We grab the EndSimulationEntityCommandBufferSystem to record our structural changes
        m_EndSimEcb = World.GetOrCreateSystem<EndSimulationEntityCommandBufferSystem>();
    }
    
    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 now any entities with a DestroyTag and an AsteroidTag
        //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 Asteroids
        Entities
        .WithAll<DestroyTag, AsteroidTag>()
        .ForEach((Entity entity, int entityInQueryIndex) =>
        {
            commandBuffer.DestroyEntity(entityInQueryIndex, entity);

        }).ScheduleParallel();

        //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);
    
    }
}
  • Move both system files into the Server/Systems folder and hit play

  • Looking much better!

  • Let's do some housekeeping:

    • Move AsteroidAuthoringComponent into Server/Components

    • Move AsteroidTag into Mixed/Components

    • Then reattach AsteroidAuthoringComponent in ConvertedSubScene and drag the Asteroid Prefab onto it. as well as re-add AsteroidTag onto the Prefab

      • Moving the location of files sometimes causes issues!

    • Move DestroyTag into Mixed/Components

  • Check out the DOTS Windows and see how the Asteroid systems are only running in ServerWorld

  • No gif here, we believe in you 💪

We now have server-spawned asteroids appearing on the client

  • We added a GhostAuthoring component on our Asteroid prefab

  • We updated our AsteroidSpawnSystem, AsteroidsOutOfBoundsSystem, and AsteroidsDestructionSystem

Github branch link:

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

Last updated