DOTS NetCode and Player Prefabs

Code and workflows to turn the Player prefab into a NetCode ghost and spawn Thin Clients

What you'll develop on this page

We will update our Player prefab by "turning it into" a client-predicted ghost which spawns and moves by commands sent from the client.

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

NetCode client-predicted model background

Our player will be client-predicted. This means we will be able to move and shoot with immediate feedback because the client will predict what will happen when it issues commands.

How is that possible? I thought the server was the authority, clients can't do what they want!

True (and way to go!) This is why the clients are only "predicting" what will happen based on the user's input (commands, like up, down, left, right arrow keys on a keyboard). The server makes the ultimate decision of what actually happened (by ingesting commands from all clients and deciding the truth).

You are probably sick of our suggestions (pleas?) to watch Timothy Ford's talk if you haven't already watched it...

But if you have gotten this far and STILL don't know what the heck is going on with predicted-clients, do yourself a favor and checkout Timothy Ford's talk:

Watch Timothy Ford's talk from 24:15 to 33:05, seriously

Spawning

Although eventually movement and shooting will be "instant" on the client (predicted), the first step, Spawning a player entity, happens as a result of the client sending an RPC to the server.

Similar to how we updated ServerSendGameSystem in the last Section to send the newly connected client an RPC to load the game, we will do same here with Player; the client will send the server an RPC to spawn it a player. Once the server spawns the client's player entity, NetCode will send the entity to all clients, but the client that requested it will have a special version of the player entity that has a "PredictedGhostComponent" attached. This is a special NetCode component that we can use to know which ghosted entities are "owned" (predicted) by the clients. So of all the player entities in ClientWorld (as many as there are connected clients) only 1 entity will have the PredictedGhostComponent (the client's player entity).

Prediction in a multiplayer games means that the client is running the same simulation as the server for the local player. The purpose of running the simulation on the client is so it can predictively apply inputs to the local player right away to reduce the input latency.

Prediction should only run for entities which have the PredictedGhostComponent. Unity adds this component to all predicted ghosts on the client and to all ghosts on the server. On the client, the component also contains some data it needs for the prediction - such as which snapshot has been applied to the ghost.

The prediction is based on a GhostPredictionSystemGroup which always runs at a fixed timestep to get the same results on the client and server.

From NetCode's Prediction documentation

Then we will use Auto Command Target to attach ICommandData to our player, and have NetCode automatically send those commands to the Server.

We USED to say (pre v.50)

"We will then update the NCE's CommandTargetComponent's targetEntity to point at that entity in the a new PlayerGhostSpawnClassificationSystem. The server will also update its NCE's CommandTargetComponent's targetEntity field to point at the spawned entity on the server.

The CommandTargetComponent points to where the Commands sent from a client should be stored. We will be storing them in the player entities."

Now NetCode allows us to send multiple command streams just by where we attach the ICommand Data, much easier!

We will also need to update our player's camera. Currently the camera is part of the Player prefab. If we leave our Player prefab like this every time a remote client appears in ClientWorld the camera will change to that new remote client's camera (because Unity switches to the last activated camera automatically). Instead we will remove the camera from the Player prefab and instead add it to the player during PlayerGhostSpawnClassification. We will store a reference to the camera in PrefabCollection.

Movement

InputSpawnSystem and InputMovementSystem will no longer capture input and update state based on that input. Instead, client inputs will be stored as "PlayerCommands" in a new "InputSystem." The Commands are then sent to the server to playback. The server will use InputSpawnSystem and InputMovementSystem to play pack commands and update the game state. The systems will also be run by the client to "predict" what will happen. If there are any "disagreements" about what happened between the client and the server NetCode updates the state on the client to match the server.

Command stream

The client continuously sends a command stream to the server. This stream includes all inputs and acknowledgements of the last received snapshot. When no commands are sent a NullCommandSendSystem sends acknowledgements for received snapshots without any inputs. This is an automatic system to make sure the flow works automatically when the game does not need to send any inputs.

To create a new input type, create a struct that implements the ICommandData interface. To implement that interface you need to provide a property for accessing the Tick.

The serialization and registration code for the ICommandData will be generated automatically, but it is also possible to disable that and write the serialization manually.

If you add your ICommandData component to a ghost which has Has Owner and Support Auto Command Target enabled in the autoring component the commands for that ghost will automatically be sent if the ghost is owned by you, is predicted, and AutoCommandTarget.Enabled has not been set to false.

If you are not using Auto Command Target, your game code must set the CommandTargetComponent on the connection entity to reference the entity that the ICommandData component has been attached to.

You can have multiple command systems, and NetCode selects the correct one based on the ICommandData type of the entity that points to CommandTargetComponent.

When you need to access inputs on the client and server, it is important to read the data from the ICommandData rather than reading it directly from the system. If you read the data from the system the inputs won’t match between the client and server so your game will not behave as expected.

When you need to access the inputs from the buffer, you can use an extension method for DynamicBuffer<ICommandData> called GetDataAtTick which gets the matching tick for a specific frame. You can also use the AddCommandData utility method which adds more commands to the buffer.

From NetCode's Command stream documentation

Thin Clients

