Use DOTS NetCode for Collisions and Destroying Bullet Prefabs

Code and workflows to spawn ghosted bullets and update to server-side destruction

What you'll develop on this page

We will "turn" our bullet prefabs "into" ghosts and update the entity destruction flow so it is server-authoritative.

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

NetCode client-predicted model background

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. 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. 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. These are objects that the player input spawns, like in-game bullets or rockets that the player fires.

Implement Predicted Spawning for player spawned objects

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 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's Ghost snapshots documentation

We are now on the third type of spawning listed above, "Predicted spawning for player spawned objects". Asteroids were "Delayed or interpolated spawning".

A fair amount of the workflow in this section is similar to spawning the Player prefab from the previous section because it is also predicted. We will have a BulletGhostSpawnClassificationSystem that is similar to PlayerGhostSpawnClassificationSystem. However, there will be a bit of a difference in workflow within BulletGhostSpawnClassificationSystem when it comes to identifying the client's bullets.

Updating our bullet spawn

Updating the Bullet Prefab

  • Navigate to the Bullet prefab

  • Add GhostAuthoringComponent

    • Name = Bullet

    • Importance = 200

    • Supported Ghost Mode = All

    • Default Ghost Mode = Owner Predicted

    • Optimization Mode = Dynamic

    • Check "Has Owner"

Updating to predicted bullet spawning

  • We are going to change our implementation of rate limiting from rate limiting on the client side, to rate limiting on the server side

  • This is more line with the authoritative model, the server should be in control of these limits

  • Paste the code snippet below into InputSystem.cs:

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

//This is a special SystemGroup introduced in NetCode 0.5
//This group only exists on the client and is meant to be used when commands are being created
[UpdateInGroup(typeof(GhostInputSystemGroup))]
public partial class InputSystem : SystemBase
{
    //We will use the BeginSimulationEntityCommandBufferSystem for our structural changes
    private BeginSimulationEntityCommandBufferSystem m_BeginSimEcb;

    //We need this sytem group so we can grab its "ServerTick" for prediction when we respond to Commands
    private ClientSimulationSystemGroup m_ClientSimulationSystemGroup;

    //We use this for thin client command generation
    private int m_FrameCount;

    protected override void OnCreate()
    {

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

        //We set our ClientSimulationSystemGroup who will provide its ServerTick needed for the Commands
        m_ClientSimulationSystemGroup = World.GetOrCreateSystem<ClientSimulationSystemGroup>();


        //The client must have loaded the game to spawn a player so we wait for the 
        //NetworkStreamInGame component added during the load game flow
        RequireSingletonForUpdate<NetworkStreamInGame>();
    }

