Load a Game using DOTS NetCode

Code and workflow to send server game data to client and have the client confirm loading the data

What you'll develop on this page

We will send server game data to the client; the client will load the data and send back another RPC.

Github branch link: https://github.com/moetsi/Unity-DOTS-Multiplayer-XR-Sample/tree/Loading-a-Game

Loading a game

First, some background:

When a socket connection is made in NetCode the server and client both have the ability to send each other RPCs.

NetCode uses a limited form of RPCs to handle events. A job on the sending side can issue RPCs, and they then execute on a job on the receiving side. This limits what you can do in an RPC; such as what data you can read and modify, and what calls you are allowed to make from the engine. For more information on the Job System see the Unity User Manual documentation on the C# Job System.

To make the system a bit more flexible, you can use the flow of creating an entity that contains specific netcode components such as SendRpcCommandRequestComponent and ReceiveRpcCommandRequestComponent, which this page outlines.

...

To send the command you need to create an entity and add the command and the special component SendRpcCommandRequestComponent to it. This component has a member called TargetConnection that refers to the remote connection you want to send this command to.

NOTE

If TargetConnection is set to Entity.Null you will broadcast the message. On a client you don't have to set this value because you will only send to the server.

...

The RpcSystem automatically finds all of the requests, sends them, and then deletes the send request. On the remote side they show up as entities with the same IRpcCommand and a ReceiveRpcCommandRequestComponent which you can use to identify which connection the request was received from.

...

The code generation for RPCs is optional, if you do not wish to use it you need to create a component and a serializer. These can be the same struct or two different ones. To create a single struct which is both the component and the serializer you would need to add:

[BurstCompile]
public struct OurRpcCommand : IComponentData, IRpcCommandSerializer<OurRpcCommand>
{
    public void Serialize(ref DataStreamWriter writer, in OurRpcCommand data)
    {
    }

    public void Deserialize(ref DataStreamReader reader, ref OurRpcCommand data)
    {
    }

    public PortableFunctionPointer<RpcExecutor.ExecuteDelegate> CompileExecute()
    {
    }

    [BurstCompile(DisableDirectCall = true)]
    private static void InvokeExecute(ref RpcExecutor.Parameters parameters)
    {
    }

    static PortableFunctionPointer<RpcExecutor.ExecuteDelegate> InvokeExecuteFunctionPointer = new PortableFunctionPointer<RpcExecutor.ExecuteDelegate>(InvokeExecute);
}

The IRpcCommandSerializer interface has three methods: Serialize, Deserialize, and CompileExecute. Serialize and Deserialize store the data in a packet, while CompileExecute uses Burst to create a FunctionPointer. The function it compiles takes a RpcExecutor.Parameters by ref that contains:

  • DataStreamReader reader

  • Entity connection

  • EntityCommandBuffer.Concurrent commandBuffer

  • int jobIndex

Because the function is static, it needs to use Deserialize to read the struct data before it executes the RPC. The RPC then either uses the command buffer to modify the connection entity, or uses it to create a new request entity for more complex tasks. It then applies the command in a separate system at a later time. This means that you don’t need to perform any additional operations to receive an RPC; its Execute method is called on the receiving end automatically.

From NetCode RPCs documentation

Not sure how you feel about it, but that RPCs documentation is TOUGH 🤯

Basically here's the gist: once you have a client/server connection you can send RPCs back and forth.

If you want to send RPCs with "just" data, NetCode has solid code generation that makes it pretty easy to do. We will do that in this project when the server sends the client the game settings.

If you want your RPCs to also execute code when they get to their intended receiver, then boilerplate code from the (confusing) explanation is involved. You'll see how this works at the point in our project when the client sends back "I have loaded the game" confirmation to the server and the client-sent RPC automatically updates the the server's NCE.

There are 2 more data streams that can be sent between NCEs: (1) Snapshots and (2) Commands. Snapshots send state data and Commands send inputs.