This is an experimental feature in NetCode's Multiplayer PlayMode Tools.

Previously in the "Create a Socket Connection" section, you saw how we could add "Thin Clients," which produced more ClientWorlds and NCEs. This is part of Unity's effort to to build more tools to help developers build multiplayer games (nice!)

Currently this functionality is not well-documented and still being ironed out by Unity, so we at Moetsi have read in-between the lines from Unity sample projects and have broken down the explanation as follows:

A "Thin Client" will contain a Singleton "ThinClientComponent" in its ClientWorld. NetCode automatically adds this Singleton when Multiplayer PlayMode Tools has Num Thin Clients > 0.

When creating input systems you must check for the Singleton ThinClientComponent, and if it exists you can create mock inputs to simulate client behavior.

As mentioned here by the DOTS NetCode team: "Thin clients just send the input stream to server, They don't have ghosts and they don't decompress snapshots. They can send RPC as normal client does (in case of asteroid, for the initial spawn and level loading)."

Because Thin clients do not get sent Ghosts, then we will not be able to use our "normal client" approach of sending ICommandData to the server (using the new, and awesome, Auto Command Target Approach). We will need to use the "old version" (setting the Command Target Component on the NCE).

So to keep your head on straight we will first implement spawning and commands for a "normal client". Then we will update our systems to account for thin clients.

Updating Player spawn with NetCode

Updating the Player prefab

  • First let's create PlayerEntityComponent in Mixed/Components. Paste this code snippet in the file

using Unity.Entities;
using Unity.NetCode;

[GenerateAuthoringComponent]
public struct PlayerEntityComponent : IComponentData
{
    public Entity PlayerEntity;
}
  • Open the Player prefab and move the Camera GameObject from Hierarchy into Scripts and Prefabs. Once moved into the folder, delete the Camera GameObject from the Player prefab in Hierarchy

  • Next add a GhostAuthoringComponent to the Player prefab

    • Name = Player

    • Importance = 90

    • Supported Ghost Modes = All

    • Default Ghost Mode = Owner Predicted

    • Optimization Mode = Dynamic

    • Check "Has Owner"

    • Check "Support Auto Command Target"

  • Finally add the PlayerEntityComponent to the prefab

Spawning a client-predicted player

  • Create a new component called "CameraAuthoringComponent" and put it in a new folder Client/Components

  • Paste the code snippet below into CameraAuthoringComponent.cs:

using Unity.Entities;
using UnityEngine;

[GenerateAuthoringComponent]
public struct CameraAuthoringComponent : IComponentData
{
    public Entity Prefab;
}
  • Navigate to PrefabCollection in ConvertedSubScene and add CameraAuthoringComponent

  • Drag the Camera prefab in Scripts and Prefabs onto the Prefab field in the CameraAuthoringComponent

  • Save, return to SampleScene and reimport ConvertedSubScene

  • Now let's make PlayerSpawnRequestRpc in Mixed/Commands. Paste the code snippet below into PlayerSpawnRequestRpc.cs:

using Unity.NetCode;
using Unity.Entities;

public struct PlayerSpawnRequestRpc : IRpcCommand
{
}
  • In a coming section, we will update InputSpawnSystem and InputMovementSystem to InputResponseSpawnSystem and InputResponseMovementSystem

  • For now let's delete InputSpawnSystem and InputMovementSystem so they do not interfere with our new work flows

  • Create a new system in Client/Systems named InputSystem

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


    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()
    {
    
        //We have removed the other inputs for now and will add them in the movement section
        byte shoot;
        shoot = 0;

        if (Input.GetKey("space"))
        {
            shoot = 1;
        };

        //Must declare local variables before using them in the .ForEach()
        var commandBuffer = m_BeginSimEcb.CreateCommandBuffer();

        TryGetSingletonEntity<PlayerCommand>(out var targetEntity);
        Job.WithCode(() => {
            if (targetEntity == Entity.Null && shoot != 0)
            {
                var req = commandBuffer.CreateEntity();
                commandBuffer.AddComponent<PlayerSpawnRequestRpc>(req);
                commandBuffer.AddComponent(req, new SendRpcCommandRequestComponent());

            }
        }).Schedule();

        //We need to add the jobs dependency to the command buffer
        m_BeginSimEcb.AddJobHandleForProducer(Dependency);
    }
}
  • Now let's create the system that will respond to the PlayerSpawnRequestRpc

  • Create a new system named PlayerSpawnSystem in the Server/Systems folder

  • Paste the code snippet below into PlayerSpawnSystem.cs:

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

//This tag is only used by the systems in this file so we define it here
public struct PlayerSpawnInProgressTag : IComponentData
{
}

//Only the server will be running this system to spawn the player
[UpdateInGroup(typeof(ServerSimulationSystemGroup))]
public partial class PlayerSpawnSystem : SystemBase
{

    private BeginSimulationEntityCommandBufferSystem m_BeginSimEcb;
    private Entity m_Prefab;

    protected override void OnCreate()
    {
        m_BeginSimEcb = World.GetOrCreateSystem<BeginSimulationEntityCommandBufferSystem>();

        //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>(); 
    }

