Deep Dive Into The Unity Netcode Cube Example

Hey everyone, In my attempts to learn the new unity Netcode system I have wished that there was more explanation on what all the code in the getting started section actually did. So that’s what I am going to do here, go line by line through the code in the getting started section of the documentation. I recommend reading through the original doc first before reading this as I am not going to be explaining all the scene setup, just a few tips here and there. I’m also going to be upgrading the code a little to extend SystemBase instead of ComponentSystem.

Creating an initial scene

Follow the steps in the docs for this step.

Creating a ghost prefab

Again follow the steps in the docs for this step, though I do have some tips for using the ghost authoring component. I don’t think it can handle children in the prefab yet because whenever I added even an empty game object as a child to the object with the authoring component the engine would instantly crash without warning upon spawning in the prefab. I have a workaround for this I will share in a future tutorial. Another tip is that the [GhostDefaultField] tag makes it so that the field is automatically synced to clients by the server. In this case, we are using it to sync the id so that we can tell what cube we should control. You can also use this to tell the authoring component how much to quantize the variable and whether to interpolate it or not like this: [GhostDefaultField(1000, true)]. The higher the quantization the more accurate the variable is. What basically happens when the variable is synced is this: (int)(value * quantization) –> sent over the network –> value / (float)(quantization). This is to aid in determinism and make predictions more accurate. You can also add them to the ghost authoring script manually.

Hook up the collections

Read the docs for this.

The code

Now we get to do some coding. Just a quick reminder that I changed the code to use SystemBase so it will look slightly different. I’m also going to put them in a different order that I think makes more sense.

This is the code for making the client and server listen for connections:

using Unity.Entities;
using Unity.NetCode;
using Unity.Networking.Transport;

[UpdateInWorld(UpdateInWorld.TargetWorld.Default)]
public class Game : SystemBase
{
    // Singleton component to trigger connections once from a control system
    struct InitGameComponent : IComponentData
    {
    }
    protected override void OnCreate()
    {
        RequireSingletonForUpdate<InitGameComponent>();
        // Create singleton, require singleton for update so system runs once
        EntityManager.CreateEntity(typeof(InitGameComponent));
    }

    protected override void OnUpdate()
    {
        // Destroy singleton to prevent system from running again
        EntityManager.DestroyEntity(GetSingletonEntity<InitGameComponent>());
        foreach (var world in World.All)
        {
            var network = world.GetExistingSystem<NetworkStreamReceiveSystem>();
            if (world.GetExistingSystem<ClientSimulationSystemGroup>() != null)
            {
                //to chose an IP instead of just always connecting to localhost uncomment the next line and delete the other two lines of code
                //NetworkEndPoint.TryParse("IP address here", 7979, out NetworkEndPoint ep);
                NetworkEndPoint ep = NetworkEndPoint.LoopbackIpv4;
                ep.Port = 7979;
                
                network.Connect(ep);
            }
#if UNITY_EDITOR
            else if (world.GetExistingSystem<ServerSimulationSystemGroup>() != null)
            {
                // Server world automatically listens for connections from any host
                NetworkEndPoint ep = NetworkEndPoint.AnyIpv4;
                ep.Port = 7979;
                network.Listen(ep);
            }
#endif
        }
    }
}

When we boot up the game we want to send a request to the server to spawn in our player prefab, we do this by sending an rpc. An rpc has four parts that we need to code: The struct that contains the data, the sending system, the system that creates the command, and the system that handles the command at the other end.

First the struct, this describes how to serialize and deserialize the data that we want to send, since we aren’t sending any data we don’t need to worry about that part and we only need to define the functions without having them do anything.

[BurstCompile]
public struct GoInGameRequest : IRpcCommand
{
    public void Deserialize(ref DataStreamReader reader)
    {
    }