    protected override void OnUpdate()
    {
        bool isThinClient = HasSingleton<ThinClientComponent>();
        if (HasSingleton<CommandTargetComponent>() && GetSingleton<CommandTargetComponent>().targetEntity == Entity.Null)
        {
            if (isThinClient)
            {
                // No ghosts are spawned, so create a placeholder struct to store the commands in
                var ent = EntityManager.CreateEntity();
                EntityManager.AddBuffer<PlayerCommand>(ent);
                SetSingleton(new CommandTargetComponent{targetEntity = ent});
            }
        }
    
        //We now have all our inputs
        byte right, left, thrust, reverseThrust, selfDestruct, shoot;
        right = left = thrust = reverseThrust = selfDestruct = shoot = 0;

        //for looking around with mouse
        float mouseX = 0;
        float mouseY = 0;

        //We are adding this difference so we can use "Num Thin Client" in "Multiplayer Mode Tools"
        //These are the instructions if we are NOT a thin client
        if (!isThinClient)
        {
            if (Input.GetKey("d"))
            {
                right = 1;
            }
            if (Input.GetKey("a"))
            {
                left = 1;
            }
            if (Input.GetKey("w"))
            {
                thrust = 1;
            }
            if (Input.GetKey("s"))
            {
                reverseThrust = 1;
            }
            if (Input.GetKey("p"))
            {
                selfDestruct = 1;
            }
            if (Input.GetKey("space"))
            {
                shoot = 1;
            }
            if (Input.GetMouseButton(1))
            {
                mouseX = Input.GetAxis("Mouse X");
                mouseY = Input.GetAxis("Mouse Y");

            }
        }
        else
        {
            // Spawn and generate some random inputs
            var state = (int) Time.ElapsedTime % 3;
            if (state == 0)
            {
                left = 1;
            }
            else {
                thrust = 1;
            }
            ++m_FrameCount;
            if (m_FrameCount % 100 == 0)
            {
                shoot = 1;
                m_FrameCount = 0;
            }
        }

        //We are sending the simulationsystemgroup tick so the server can playback our commands appropriately
        var inputTargetTick = m_ClientSimulationSystemGroup.ServerTick;    
        //Must declare local variables before using them in the .ForEach()
        var commandBuffer = m_BeginSimEcb.CreateCommandBuffer();
        // This is how we will grab the buffer of PlayerCommands from the player prefab
        var inputFromEntity = GetBufferFromEntity<PlayerCommand>();

        TryGetSingletonEntity<PlayerCommand>(out var targetEntity);
        Job.WithCode(() => {
        if (isThinClient && shoot != 0)
        {
            // Special handling for thin clients since we can't tell if the ship is spawned or not
            // This means every time we shoot we also send an RPC, but the Server protects against creating more Players
            var req = commandBuffer.CreateEntity();
            commandBuffer.AddComponent<PlayerSpawnRequestRpc>(req);
            commandBuffer.AddComponent(req, new SendRpcCommandRequestComponent());
        }
        if (targetEntity == Entity.Null)
        {
            if (shoot != 0)
            {
                var req = commandBuffer.CreateEntity();
                commandBuffer.AddComponent<PlayerSpawnRequestRpc>(req);
                commandBuffer.AddComponent(req, new SendRpcCommandRequestComponent());
            }
        }
        else
        {
            var input = inputFromEntity[targetEntity];
            input.AddCommandData(new PlayerCommand{Tick = inputTargetTick, left = left, right = right, thrust = thrust, reverseThrust = reverseThrust,
                selfDestruct = selfDestruct, shoot = shoot,
                mouseX = mouseX,
                mouseY = mouseY});
        }
        }).Schedule();

        //We need to add the jobs dependency to the command buffer
        m_BeginSimEcb.AddJobHandleForProducer(Dependency);
    }
}
  • We need a way to store firing data on the player entity so the server can know if the client's firing system has "cooled down"

  • So we are going to repurpose our BulletSpawnOffsetComponent and rename it to PlayerStateAndOffsetComponent

    • We could make a new component for rate limiting but this would mean we would need to put 9 different components into our .ForEach() for our InputResponseSpawnSystem 😱

  • Paste the code snippet below into PlayerStateAndOffsetComponent.cs:

using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;

public struct PlayerStateAndOffsetComponent : IComponentData
{
    public float3 Value;
    [GhostField]
    public int State;
    public uint WeaponCooldown;

}
  • We also included a "State" field in the component that can be updated when the user is thrusting or firing

    • Although we won't be doing anything with "State" in this project, you are totally free to add something like change a client's color when it is thrusting or firing, if you want! To do this update the State value and create a client-only system that updates Player prefab meshes based on the State value

      • Just a thought!

  • Since we updated BulletOffsetComponent we will also need to update our SetBulletSpawnOffset which references it

    • We will not rename the SetBulletSpawnOffset system and can keep the system as the same name, because even though we are adding the PlayerStateAndOffsetComponent to the entity the primary purpose of this component is to set the bullet offset

      • So the name still works (kind of)

  • Paste the code snippet below into SetBulletSpawnOffset.cs:

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

public class SetBulletSpawnOffset : UnityEngine.MonoBehaviour, IConvertGameObjectToEntity
{
    public GameObject bulletSpawn;

    public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
    {
        var bulletOffset = default(PlayerStateAndOffsetComponent);

        var offsetVector = bulletSpawn.transform.position;
        bulletOffset.Value = new float3(offsetVector.x, offsetVector.y, offsetVector.z);        

        dstManager.AddComponentData(entity, bulletOffset);
    }
}
  • Now let's re-add our SetBulletSpawnOffset system onto our Player prefab and drag the Bullet Spawn GameObject into the bulletSpawn field

    • This really isn't necessary, but sometimes updating components that are on prefabs causes errors so better to be safe than sorry

  • Now we need to create the system that will be responding to Commands that have to do with spawning

  • Similar to InputResponseMovementSystem, which responds to inputs for movement, we need to create InputResponseSpawnSystem (which responds to inputs for spawning)

  • Create InputResponseSpawnSystem in the Mixed/Systems folder

  • Paste the code snippet below into InputResponseSpawnSystem.cs:

using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
using Unity.NetCode;
using Unity.Networking.Transport.Utilities;
using Unity.Collections;
using Unity.Physics;
using Unity.Physics.Systems;
using Unity.Jobs;
using UnityEngine;

//InputResponseSpawntSystem runs on both the Client and Server
//It is predicted on the client but "decided" on the server
[UpdateInWorld(TargetWorld.ClientAndServer)]
[UpdateInGroup(typeof(FixedStepSimulationSystemGroup))]
[UpdateAfter(typeof(ExportPhysicsWorld))]
public partial class InputResponseSpawnSystem : SystemBase
{
    //We will use the BeginSimulationEntityCommandBufferSystem for our structural changes
    private BeginSimulationEntityCommandBufferSystem m_BeginSimEcb;

    //This is a special NetCode group that provides a "prediction tick" and a fixed "DeltaTime"
    private GhostPredictionSystemGroup m_PredictionGroup;

    //This will save our Bullet prefab to be used to spawn bullets 
    private Entity m_BulletPrefab;

    //We are going to use this for "weapon cooldown"
    private const int k_CoolDownTicksCount = 5;


    protected override void OnCreate()
    {
        //This will grab the BeginSimulationEntityCommandBuffer system to be used in OnUpdate
        m_BeginSimEcb = World.GetOrCreateSystem<BeginSimulationEntityCommandBufferSystem>();

        //This will grab the BeginSimulationEntityCommandBuffer system to be used in OnUpdate
        m_PredictionGroup = World.GetOrCreateSystem<GhostPredictionSystemGroup>();
        
        //We check to ensure GameSettingsComponent exists to know if the SubScene has been streamed in
        //We need the SubScene for actions in our OnUpdate()
        RequireSingletonForUpdate<GameSettingsComponent>(); 
        // Make sure we have the bullet prefab to be able to create the predicted spawning
        RequireSingletonForUpdate<BulletAuthoringComponent>();        
    }