    protected override void OnUpdate()
    {
        //Here we set the prefab we will use
        if (m_Prefab == Entity.Null)
        {
            //We grab the converted PrefabCollection Entity's PlayerAuthoringComponent
            //and set m_Prefab to its Prefab value
            m_Prefab = GetSingleton<PlayerAuthoringComponent>().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
        var commandBuffer = m_BeginSimEcb.CreateCommandBuffer();
        var playerPrefab = m_Prefab;
        var rand = new Unity.Mathematics.Random((uint) Stopwatch.GetTimestamp());
        var gameSettings = GetSingleton<GameSettingsComponent>();
        

        //GetComponentDataFromEntity allows us to grab data from an entity that we don't have access to
        //until we are within a job
        //We know we will need to get the PlayerSpawningStateComponent from an NCE but we don't know which one yet
        //So we create a variable that will get PlayerSpawningStateComponent from an entity
        var playerStateFromEntity = GetComponentDataFromEntity<PlayerSpawningStateComponent>();

        //Similar to playerStateFromEntity, these variables WILL get data from an entity (in the job below)
        //but do not have it currently
        var networkIdFromEntity = GetComponentDataFromEntity<NetworkIdComponent>();

        //We are looking for an Entity with a PlayerSpawnRequestRpc
        //That means the client associated with that NCE wants a player to be spawned for them
        Entities
        .ForEach((Entity entity, in PlayerSpawnRequestRpc request,
            in ReceiveRpcCommandRequestComponent requestSource) =>
        {
            //We immediately destroy the request so we act on it once
            commandBuffer.DestroyEntity(entity);

            // This makes sure that we don't act on another RPC from the same NCE before we finish spawning the first
            if (!playerStateFromEntity.HasComponent(requestSource.SourceConnection) ||
                playerStateFromEntity[requestSource.SourceConnection].IsSpawning != 0)
            {
                return;
            }

            //We create our player prefab
            var player = commandBuffer.Instantiate(playerPrefab);

            //We will spawn our player in the center-ish of our game
            var width = gameSettings.levelWidth * .2f;
            var height = gameSettings.levelHeight * .2f;
            var depth = gameSettings.levelDepth * .2f;
            

            var pos = new Translation
            {
                Value = new float3(rand.NextFloat(-width, width),
                    rand.NextFloat(-height, height), rand.NextFloat(-depth, depth))
            };

            //We will not spawn a random rotation for simplicity but include
            //setting rotation for you to be able to update in your own projects if you like
            var rot = new Rotation {Value = Quaternion.identity};

            //Here we set the componets that already exist on the Player prefab
            commandBuffer.SetComponent(player, pos);
            commandBuffer.SetComponent(player, rot);
            //This sets the GhostOwnerComponent value to the NCE NetworkId (how we know what client the player belongs to)
            commandBuffer.SetComponent(player, new GhostOwnerComponent {NetworkId = networkIdFromEntity[requestSource.SourceConnection].Value});
            //This sets the PlayerEntity value in PlayerEntityComponent to the NCE
            commandBuffer.SetComponent(player, new PlayerEntityComponent {PlayerEntity = requestSource.SourceConnection});

            //Here we add a component that was not included in the Player prefab, PlayerSpawnInProgressTag
            //This is a temporary tag used to make sure the entity was able to be created and will be removed
            //in PlayerCompleteSpawnSystem below    
            commandBuffer.AddComponent(player, new PlayerSpawnInProgressTag());

            //We update the PlayerSpawningStateComponent tag on the NCE to "has a spawning/spawned player" (1)
            playerStateFromEntity[requestSource.SourceConnection] = new PlayerSpawningStateComponent {IsSpawning = 1};
        }).Schedule();


        m_BeginSimEcb.AddJobHandleForProducer(Dependency);
    }
}

//We want to complete the spawn before ghosts are sent on the server
[UpdateInGroup(typeof(ServerSimulationSystemGroup))]
[UpdateBefore(typeof(GhostSendSystem))]
public partial class PlayerCompleteSpawnSystem : SystemBase
{
    private BeginSimulationEntityCommandBufferSystem m_BeginSimEcb;

    protected override void OnCreate()
    {
        m_BeginSimEcb = World.GetOrCreateSystem<BeginSimulationEntityCommandBufferSystem>();
    }