Currently (at this point in the project), by creating a NCE, we have "unlocked" RPCs. Once we put a special NetCode component, "NetworkStreamInGame", on the NCE it signals to NetCode to begin sending game data. Then Snapshots and Commands will be unlocked.

Part of the flow of loading the level will be to send the GameSettings data to the client, the second part is to add the NetworkStreamInGame component to the NCE on both the client and server. Because the GameSettings data is set from the fields on the GameSettings GameObject in the Sub Scene, which is accessible to both the client and server, it is actually unnecessary to send GameSettings from the server to the client (the server would be sending the client data that the client already knows).

However, in our project we implement this data transfer even though it is unnecessary (currently) to show an example of how to send data. In the Multiplayer section we will be sending data that the client actually does not know.

Now let's implement

  • We are going to implement a flow where the server sends an RPC to a newly connected client and the client responds by sending back an RPC

  • Let's start by creating a folder in "Mixed" called "Commands" where we will store our RPCs

    • The RPCs need to be in the Mixed folder because both the server and client utilize these RPCs

  • Make another folder in Mixed called "Components"

    • In this folder we will put the components that both the server and client touch

  • Put the GameSettingsComponent in Mixed/Components

    • Both the server and client use the GameSettingsComponent in this flow (which is why it is in Mixed)

  • Now create PlayerSpawningStateComponent and put it in Server/Components

    • So make a Components folder in Server folder :)

    • PlayerSpawningStateComponent will be used by the server to know when a player is spawning

  • Paste the code snippet below into PlayerSpawningStateComponentData.cs:

using Unity.Entities;
using Unity.NetCode;

public struct PlayerSpawningStateComponent : IComponentData
{
    public int IsSpawning;
}
  • Now in Mixed/Commands create two 2 RPCs

  • First one is SendClientGameRpc

  • Paste the code snippet below in SendClientGameRpc.cs:

using AOT;
using Unity.Burst;
using Unity.Networking.Transport;
using Unity.NetCode;
using Unity.Entities;
using Unity.Collections;
using System.Collections;
using System;

public struct SendClientGameRpc : IRpcCommand
{
    public int levelWidth;
    public int levelHeight;
    public int levelDepth;
    public float playerForce;
    public float bulletVelocity;
}
  • Second one is SendServerGameLoadedRpc

  • Paste the code snippet below in SendServerGameLoadedRpc.cs:

using AOT;
using Unity.Burst;
using Unity.Networking.Transport;
using Unity.NetCode;
using Unity.Entities;
using UnityEngine;
using Unity.Collections;

[BurstCompile]
public struct SendServerGameLoadedRpc : IComponentData, IRpcCommandSerializer<SendServerGameLoadedRpc>
{
    //Necessary boilerplate
    public void Serialize(ref DataStreamWriter writer, in RpcSerializerState state, in SendServerGameLoadedRpc data)
    {
    }
    //Necessary boilerplate
    public void Deserialize(ref DataStreamReader reader, in RpcDeserializerState state, ref SendServerGameLoadedRpc data)
    {
    }

    [BurstCompile]
    [MonoPInvokeCallback(typeof(RpcExecutor.ExecuteDelegate))]
    private static void InvokeExecute(ref RpcExecutor.Parameters parameters)
    {
        //Within here is where
        var rpcData = default(SendServerGameLoadedRpc);

        //Here we deserialize the received data
        rpcData.Deserialize(ref parameters.Reader, parameters.DeserializerState, ref rpcData);

        //Here we add 3 components to the NCE
        //The first, PlayerSpawningStateComonent will be used during our player spawn flow
        parameters.CommandBuffer.AddComponent(parameters.JobIndex, parameters.Connection, new PlayerSpawningStateComponent());
        //NetworkStreamInGame must be added to an NCE to start receiving Snapshots
        parameters.CommandBuffer.AddComponent(parameters.JobIndex, parameters.Connection, default(NetworkStreamInGame));
        //GhostConnectionPosition is added to be used in conjunction with GhostDistanceImportance (from the socket section)
        parameters.CommandBuffer.AddComponent(parameters.JobIndex, parameters.Connection, default(GhostConnectionPosition));

        //We add a log that we will remove later to show that this RPC has been executed
        //iOS will crash if Debug.Log is used within an RPC so we will remove this in the ARFoundation section
        Debug.Log("Server acted on confirmed game load");
    }