    public void Serialize(ref DataStreamWriter writer)
    {
    }
    [BurstCompile]
    private static void InvokeExecute(ref RpcExecutor.Parameters parameters)
    {
        RpcExecutor.ExecuteCreateRequestComponent<GoInGameRequest>(ref parameters);
    }

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

Now we create a send system, this what actually sends it over the network when we create rpcs, but we don’t need to code anything for it, we just need to define it. If there are no errors in the console but your requests don’t seem to be sending it is a good idea to check if you forgot this part.

// The system that makes the RPC request component transfer
public class GoInGameRequestSystem : RpcCommandRequestSystem<GoInGameRequest>
{
}

Now we create the system that creates the request so that it can be sent:

// When client has a connection with network id, go in game and tell server to also go in game
//this update in group makes sure this code only runs on the client
[UpdateInGroup(typeof(ClientSimulationSystemGroup))] 
public class GoInGameClientSystem : SystemBase
{
    protected override void OnUpdate()
    {
        EntityManager entityManager = EntityManager;
        //we addd the with structural changes so that we can add components and the with none to make sure we don't send the request if we already have a connection to the server
        Entities.WithStructuralChanges().WithNone<NetworkStreamInGame>().ForEach((Entity ent, ref NetworkIdComponent id) =>
        {
            //we add a network stream in game component so that this code is only run once
            entityManager.AddComponent<NetworkStreamInGame>(ent);
            //create an entity to hold our request, requests are automatically sent when they are detected on an entity making it really simple to send them.
            var req = entityManager.CreateEntity();
            //add our go in game request
            entityManager.AddComponent<GoInGameRequest>(req);
            //add the rpc request component, this is what tells the sending system to send it
            entityManager.AddComponent<SendRpcCommandRequestComponent>(req);
            //add the entity with the network components as our target.
            entityManager.SetComponentData(req, new SendRpcCommandRequestComponent { TargetConnection = ent });
        }).Run();
    }
}

And finally we create the system that handles it on the server side:

// When server receives go in game request, go in game and delete request
[UpdateInGroup(typeof(ServerSimulationSystemGroup))]//make sure this only runs on the server
public class GoInGameServerSystem : SystemBase
{
    protected override void OnUpdate()
    {
        EntityManager entityManager = EntityManager;
        //the with none is to make sure that we don't run this system on rpcs that we are sending, only on ones that we are receiving. 
    Entities.WithStructuralChanges().WithNone<SendRpcCommandRequestComponent>().ForEach((Entity reqEnt, ref GoInGameRequest req, ref ReceiveRpcCommandRequestComponent reqSrc) =>
        {
            //we add a network connection to the component on our side
            entityManager.AddComponent<NetworkStreamInGame>(reqSrc.SourceConnection);
            UnityEngine.Debug.Log(System.String.Format("Server setting connection {0} to in game", EntityManager.GetComponentData<NetworkIdComponent>(reqSrc.SourceConnection).Value));

            var ghostCollection = GetSingleton<GhostPrefabCollectionComponent>();
            //if you get errors in this code make sure you have generated the ghost collection and ghost code using their authoring components and that the names are set correctly when you do so.
            var ghostId = NetCubeGhostSerializerCollection.FindGhostType<CubeSnapshotData>();
            var prefab = EntityManager.GetBuffer<GhostPrefabBuffer>(ghostCollection.serverPrefabs)[ghostId].Value;
            //spawn in our player
            var player = entityManager.Instantiate(prefab);

            entityManager.SetComponentData(player, new MovableCubeComponent { PlayerId = entityManager.GetComponentData<NetworkIdComponent>(reqSrc.SourceConnection).Value });
            //if you are copy pasting this article in order don't worry if you get an error about cube input not existing as we are going to code that next, this just adds the buffer so that we can receive input from the player. If you want to test your code at this point just comment it out
            entityManager.AddBuffer<CubeInput>(player);

            entityManager.SetComponentData(reqSrc.SourceConnection, new CommandTargetComponent { targetEntity = player });

            entityManager.DestroyEntity(reqEnt);
            Debug.Log("Spawned Player");
        }).Run();
    }
}

Now that our cube spawns when we start the game, we can write some code to move it. The way we do this is similar to an RPC. Though with a few differences. The new net code system is very centered around a server authoritative system which in layman’s terms means that you essentially send what buttons you are pressing to the server, it runs the moving script, and then sends your position back to you. looking at this you might instantly catch the problem. LAG. Even with a good ping and experienced gamer is instantly going to notice the disconnect between what they are pressing and the actual movement. Which is where projection comes in. The basic idea is to run identical movement code on the server and player, if everything goes well this will result in the player moving the exact same way on the server and client plus some lag. Though there is still the problem of the server responding with the position that you were at half a second ago. To compensate for this we store all the player input in an array and can effectively re-run the last few frames of movement to bring us back to the current frame.

For input we need four parts. The buffer that is synced between client and server, the send and receive systems, the system that updates it, and finally, the movement system.

First let’s create the buffer, the serialize and deserialize functions tell the game how to send data over the network the read and write functions need to be symmetrical for it to work properly, the tick variable is so we know what frame this data was from so that we can roll back to it.

public struct CubeInput : ICommandData<CubeInput>
{
    public uint Tick => tick;
    public uint tick;
    public int horizontal;
    public int vertical;