    protected override void OnUpdate()
    {
        var commandBuffer = m_BeginSimEcb.CreateCommandBuffer();

        //GetComponentDataFromEntity allows us to grab data from an entity that we don't have access to
        //until we are within a job
        //We don't know exactly which NCE we currently want to grab data from, but we do know we will want to
        //so we use GetComponentDataFromEntity to prepare ECS that we will be grabbing this data from an entity
        var playerStateFromEntity = GetComponentDataFromEntity<PlayerSpawningStateComponent>();
        var connectionFromEntity = GetComponentDataFromEntity<NetworkStreamConnection>();

        // This is used to help with clean up when players disconnect, we keep track
        var linkedEntityGroupFromEntity = GetBufferFromEntity<LinkedEntityGroup>();

        //Now the server checks for Players that are in the middle of spawning (haven't been sent yet)
        Entities
        .WithAll<PlayerSpawnInProgressTag>()
        .ForEach((Entity entity, in PlayerEntityComponent player) =>
            {
                //This is another check from Unity samples
                //This ensures there was no disconnect
                if (!playerStateFromEntity.HasComponent(player.PlayerEntity) ||
                    !connectionFromEntity[player.PlayerEntity].Value.IsCreated)
                {
                    //Player was disconnected during spawn, or other error so delete
                    commandBuffer.DestroyEntity(entity);
                    return;
                }

                //If there was no error with spawning the player we can remove the PlayerSpawnInProgressTag
                commandBuffer.RemoveComponent<PlayerSpawnInProgressTag>(entity);

                // We add the the player to the linked entity group (so we know what to delete when the player disconnects)
                var linkedEntityGroup = linkedEntityGroupFromEntity[player.PlayerEntity];
                linkedEntityGroup.Add(new LinkedEntityGroup {Value = entity});

            }).Schedule();
            
        m_BeginSimEcb.AddJobHandleForProducer(Dependency);
    }
} 
  • This file actually contains 2 systems:

    • PlayerSpawnSystem

    • PlayerCompleteSpawnSystem

  • PlayerSpawnSystem will instantiate the prefab and set the components on the Player prefab and add a PlayerSpawnInProgressTag

    • It does not fully commit to updating because first it will ensure that the entity made it over to the client without issues

  • PlayerCompleteSpawnSystem will check for any entities with a PlayerSpawnInProgressTag (which means the entity was created) and if they exist they will remove the tag and add it to the linked entity group (used for house keeping when a player disconnects)

  • Finally let's create PlayerGhostSpawnClassificationSystem in Client/Systems. Paste the code snippet below into the file:

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

//We are updating only in the client world because only the client must specify exactly which player entity it "owns"
[UpdateInWorld(TargetWorld.Client)]
//We will be updating after NetCode's GhostSpawnClassificationSystem because we want
//to ensure that the PredictedGhostComponent (which it adds) is available on the player entity to identify it
[UpdateInGroup(typeof(GhostSimulationSystemGroup))]
[UpdateAfter(typeof(GhostSpawnClassificationSystem))]
public partial class PlayerGhostSpawnClassificationSystem : SystemBase
{
    private BeginSimulationEntityCommandBufferSystem m_BeginSimEcb;

    //We will store the Camera prefab here which we will attach when we identify our player entity
    private Entity m_CameraPrefab;

    protected override void OnCreate()
    {
        m_BeginSimEcb = World.GetExistingSystem<BeginSimulationEntityCommandBufferSystem>();

        //We need to make sure we have NCE before we start the update loop (otherwise it's unnecessary)
        RequireSingletonForUpdate<NetworkIdComponent>();
        RequireSingletonForUpdate<CameraAuthoringComponent>();
    }

    protected override void OnUpdate()
    {
        //Here we set the prefab we will use
        if (m_CameraPrefab == Entity.Null)
        {
            //We grab our camera and set our variable
            m_CameraPrefab = GetSingleton<CameraAuthoringComponent>().Prefab;
            return;
        }
        
        var commandBuffer = m_BeginSimEcb.CreateCommandBuffer().AsParallelWriter();
        
        //We must declare our local variables before using them
        var camera = m_CameraPrefab;
        //The "playerEntity" is the NCE
        var networkIdComponent = GetSingleton<NetworkIdComponent>();
        //The false is to signify that the data will NOT be read-only
        var commandTargetFromEntity = GetComponentDataFromEntity<CommandTargetComponent>(false);

        //We will look for Player prefabs that we have not added a "PlayerClassifiedTag" to (which means we have checked the player if it is "ours")
        Entities
        .WithAll<PlayerTag>()
        .WithNone<PlayerClassifiedTag>()
        .ForEach((Entity entity, int entityInQueryIndex, in GhostOwnerComponent ghostOwnerComponent) =>
        {
            // If this is true this means this Player is mine (because the GhostOwnerComponent value is equal to the NetworkId)
            // Remember the GhostOwnerComponent value is set by the server and is ghosted to the client
            if (ghostOwnerComponent.NetworkId == networkIdComponent.Value)
            {
                //This creates our camera
                var cameraEntity = commandBuffer.Instantiate(entityInQueryIndex, camera);
                //This is how you "attach" a prefab entity to another
                commandBuffer.AddComponent(entityInQueryIndex, cameraEntity, new Parent { Value = entity });
                commandBuffer.AddComponent(entityInQueryIndex, cameraEntity, new LocalToParent() );
            }
            // This means we have classified this Player prefab
            commandBuffer.AddComponent(entityInQueryIndex, entity, new PlayerClassifiedTag() );

        }).ScheduleParallel();

        m_BeginSimEcb.AddJobHandleForProducer(Dependency);
    }
}
  • Hit play then hit space bar to spawn our player

  • Great. Now we are able to spawn our player entity and have it set up in NetCode

WARNING!

Sometimes you can get an error here where the camera stops working after updating PlayerGhostSpawnClassificationSystem (when we added the camera on the client). Instead of switching from the main camera to the newly-added player camera (that has been added by PlayerGhostSpawnClassificationSystem), the active camera stays as the Main Camera.

From our testing this has a 50% of happening. These are the steps we recommend you take to fix this issue:

  1. Reimport all assets (this sometimes fixes it, if not go to step 2)

  2. Quit out of Unity entirely, then reopen the Project (this should fix it most of the time, if not go to step 3)

  3. Restart your computer (yup that's right, somehow restarting the computer has been known to fix this camera issue 😔)

If you are still having issues with the new camera flow, ask us a question on Discord.

We can now spawn a client-predicted Player prefab

  • We updated our Player prefab by

    • Removing the Camera GameObject

    • Adding a GhostAuthoringComponent

    • Adding a PlayerEntityComponent

  • Created a CameraAuthoringComponent and adding it to the PrefabCollection

  • Created PlayerSpawnRequestRpc

  • Deleted InputSpawnSystem and InputMovementSystem

  • Created InputSystem

  • Created PlayerSpawnSystem

  • Created PlayerGhostSpawnClassificationSystem

Updating player movement

  • Let's start by creating the ICommandData component that will store our input Commands in the Mixed/Components folder

  • Name it PlayerCommand

  • Paste the code snippet below into PlayerCommand.cs:

using Unity.Networking.Transport;
using Unity.NetCode;
using Unity.Burst;
using Unity.Entities;
using Unity. Transforms;
using Unity.Mathematics;


[GhostComponent(PrefabType = GhostPrefabType.AllPredicted)]
public struct PlayerCommand : ICommandData
{
    public uint Tick {get; set;}
    public byte right;
    public byte left;
    public byte thrust;
    public byte reverseThrust;
    public byte selfDestruct;
    public byte shoot;
    public float mouseX;
    public float mouseY;
}
  • Next let's create an authoring component in the Mixed/Components folder that will add a buffer of PlayerCommands to whatever prefab we add it to

  • Name it PlayerCommandBufferAuthoringComponent

using Unity.Entities;
using UnityEngine;

public class PlayerCommandBufferAuthoringComponent : MonoBehaviour, IConvertGameObjectToEntity
{
    public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
    {
        dstManager.AddBuffer<PlayerCommand>(entity);
    }
}
  • We need to add our PlayerCommandBufferAuthoringComponent to our Player prefab (navigate to Player prefab and click "Add Component" in the Inspector)

  • Let's do a quick review of NetCode's prediction handling to make sense of ".ShouldPredict()" in

Prediction

Prediction in a multiplayer games means that the client is running the same simulation as the server for the local player. The purpose of running the simulation on the client is so it can predictively apply inputs to the local player right away to reduce the input latency.

Prediction should only run for entities which have the PredictedGhostComponent. Unity adds this component to all predicted ghosts on the client and to all ghosts on the server. On the client, the component also contains some data it needs for the prediction - such as which snapshot has been applied to the ghost.

The prediction is based on a GhostPredictionSystemGroup which always runs at a fixed timestep to get the same results on the client and server.

Client

The basic flow on the client is:

  • NetCode applies the latest snapshot it received from the server to all predicted entities.

  • While applying the snapshots, NetCode also finds the oldest snapshot it applied to any entity.

  • Once NetCode applies the snapshots, the GhostPredictionSystemGroup runs from the oldest tick applied to any entity, to the tick the prediction is targeting.

  • When the prediction runs, the GhostPredictionSystemGroup sets the correct time for the current prediction tick in the ECS TimeData struct. It also sets GhostPredictionSystemGroup.PredictingTick to the tick being predicted.

Because the prediction loop runs from the oldest tick applied to any entity, and some entities might already have newer data, you must check whether each entity needs to be simulated or not. To perform these checks, call the static method GhostPredictionSystemGroup.ShouldPredict before updating an entity. If it returns false the update should not run for that entity.

If an entity did not receive any new data from the network since the last prediction ran, and it ended with simulating a full tick (which is not always true when you use a dynamic timestep), the prediction continues from where it finished last time, rather than applying the network data.

Server

On the server the prediction loop always runs exactly once, and does not update the TimeData struct because it is already correct. It still sets GhostPredictionSystemGroup.PredictingTick to make sure the exact same code can be run on both the client and server.

From NetCode's Prediction documentation

  • Drag the InputSystem file into the Client/Systems folder

  • Next let's update InputSystem by pasting 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 are going to use this to rate limit bullets per second
        //We could have included this in the game settings, no "ECS reason" not to
        private float m_PerSecond = 10f;
        private float m_NextTime = 0;

    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()
    {
    
        //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;

        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");

        }
        
        //we are going to implement rate limiting for shooting
        var canShoot = false;
        if (UnityEngine.Time.time >= m_NextTime)
        {
            canShoot = true;
            m_NextTime += (1/m_PerSecond);
        }

        //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 (targetEntity == Entity.Null)
        {
            if (shoot != 0)
            {
                var req = commandBuffer.CreateEntity();
                commandBuffer.AddComponent<PlayerSpawnRequestRpc>(req);
                commandBuffer.AddComponent(req, new SendRpcCommandRequestComponent());
            }
        }
        else
        {
            if (shoot == 1 && canShoot)
                shoot = 1;
            else
                shoot = 0;
            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);
    }
}
  • This updated InputSystem is pretty intense, so take another look at the Command documentation to get a better sense of what's going on