    //Necessary boilerplate
    static PortableFunctionPointer<RpcExecutor.ExecuteDelegate> InvokeExecuteFunctionPointer =
        new PortableFunctionPointer<RpcExecutor.ExecuteDelegate>(InvokeExecute);
    public PortableFunctionPointer<RpcExecutor.ExecuteDelegate> CompileExecute()
    {
        return InvokeExecuteFunctionPointer;
    }
}

//Necessary boilerplate
class SendServerGameLoadedRpcCommandRequestSystem : RpcCommandRequestSystem<SendServerGameLoadedRpc, SendServerGameLoadedRpc>
{
    [BurstCompile]
    protected struct SendRpc : IJobEntityBatch
    {
        public SendRpcData data;
        public void Execute(ArchetypeChunk chunk, int orderIndex)
        {
            data.Execute(chunk, orderIndex);
        }
    }
    protected override void OnUpdate()
    {
        var sendJob = new SendRpc{data = InitJobData()};
        ScheduleJobData(sendJob);
    }
}
  • You'll notice that SendServerGameLoadedRpc is more complicated than SendClientGameRpc

    • SendClientGameRpc is used to send data from the server to the client. There's no other funny business going on, just an RPC being used as a way to send data

    • SendServerGameLoadedRpc does not send any data. Instead, when it reaches the server the InvokeExecute method is called, which updates the receiving NCE

      • The RPC is able to update the NCE itself, without us needing to implement a system to get it done

  • Create a folder in Server named "Systems" and within that folder create ServerSendGameSystem

  • Paste this code snippet below into ServerSendGameSystem.cs:

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

//This component is only used by this system so we define it in this file
public struct SentClientGameRpcTag : IComponentData
{
}

//This system should only be run by the server (because the server sends the game settings)
//By sepcifying to update in group ServerSimulationSystemGroup it also specifies that it must
//be run by the server
[UpdateInGroup(typeof(ServerSimulationSystemGroup))]
[UpdateBefore(typeof(RpcSystem))]
public partial class ServerSendGameSystem : SystemBase
{
    private BeginSimulationEntityCommandBufferSystem m_Barrier;

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

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

        var serverData = GetSingleton<GameSettingsComponent>();

        Entities
        .WithNone<SentClientGameRpcTag>()
        .ForEach((Entity entity, in NetworkIdComponent netId) =>
        {
            commandBuffer.AddComponent(entity, new SentClientGameRpcTag());
            var req = commandBuffer.CreateEntity();
            commandBuffer.AddComponent(req, new SendClientGameRpc
            {
                levelWidth = serverData.levelWidth,
                levelHeight = serverData.levelHeight,
                levelDepth = serverData.levelDepth,
                playerForce = serverData.playerForce,
                bulletVelocity = serverData.bulletVelocity,
            });

            commandBuffer.AddComponent(req, new SendRpcCommandRequestComponent {TargetConnection = entity});
        }).Schedule();

        m_Barrier.AddJobHandleForProducer(Dependency);
    }
}
  • Create a folder in Client named "Systems" and within that folder create ClientLoadGameSystem

  • Paste the code snippet below into ClientLoadGameSystem:

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

//This will only run on the client because it updates in ClientSimulationSystemGroup (which the server does not have)
[UpdateInGroup(typeof(ClientSimulationSystemGroup))]
[UpdateBefore(typeof(RpcSystem))]
public partial class ClientLoadGameSystem : SystemBase
{
    private BeginSimulationEntityCommandBufferSystem m_BeginSimEcb;