    public void Deserialize(uint tick, ref DataStreamReader reader)
    {
        this.tick = tick;
        horizontal = reader.ReadInt();
        vertical = reader.ReadInt();
    }

    public void Serialize(ref DataStreamWriter writer)
    {
        writer.WriteInt(horizontal);
        writer.WriteInt(vertical);
    }

    public void Deserialize(uint tick, ref DataStreamReader reader, CubeInput baseline,
        NetworkCompressionModel compressionModel)
    {
        Deserialize(tick, ref reader);
    }

    public void Serialize(ref DataStreamWriter writer, CubeInput baseline, NetworkCompressionModel compressionModel)
    {
        Serialize(ref writer);
    }
}

Now the send and receive boilerplate:

public class NetCubeSendCommandSystem : CommandSendSystem<CubeInput>
{
}
public class NetCubeReceiveCommandSystem : CommandReceiveSystem<CubeInput>
{
}

Next the input updater:

[UpdateInGroup(typeof(ClientSimulationSystemGroup))]//make sure this only runs on the client
public class SampleCubeInput : SystemBase
{
    protected override void OnCreate()
    {
        RequireSingletonForUpdate<NetworkIdComponent>();
        RequireSingletonForUpdate<EnablePlayersGhostReceiveSystemComponent>();
    }

    protected override void OnUpdate()
    {
        var localInput = GetSingleton<CommandTargetComponent>().targetEntity;
        var entityManager = EntityManager;
        //if the singleton is not set it means that we need to setup our cube
        if (localInput == Entity.Null)
        {
            //find the cube that we control
            var localPlayerId = GetSingleton<NetworkIdComponent>().Value;
            Entities.WithStructuralChanges().WithNone<CubeInput>().ForEach((Entity ent, ref MovableCubeComponent cube) =>
            {
                if (cube.PlayerId == localPlayerId)
                {
                    //add our input buffer and set the singleton to our cube
                    UnityEngine.Debug.Log("Added input buffer");
                    entityManager.AddBuffer<CubeInput>(ent);
                    entityManager.SetComponentData(GetSingletonEntity<CommandTargetComponent>(), new CommandTargetComponent { targetEntity = ent });
                }
            }).Run();
            return;
        }
        var input = default(CubeInput);
        //set our tick so we can roll it back
        input.tick = World.GetExistingSystem<ClientSimulationSystemGroup>().ServerTick;
        //the reason that we are adding instead of just setting the values is so that opposite keys cancel each other out
        if (Input.GetKey("a"))
            input.horizontal -= 1;
        if (Input.GetKey("d"))
            input.horizontal += 1;
        if (Input.GetKey("s"))
            input.vertical -= 1;
        if (Input.GetKey("w"))
            input.vertical += 1;
        //add our input to the buffer
        var inputBuffer = EntityManager.GetBuffer<CubeInput>(localInput);
        inputBuffer.AddCommandData(input);
    }
}

Now for the last thing we just need to make the actual movement system:

[UpdateInGroup(typeof(GhostPredictionSystemGroup))]//make sure this runs in the prediction group
public class MoveCubeSystem : SystemBase
{
    protected override void OnUpdate()
    {
        var group = World.GetExistingSystem<GhostPredictionSystemGroup>();
        //get the current tick so that we can get the right input
        var tick = group.PredictingTick;
        var deltaTime = Time.DeltaTime;
        Entities.ForEach((DynamicBuffer<CubeInput> inputBuffer, ref Translation trans, ref PredictedGhostComponent prediction) =>
        {
            if (!GhostPredictionSystemGroup.ShouldPredict(tick, prediction))
                return;
            CubeInput input;
            //we get the input at the tick we are predicting
            inputBuffer.GetDataAtTick(tick, out input);
            if (input.horizontal > 0)
                trans.Value.x += deltaTime;
            if (input.horizontal < 0)
                trans.Value.x -= deltaTime;
            if (input.vertical > 0)
                trans.Value.z += deltaTime;
            if (input.vertical < 0)
                trans.Value.z -= deltaTime;
        }).Run();
    }
}

And that about covers all the code in the netcode getting started tutorial. I really hope this helped you. If any of the code has a bug in it please tell me in the comments so that I can fix it. Same goes for if you have anything to add. Till next time.

Liked it? Take a second to support WireWhiz on Patreon!
Become a patron at Patreon!