Command stream

The client continuously sends a command stream to the server. This stream includes all inputs and acknowledgements of the last received snapshot. When no commands are sent a NullCommandSendSystem sends acknowledgements for received snapshots without any inputs. This is an automatic system to make sure the flow works automatically when the game does not need to send any inputs.

To create a new input type, create a struct that implements the ICommandData interface. To implement that interface you need to provide a property for accessing the Tick.

The serialization and registration code for the ICommandData will be generated automatically, but it is also possible to disable that and write the serialization manually.

If you add your ICommandData component to a ghost which has Has Owner and Support Auto Command Target enabled in the autoring component the commands for that ghost will automatically be sent if the ghost is owned by you, is predicted, and AutoCommandTarget.Enabled has not been set to false.

If you are not using Auto Command Target, your game code must set the CommandTargetComponent on the connection entity to reference the entity that the ICommandData component has been attached to.

You can have multiple command systems, and NetCode selects the correct one based on the ICommandData type of the entity that points to CommandTargetComponent.

When you need to access inputs on the client and server, it is important to read the data from the ICommandData rather than reading it directly from the system. If you read the data from the system the inputs won’t match between the client and server so your game will not behave as expected.

When you need to access the inputs from the buffer, you can use an extension method for DynamicBuffer<ICommandData> called GetDataAtTick which gets the matching tick for a specific frame. You can also use the AddCommandData utility method which adds more commands to the buffer.

From NetCode's Command stream documentation

  • You can see why we need to add tick data in ICommandData

    • This is how NetCode knows "when" the Command came

  • We need to create InputResponseMovementSystem

    • Both the server and the client use this system so put the file in Mixed/Systems folder

  • Paste the code snippet below into InputResponseMovementSystem.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;

//InputResponseMovementSystem runs on both the Client and Server
//It is predicted on the client but "decided" on the server
[UpdateInWorld(TargetWorld.ClientAndServer)]
// [UpdateInGroup(typeof(PredictedPhysicsSystemGroup))]
// want to change the Velocity BEFORE the physics is run (which happens after BuildPhysicsWorld)
// so it is not like the input had no affect on the player for a frame), so we run before BuildPhysicsWorld
[UpdateInGroup(typeof(FixedStepSimulationSystemGroup))]
[UpdateBefore(typeof(BuildPhysicsWorld))]
public partial class InputResponseMovementSystem : SystemBase
{
    //This is a special NetCode group that provides a "prediction tick" and a fixed "DeltaTime"
    private GhostPredictionSystemGroup m_PredictionGroup;
     

    protected override void OnCreate()
    {
        // m_BeginSimEcb = World.GetOrCreateSystem<BeginSimulationEntityCommandBufferSystem>();

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

        // Creating this Singleton is what allows the client to predict physics
        // Once the singleton is present, all the physics systems are moved into a new group inside the GhostPredictionSystemGroup that run in sync with the ghost simulation as expected.
        // The PhysicsVelocity is replicated to all clients and it is the only thing it necessary to sync the physic state.
        Entity physicsSingleton = EntityManager.CreateEntity();
        EntityManager.AddComponentData(physicsSingleton, new PredictedPhysicsConfig {});
        
    }

    protected override void OnUpdate()
    {
        //No need for a CommandBuffer because we are not making any structural changes to any entities
        //We are setting values on components that already exist
        // var commandBuffer = m_BeginSimEcb.CreateCommandBuffer().AsParallelWriter();

        //These are special NetCode values needed to work the prediction system
        var currentTick = m_PredictionGroup.PredictingTick;
        var deltaTime = m_PredictionGroup.Time.DeltaTime;

        //We must declare our local variables before the .ForEach()
        var playerForce = GetSingleton<GameSettingsComponent>().playerForce;

        //We will grab the buffer of player commands from the player 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 Rotation rotation, ref PhysicsVelocity velocity,
                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;

            if (inputData.right == 1)
            {   //thrust to the right of where the player is facing
                velocity.Linear += math.mul(rotation.Value, new float3(1,0,0)).xyz * playerForce * deltaTime;                
            }
            if (inputData.left == 1)
            {   //thrust to the left of where the player is facing
                velocity.Linear += math.mul(rotation.Value, new float3(-1,0,0)).xyz * playerForce * deltaTime;
            }
            if (inputData.thrust == 1)
            {   //thrust forward of where the player is facing
                velocity.Linear += math.mul(rotation.Value, new float3(0,0,1)).xyz * playerForce * deltaTime;
            }
            if (inputData.reverseThrust == 1)
            {   //thrust backwards of where the player is facing
                velocity.Linear += math.mul(rotation.Value, new float3(0,0,-1)).xyz * playerForce * deltaTime;
            }

            
            if (inputData.mouseX != 0 || inputData.mouseY != 0)
            {   //move the mouse
                //here we have "hardwired" the look speed, we could have included this in the GameSettingsComponent to make it configurable
                float lookSpeedH = 2f;
                float lookSpeedV = 2f;
                Quaternion currentQuaternion = rotation.Value; 
                float yaw = currentQuaternion.eulerAngles.y;
                float pitch = currentQuaternion.eulerAngles.x;

                //MOVING WITH MOUSE
                yaw += lookSpeedH * inputData.mouseX;
                pitch -= lookSpeedV * inputData.mouseY;
                Quaternion newQuaternion = Quaternion.identity;
                newQuaternion.eulerAngles = new Vector3(pitch,yaw, 0);
                rotation.Value = newQuaternion;
            }
           
        }).ScheduleParallel();

        //No need to .AddJobHandleForProducer() because we did not need a CommandBuffer to make structural changes
    }
    
}
  • The client "predicts" the movement but the server ultimately decides game state by sending back ghost Snapshots of correct game state

  • Navigate to GameSettings in ConvertedSubScene increase the Player Force to 20 to make the player controls feel a bit more "zippy"

  • Reimport ConvertedSubScene and hit "play"

  • We are able to spawn and move around through ICommandData

  • Now let's do some clean up

    • Move PlayerTag into Mixed/Components

    • Move PlayerAuthoringComponent to Server/Components

      • You will likely need to update the prefab with these scripts because it will lose track of them

  • No gif here, we believe in you 💪