    protected override void OnUpdate()
    {

        //Here we set the prefab we will use
        if (m_BulletPrefab == Entity.Null)
        {
            //We grab the converted PrefabCollection Entity's BulletAuthoringComponent
            //and set m_BulletPrefab to its Prefab value
            var foundPrefab = GetSingleton<BulletAuthoringComponent>().Prefab;
            m_BulletPrefab = GhostCollectionSystem.CreatePredictedSpawnPrefab(EntityManager, foundPrefab);
            //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 a CommandBuffer because we will be making structural changes (creating bullet entities)
        var commandBuffer = m_BeginSimEcb.CreateCommandBuffer().AsParallelWriter();

        //Must declare our local variables before the jobs in the .ForEach()
        var bulletVelocity = GetSingleton<GameSettingsComponent>().bulletVelocity;
        var bulletPrefab = m_BulletPrefab;
        //These are special NetCode values needed to work the prediction system
        var deltaTime = m_PredictionGroup.Time.DeltaTime;
        var currentTick = m_PredictionGroup.PredictingTick;

        //We will grab the buffer of player command from the palyer entity
        var inputFromEntity = GetBufferFromEntity<PlayerCommand>(true);

        //We are looking for player entities that have PlayerCommands in their buffer
        Entities
        .WithReadOnly(inputFromEntity)
        .WithAll<PlayerTag, PlayerCommand>()
        .ForEach((Entity entity, int entityInQueryIndex, ref PlayerStateAndOffsetComponent bulletOffset, in Rotation rotation, in Translation position, in PhysicsVelocity velocityComponent,
                in GhostOwnerComponent ghostOwner, in PredictedGhostComponent prediction) =>
        {
            //Here we check if we SHOULD do the prediction based on the tick, if we shouldn't, we return
            if (!GhostPredictionSystemGroup.ShouldPredict(currentTick, prediction))
                return;

            //We grab the buffer of commands from the player entity
            var input = inputFromEntity[entity];

            //We then grab the Command from the current tick (which is the PredictingTick)
            //if we cannot get it at the current tick we make sure shoot is 0
            //This is where we will store the current tick data
            PlayerCommand inputData;
            if (!input.GetDataAtTick(currentTick, out inputData))
                inputData.shoot = 0;

            //Here we add the destroy tag to the player if the self-destruct button was pressed
            if (inputData.selfDestruct == 1)
            {  
                commandBuffer.AddComponent<DestroyTag>(entityInQueryIndex, entity);
            }

            var canShoot = bulletOffset.WeaponCooldown == 0 || SequenceHelpers.IsNewer(currentTick, bulletOffset.WeaponCooldown);
            if (inputData.shoot != 0 && canShoot)
            {
                // We create the bullet here
                var bullet = commandBuffer.Instantiate(nativeThreadIndex, bulletPrefab);
                //We declare it as a predicted spawning for player spawned objects by adding a special component
                commandBuffer.AddComponent(entityInQueryIndex, bullet, new PredictedGhostSpawnRequestComponent());


                //we set the bullets position as the player's position + the bullet spawn offset
                //math.mul(rotation.Value,bulletOffset.Value) finds the position of the bullet offset in the given rotation
                //think of it as finding the LocalToParent of the bullet offset (because the offset needs to be rotated in the players direction)
                var newPosition = new Translation {Value = position.Value + math.mul(rotation.Value, bulletOffset.Value).xyz};

                // bulletVelocity * math.mul(rotation.Value, new float3(0,0,1)).xyz) takes linear direction of where facing and multiplies by velocity
                // adding to the players physics Velocity makes sure that it takes into account the already existing player velocity (so if shoot backwards while moving forwards it stays in place)
                var vel = new PhysicsVelocity {Linear = (bulletVelocity * math.mul(rotation.Value, new float3(0,0,1)).xyz) + velocityComponent.Linear};

                commandBuffer.SetComponent(entityInQueryIndex, bullet, newPosition);
                commandBuffer.SetComponent(entityInQueryIndex, bullet, vel);
                commandBuffer.SetComponent(entityInQueryIndex, bullet,
                    new GhostOwnerComponent {NetworkId = ghostOwner.NetworkId});


                bulletOffset.WeaponCooldown = currentTick + k_CoolDownTicksCount;
            }

        }).ScheduleParallel();

        //We must add our dependency to the CommandBuffer because we made structural changes
        m_BeginSimEcb.AddJobHandleForProducer(Dependency);
    }
}
  • We identify the bullet as a Predicted spawning prefab by adding "PredictedGhostSpawnRequestComponent"

  • This allows us to identify it in the BulletGhostSpawnClassificationSystem

  • The bulletOffset.WeaponCooldown may seem confusing

    • It makes sure that the Player can only fire every 5 server ticks

      • If you want to increase or decrease the rate of fire update k_CoolDownTicksCount

Implement Predicted Spawning for player spawned objects

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 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's Ghost snapshots documentation

  • Now we must classify these predicted bullets with BulletGhostSpawnClassificationSystem

  • Create BulletGhostSpawnClassificationSystem in the Client/Systems folder