    protected override void OnCreate()
    {
        //We will be using the BeginSimECB
        m_BeginSimEcb = World.GetOrCreateSystem<BeginSimulationEntityCommandBufferSystem>();

        //Requiring the ReceiveRpcCommandRequestComponent ensures that update is only run when an NCE exists
        RequireForUpdate(GetEntityQuery(ComponentType.ReadOnly<SendClientGameRpc>(), ComponentType.ReadOnly<ReceiveRpcCommandRequestComponent>()));   
        //This is just here to make sure the Sub Scene is streamed in before the client sets up the level data
        RequireSingletonForUpdate<GameSettingsComponent>();
    }

    protected override void OnUpdate()
    {

        //We must declare our local variables before using them within a job (.ForEach)
        var commandBuffer = m_BeginSimEcb.CreateCommandBuffer();
        var rpcFromEntity = GetBufferFromEntity<OutgoingRpcDataStreamBufferComponent>();
        var gameSettingsEntity = GetSingletonEntity<GameSettingsComponent>();
        var getGameSettingsComponentData = GetComponentDataFromEntity<GameSettingsComponent>();

        Entities
        .ForEach((Entity entity, in SendClientGameRpc request, in ReceiveRpcCommandRequestComponent requestSource) =>
        {
            //This destroys the incoming RPC so the code is only run once
            commandBuffer.DestroyEntity(entity);

            //Check for disconnects before moving forward
            if (!rpcFromEntity.HasComponent(requestSource.SourceConnection))
                return;

            //Set the game size (unnecessary right now but we are including it to show how it is done)
            getGameSettingsComponentData[gameSettingsEntity] = new GameSettingsComponent
            {
                levelWidth = request.levelWidth,
                levelHeight = request.levelHeight,
                levelDepth = request.levelDepth,
                playerForce = request.playerForce,
                bulletVelocity = request.bulletVelocity
            };

            //These update the NCE with NetworkStreamInGame (required to start receiving snapshots)
            commandBuffer.AddComponent(requestSource.SourceConnection, default(NetworkStreamInGame));
            
            //This tells the server "I loaded the level"
            //First we create an entity called levelReq that will have 2 necessary components
            //Next we add the RPC we want to send (SendServerGameLoadedRpc) and then we add
            //SendRpcCommandRequestComponent with our TargetConnection being the NCE with the server (which will send it to the server)
            var levelReq = commandBuffer.CreateEntity();
            commandBuffer.AddComponent(levelReq, new SendServerGameLoadedRpc());
            commandBuffer.AddComponent(levelReq, new SendRpcCommandRequestComponent {TargetConnection = requestSource.SourceConnection});

            Debug.Log("Client loaded game");
        }).Schedule();

        m_BeginSimEcb.AddJobHandleForProducer(Dependency);
    }
}
  • Let's hit play and then go take a look the NCE in Server World in the DOTS Hierarchy

  • Checkout the "NetworkStreamInGame" component on the NCE

    • "Network Stream In Game" listed under tags

  • We can see the component has been added on the Client as well

  • Our asteroids now have movement because we are "in game" (which we signaled by adding the "NetworkStreamInGame" component on the NCE)

    • The server can send Snapshots of the asteroids

  • We see our NCEs have been updated by our RPCs and systems

We can now load data on the client sent from the server through an RPC

  • We created a new component

    • PlayerSpawningStateComponent

  • We created 2 RPCs

    • SendClientGameRpc

    • SendServerGameLoadedRpc

  • We created 2 systems

    • ServerSendGameSystem

    • ClientLoadGameSystem

Github branch link:

git clone https://github.com/moetsi/Unity-DOTS-Multiplayer-XR-Sample/ git checkout 'Loading-a-Game'

Last updated