We can now spawn a client-predicted Player prefab and move it through commands

  • We added a GhostAuthoring component on our Player prefab

  • We created PlayerSpawnRequest

  • We created PlayerCommand

  • We merged InputSpawnSystem and InputMovementSystem into InputSystem

  • We created InputResponseMovementSystem system

Updating Systems to Handle Thin Clients

As mentioned earlier:

As mentioned here by the DOTS NetCode team: "Thin clients just send the input stream to server, They don't have ghosts and they don't decompress snapshots. They can send RPC as normal client does (in case of asteroid, for the initial spawn and level loading)."

Because Thin clients do not get sent Ghosts, then we will not be able to use our "normal client" approach of sending ICommandData to the server (using the new, and awesome, Auto Command Target Approach). We will need to use the "old version" (setting the Command Target Component on the NCE).

  • Let's update InputSystem.cs to generate mock data if we are a thin client

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 are going to use this to rate limit bullets per second
    //We could have included this in the game settings, no "ECS reason" not to
    private float m_PerSecond = 10f;
    private float m_NextTime = 0;
    //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 going to implement rate limiting for shooting
        var canShoot = false;
        if (UnityEngine.Time.time >= m_NextTime)
        {
            canShoot = true;
            m_NextTime += (1/m_PerSecond);
        }

        //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
        {
            if (shoot == 1 && canShoot)
                shoot = 1;
            else
                shoot = 0;

            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);
    }
}
  • Next let's update the PlayerSpawnSystem.cs

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

//This tag is only used by the systems in this file so we define it here
public struct PlayerSpawnInProgressTag : IComponentData
{
}

//Only the server will be running this system to spawn the player
[UpdateInGroup(typeof(ServerSimulationSystemGroup))]
public partial class PlayerSpawnSystem : SystemBase
{

    private BeginSimulationEntityCommandBufferSystem m_BeginSimEcb;
    private Entity m_Prefab;

    protected override void OnCreate()
    {
        m_BeginSimEcb = World.GetOrCreateSystem<BeginSimulationEntityCommandBufferSystem>();


        //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>(); 
    }