  • Paste the code snippet below into BulletGhostSpawnClassificationSystem.cs:

using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Networking.Transport.Utilities;

//This system will only run on the client and within GhostSimulationSystemGroup
//and after GhostSpawnClassification system as is specified in the NetCode documentation
[UpdateInWorld(TargetWorld.Client)]
[UpdateInGroup(typeof(GhostSimulationSystemGroup))]
[UpdateAfter(typeof(GhostSpawnClassificationSystem))]
public partial class BulletGhostSpawnClassificationSystem : SystemBase
{
    protected override void OnCreate()
    {
        //Both of these components are needed in the OnUpdate so we will wait until they exist to update
        RequireSingletonForUpdate<GhostSpawnQueueComponent>();
        RequireSingletonForUpdate<PredictedGhostSpawnList>();
    }
    protected override void OnUpdate()
    {
        //This is the NetCode recommended method to identify predicted spawning for player spawned objects
        //More information can be found at: https://docs.unity3d.com/Packages/com.unity.netcode@0.5/manual/ghost-snapshots.html
        //under "Entity spawning"
        var spawnListEntity = GetSingletonEntity<PredictedGhostSpawnList>();
        var spawnListFromEntity = GetBufferFromEntity<PredictedGhostSpawn>();
        Dependency = Entities
            .WithAll<GhostSpawnQueueComponent>()
            .WithoutBurst()
            .ForEach((DynamicBuffer<GhostSpawnBuffer> ghosts, DynamicBuffer<SnapshotDataBuffer> data) =>
        {
            var spawnList = spawnListFromEntity[spawnListEntity];
            for (int i = 0; i < ghosts.Length; ++i)
            {
                var ghost = ghosts[i];
                if (ghost.SpawnType == GhostSpawnBuffer.Type.Predicted)
                {
                    for (int j = 0; j < spawnList.Length; ++j)
                    {
                        if (ghost.GhostType == spawnList[j].ghostType && !SequenceHelpers.IsNewer(spawnList[j].spawnTick, ghost.ServerSpawnTick + 5) && SequenceHelpers.IsNewer(spawnList[j].spawnTick + 5, ghost.ServerSpawnTick))
                        {
                            ghost.PredictedSpawnEntity = spawnList[j].entity;
                            spawnList[j] = spawnList[spawnList.Length-1];
                            spawnList.RemoveAt(spawnList.Length - 1);
                            break;
                        }
                    }
                    ghosts[i] = ghost;
                }
            }
        }).Schedule(Dependency);
    }
}
  • This code implements the steps described in the official Unity NetCode documentation

    • It's a bit weird to follow, but no need to sweat it; this is just what needs to be done with predicted spawning

      • If you have other predicted-spawning objects you want to include in your project (i.e. not bullets) just make sure to follow the same steps as in this system

  • Navigate to Multiplayer > PlayMode Tools and make sure "Num Thin Clients" is at 0 and save

  • Hit play, then hit spacebar to spawn bullets

  • Although we are able to spawn bullets at first, after a while errors appear, what gives?

    • This is because our client is destroying bullets in BulletAgeSystem

      • We know that only the server can make those kind of decisions, so we will fix that in the next section

  • Now for some housekeeping:

    • Move into Mixed/Components

      • BulletTag

      • BulletAgeComponent

      • BulletAuthoringComponent

      • PlayerStateAndOffsetComponent

    • Create a new folder in Scripts and Prefabs called "Authoring" (same level as Client/Mixed/Server/Multiplayer Setup)

      • Here we will start moving items that aren't necessary at runtime, just during authoring

      • Move SetBulletSpawnOffset into the "Authoring" folder

      • Move SetGameSettingsSystem into the "Authoring" folder

  • No gif here, we believe in you 💪

We can now predicted-spawn bullets

  • We updated our Bullet Prefab

  • We updated InputSystem and removed rate-limiting

  • We updated BulletOffsetComponent to PlayerStateAndOffsetComponent

  • Changed the added component in SetBulletSpawnOffset

  • Created InputResponseSpawnSystem

  • Created BulletGhostSpawnClassificationSystem

Updating destruction workflows

Bullet destruction

  • We will need to update our BulletAgeSystem to run only on the server

    • The server must be the authority on when entities are destroyed, not the client

    • Move BulletAgeSystem into the Server/Systems folder

  • Update BulletAgeSystem.cs by pasting in the code snippet below:

using Unity.Entities;
using Unity.NetCode;

//Only our server will decide when bullets die of old age
[UpdateInGroup(typeof(ServerSimulationSystemGroup))]
public partial class BulletAgeSystem : SystemBase
{
    //We will be using the BeginSimulationEntityCommandBuffer to record our structural changes
    private BeginSimulationEntityCommandBufferSystem m_BeginSimEcb;
    
    //This is a special NetCode group that provides a "prediction tick" and a fixed "DeltaTime"
    private GhostPredictionSystemGroup m_PredictionGroup;

    protected override void OnCreate()
    {
        //Grab the CommandBuffer for structural changes
        m_BeginSimEcb = World.GetOrCreateSystem<BeginSimulationEntityCommandBufferSystem>();

        //We will grab this system so we can use its "DeltaTime"
        m_PredictionGroup = World.GetOrCreateSystem<GhostPredictionSystemGroup>();
    }