    protected override void OnUpdate()
    {
        //Here we set the prefab we will use
        if (m_Prefab == Entity.Null)
        {
            //We grab the converted PrefabCollection Entity's PlayerAuthoringComponent
            //and set m_Prefab to its Prefab value
            m_Prefab = GetSingleton<PlayerAuthoringComponent>().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
        var commandBuffer = m_BeginSimEcb.CreateCommandBuffer();
        var playerPrefab = m_Prefab;
        var rand = new Unity.Mathematics.Random((uint) Stopwatch.GetTimestamp());
        var gameSettings = GetSingleton<GameSettingsComponent>();

        //GetComponentDataFromEntity allows us to grab data from an entity that we don't have access to
        //until we are within a job
        //We know we will need to get the PlayerSpawningStateComponent from an NCE but we don't know which one yet
        //So we create a variable that will get PlayerSpawningStateComponent from an entity
        var playerStateFromEntity = GetComponentDataFromEntity<PlayerSpawningStateComponent>();

        //Similar to playerStateFromEntity, these variables WILL get data from an entity (in the job below)
        //but do not have it currently
        var commandTargetFromEntity = GetComponentDataFromEntity<CommandTargetComponent>();
        var networkIdFromEntity = GetComponentDataFromEntity<NetworkIdComponent>();

        //We are looking for NCEs with a PlayerSpawnRequestRpc
        //That means the client associated with that NCE wants a player to be spawned for them
        Entities
        .ForEach((Entity entity, in PlayerSpawnRequestRpc request,
            in ReceiveRpcCommandRequestComponent requestSource) =>
        {
            //We immediately destroy the request so we act on it once
            commandBuffer.DestroyEntity(entity);

            //These are checks to see if the NCE has disconnected or if there are any other issues
            //These checks are pulled from Unity samples and we have left them in even though they seem
            //Is there a PlayerSpawningState on the NCE
            //Is there a CommandTargetComponent on the NCE
            //Is the CommandTargetComponent targetEntity != Entity.Null
            //Is the PlayerSpawningState == 0
            //If all those are true we continue with spawning, otherwise we don't

            if (!playerStateFromEntity.HasComponent(requestSource.SourceConnection) ||
                !commandTargetFromEntity.HasComponent(requestSource.SourceConnection) ||
                commandTargetFromEntity[requestSource.SourceConnection].targetEntity != Entity.Null ||
                playerStateFromEntity[requestSource.SourceConnection].IsSpawning != 0)
                return;

            //We create our player prefab
            var player = commandBuffer.Instantiate(playerPrefab);

            //We will spawn our player in the center-ish of our game
            var width = gameSettings.levelWidth * .2f;
            var height = gameSettings.levelHeight * .2f;
            var depth = gameSettings.levelDepth * .2f;
            

            var pos = new Translation
            {
                Value = new float3(rand.NextFloat(-width, width),
                    rand.NextFloat(-height, height), rand.NextFloat(-depth, depth))
            };

            //We will not spawn a random rotation for simplicity but include
            //setting rotation for you to be able to update in your own projects if you like
            var rot = new Rotation {Value = Quaternion.identity};

            //Here we set the componets that already exist on the Player prefab
            commandBuffer.SetComponent(player, pos);
            commandBuffer.SetComponent(player, rot);
            //This sets the GhostOwnerComponent value to the NCE NetworkId
            commandBuffer.SetComponent(player, new GhostOwnerComponent {NetworkId = networkIdFromEntity[requestSource.SourceConnection].Value});
            //This sets the PlayerEntity value in PlayerEntityComponent to the NCE
            commandBuffer.SetComponent(player, new PlayerEntityComponent {PlayerEntity = requestSource.SourceConnection});

            //Here we add a component that was not included in the Player prefab, PlayerSpawnInProgressTag
            //This is a temporary tag used to make sure the entity was able to be created and will be removed
            //in PlayerCompleteSpawnSystem below    
            commandBuffer.AddComponent(player, new PlayerSpawnInProgressTag());

            //We update the PlayerSpawningStateComponent tag on the NCE to "currently spawning" (1)
            playerStateFromEntity[requestSource.SourceConnection] = new PlayerSpawningStateComponent {IsSpawning = 1};
        }).Schedule();


        m_BeginSimEcb.AddJobHandleForProducer(Dependency);
    }
}

//We want to complete the spawn before ghosts are sent on the server
[UpdateInGroup(typeof(ServerSimulationSystemGroup))]
[UpdateBefore(typeof(GhostSendSystem))]
public partial class PlayerCompleteSpawnSystem : SystemBase
{
    private BeginSimulationEntityCommandBufferSystem m_BeginSimEcb;

    protected override void OnCreate()
    {
        m_BeginSimEcb = World.GetOrCreateSystem<BeginSimulationEntityCommandBufferSystem>();
    }

    protected override void OnUpdate()
    {
        var commandBuffer = m_BeginSimEcb.CreateCommandBuffer();

        //GetComponentDataFromEntity allows us to grab data from an entity that we don't have access to
        //until we are within a job
        //We don't know exactly which NCE we currently want to grab data from, but we do know we will want to
        //so we use GetComponentDataFromEntity to prepare ECS that we will be grabbing this data from an entity
        var playerStateFromEntity = GetComponentDataFromEntity<PlayerSpawningStateComponent>();
        var commandTargetFromEntity = GetComponentDataFromEntity<CommandTargetComponent>();
        var connectionFromEntity = GetComponentDataFromEntity<NetworkStreamConnection>();

        Entities.WithAll<PlayerSpawnInProgressTag>().
            ForEach((Entity entity, in PlayerEntityComponent player) =>
            {
                // This is another check from Unity samples
                // This ensures there was no disconnect
                if (!playerStateFromEntity.HasComponent(player.PlayerEntity) ||
                    !connectionFromEntity[player.PlayerEntity].Value.IsCreated)
                {
                    //Player was disconnected during spawn, or other error so delete
                    commandBuffer.DestroyEntity(entity);
                    return;
                }

                //If there was no error with spawning the player we can remove the PlayerSpawnInProgressTag
                commandBuffer.RemoveComponent<PlayerSpawnInProgressTag>(entity);

                //We now update the NCE to point at our player entity
                commandTargetFromEntity[player.PlayerEntity] = new CommandTargetComponent {targetEntity = entity};
                //We can now say that our player is no longer spawning so we set IsSpawning = 0 on the NCE
                playerStateFromEntity[player.PlayerEntity] = new PlayerSpawningStateComponent {IsSpawning = 0};
            }).Schedule();
            
        m_BeginSimEcb.AddJobHandleForProducer(Dependency);
    }
}
  • Note that the PlayerSpawnSystem checks the NCE to see if the CommandTargetComponent targetEntity has been set to see if there is already an active player for a Network Connection

    • In this way the server makes sure it doesn't spawn more than 1 player per Network Connection

  • Now we can support Thin Clients, update PlayMode tools and add Thin Clients, hit play, and checkout the Players go!

We can now spawn and move thin clients by generated mock data

  • We updated InputSystem

  • We updated PlayerSpawnSystem

Github branch link:

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

Last updated