    protected override void OnUpdate()
    {
        //We create our CommandBuffer and add .AsParallelWriter() because we will be scheduling parallel jobs
        var commandBuffer = m_BeginSimEcb.CreateCommandBuffer().AsParallelWriter();

        //We must declare local variables before using them in the job below
        var deltaTime = m_PredictionGroup.Time.DeltaTime;

        //Our query writes to the BulletAgeComponent
        //The reason we don't need to add .WithAll<BulletTag>() here is because referencing the BulletAgeComponent
        //requires the Entities to have a BulletAgeComponent and only Bullets have those
        Entities.ForEach((Entity entity, int nativeThreadIndex, ref BulletAgeComponent age) =>
        {
            age.age += deltaTime;
            if (age.age > age.maxAge)
                commandBuffer.DestroyEntity(nativeThreadIndex, entity);

        }).ScheduleParallel();
        m_BeginSimEcb.AddJobHandleForProducer(Dependency);
    }
}

Player destruction

  • Currently we have PlayerDestructionSystem running on both the client and the server

    • Let's update this system so that it only runs on the server

    • We'll also set the NCE CommandTargetComponent's targetEntity back to being equal to null (how it was before we spawned a player). Think of this as a "clean up"

    • It needs to be set back to null because if not, the server will not respond to any more player spawn requests from that NCE

      • This is because the server checks if the NCE is null before it spawns a player in PlayerSpawnSystem (that is one of those 4 checks)

    • First move the PlayerDestructionSystem file to the Server/Systems folder

  • Paste the code snippet below into PlayerDestructionSystem.cs:

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

//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;    

    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 are going to need to update the NCE CommandTargetComponent so we set the argument to false (not read-only)
        var commandTargetFromEntity = GetComponentDataFromEntity<CommandTargetComponent>(false);

        //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"
        Entities
        .WithNativeDisableParallelForRestriction(commandTargetFromEntity)
        .WithAll<DestroyTag, PlayerTag>()
        .ForEach((Entity entity, int entityInQueryIndex, in PlayerEntityComponent playerEntity) =>
        {
            // 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;

            //Then destroy the entity
            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);
    
    }
}
  • Hit play, shoot around, self-destruct, and re-spawn to check out the updates

  • You might notice that sometimes asteroids and players sometimes turn red, but do not get destroyed. What's up with that?? Doesn't changing the render mesh to red mean the bullet and the object collided?!

    • Remember that both the client and server are running ChangeMaterialAndDestroySystem

    • So at times a client might "predict" that a bullet collided with an object and change the render mesh of the collided object

    • But the server calculated that those two entities did not actually collide and so the server does not add the "DestroyTag" to the object, and therefore it does not get destroyed

    • The render mesh of the object is not ghosted

      • That means the value of the render mesh is not synchronized between the server and clients

    • So because the client predicted something would happen that did not actually happen, we are left with red objects that do not actually get destroyed

    • We could build a workflow that changes the render mesh back to the appropriate color if the server does not confirm the hit, but that's out of scope of this gitbook

      • If you want to do this yourself and you have a cool solution you're willing to share, please let us know in the Moetsi Discord

  • Now let's add 2 Thin Clients and make sure everything still functions with Thin Clients

    • Go to Multiplayer menu > PlayMode Tools > Type 2 in the Num Thin Clients field

  • Boy, do those guys zip around!

  • You will notice that based on the randomly-generated inputs in InputSystem the Thin Clients accelerate into their own bullets which causes them to get destroyed

    • This is actually useful for testing because they continue spawning as you continue to try getting shot and try shooting them. This helps ensure you that all workflows are functioning

  • Final housekeeping in this section:

    • Move ChangeMaterialAndDestroySystem to Mixed/Systems

    • Move StatefulCollisionEventBufferAuthoring to Authoring

    • Move StatefulTriggerEventBufferAuthoring to Authoring

    • Move StatefulTriggerEventBufferSystem to Mixed/Systems

    • Move StatefulCollisionEventBufferSystem to Mixed/Systems

    • Move to Mixed/Components

      • IStatefulSimulationEvent

      • StatefulCollisionEvent

      • StatefulSimulationEventBuffers

      • StatefulTriggerEvent

  • No gif here, we believe in you 💪

We now are able to destroy bullets and players in NetCode

  • We updated BulletAgeSystem to run on server

  • We updated PlayerDestructionSystem to run on the server

Github branch link:

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

Last updated