Multiplayer 12. Client / server communication

The classic way to describe client / server architecture - a friendly game of Wiff Waff.

Now we have a working lobby and leaderboard setup for our game, it's probably a good time to look back over exactly how we're passing data between our game instances.

As mentioned way back in the first blog post, using Unity's networking code means that we can expect our server to be authoritative, with the clients telling the server what to update for themselves only. The rest of the data for a client coming straight from a server.

To allow us to pass data around easily, Unity allows us use of attributes attached to methods and variables. These specify not just whether a client or server can run the code, but whether or not one is telling the other it has to run this code.

Who runs what?

Our codebase is run by both the client and the server, which means that if we have an object with a MonoBehaviour script attached to it, any Update() method will be run by both. This can be useful if it's doing something both client and server want to do, but also not if we don't.

We can limit who runs what code by using one of the following attributes:

  • [Client]
  • [ClientCallback]
  • [Server]
  • [ServerCallback]

With these attributes, we can say a given method can only be run by a client, for instance.

The difference between the [Client] and the [ClientCallback] is that if you define the following code:

public void Update()
{
    OnlyServer();
    OnlyClient();
}

[Server]
private void OnlyServer() {}

[Client]
private void OnlyClient() {}

The server will throw warnings every time OnlyClient() is called, and the clients will throw warnings every time OnlyServer() is called.

The [*Callback] attributes will suppress those warnings, as you'll define those methods as possibly called by the wrong game instance, but we know it may happen so ignore it.

As such, the main use of [Server] and [Client] is for documentation. They're for when you know code will only be called by a server or client, and you want to make note of it, and also absolutely, positively prevent it being otherwise.

Client to Server

When it comes to communication from a client to a server there aren't many ways. Well, there's one, to be honest - the [Command] attribute.

Because we expect the server to be telling the clients what to do, there's only ever a need for a local client to update the server what's going with objects directly under it's control.

Most objects that aren't the Player will likely be either created on the server and have their attributes sent back to the clients, or they'll be created on both the server and client and programmatically updated with passing information.

So we'll use the [Command] attribute for sending data mainly about our player. In our code so far, we use it for PlayerDataForClientsto keep our player's options up-to-date on the server. Note that all of these methods have to start with the characters "Cmd".

// Player/SyncedData/PlayerDataForClients.cs

namespace Player.SyncedData {
    public class PlayerDataForClients : NetworkBehaviour {

        ...

        [Client]
        public void SetName(string newName)
        {
            CmdSetName(newName);
        }

        [Command]
        public void CmdSetName(string newName)
        {
            playerName = newName;
        }

This above code shows that a client can set the name of the player data. This then triggers a command which saves the data on the server.

Server to Client

When we want to communicate the other way around, there's a couple of ways we can do - based on if we just want to update a client's variable, or trigger a client's method. The first way, the variable, is the most common way you'll see for updating client data.

For that we'll use the [SyncVar] attribute, but attached to a variable instead of a method. As you can see from the following code snippet, these are simply variables set by the server that Unity will automatically pass their values to all clients.

// UI/Level/TimerUI.cs

namespace UI.Level {
    public class TimerUI : NetworkBehaviour {
        
        [SyncVar]
        public int timer = 10;

        ...

        public void Awake ()
        {
            timer = LevelData.GetInstance().levelTime;

            ...
        }

        ...
    }
}

When you're not passing something that can easily be sent as a variable, or you don't want to keep a local copy, then the server can trigger local methods on the client. You can pass these whatever arguments you want, and Unity will handle passing the data through to the clients.

To do this, you'll use the [ClientRpc] attribute on a method. These methods also need to be prefixed. In this case with the characters "Rpc". The following code shows how we can do this:

// UI/Level/TimerUI.cs

namespace UI.Level {
    public class TimerUI : NetworkBehaviour {
        
        ...

        [Server]
        private void StartTimer ()
        {
            if (this != null) {
                StartCoroutine(this.WaitForTimerToEnd());
                RpcStartTheGame();
            }
        }

        ...
        
        [ClientRpc]
        private void RpcStartTheGame()
        {
            State.GetInstance()
                .Level(State.LEVEL_PLAYING)
                .Publish();
        }

        ...
    }
}

The SyncVar hook

There will come times when you do want to pass variables from server to client, but you also need to trigger additional actions. We use this pattern heavily on our PlayerDataForClientscode.

To accomplish this, you can specify a [SyncVar] attribute with it's optional hook parameter, as show below.

// Player/SyncedData/PlayerDataForClients.cs

namespace Player.SyncedData {
    public class PlayerDataForClients : NetworkBehaviour {

        ...

        [SyncVar(hook = "UpdateName")]
        private string playerName;
        
        ...

        [Command]
        public void CmdSetName(string newName)
        {
            playerName = newName;
        }

        [Client]
        public void UpdateName(string newName)
        {
            playerName = newName;
            
            ...
        }

        ...
    }
}

An important point to remember with this pattern is when you call the SyncVar hook instead of a normal SyncVar, the client's local variable that it's called on isn't automatically updated, so you need to do it manually. You can see this in the code above.

Client to Client pattern

That covers the main patterns we use for communication, so let's look back at a whole client to client run-through then. This is the main pattern used by PlayerDataForClientsas it's particularly easy to follow.

The full path how with this pattern the client sends the data to the server, which updates it's local variable, which uses a [SyncVar] hook to update all clients, which use that hook to trigger local events on each client, so every listener who wanted to know when a value has changed, can.

Message passing

So far all of the covered patterns have been ones we've used before, but there's another one we should cover. It's a lower-level method that's handy for instantiating an object on all clients with it's settings already provided.

While we could replicate the above idea using a [ClientRPC] after the object has been created, the following method is easier to follow and provides no time when the object has been created but doesn't have it's settings.

With this pattern, you'll create a game object on your server and give it some values. To create it on all clients as well, you'll run the NetworkServer.Spawn() method. This, in turn, calls any OnSerialize() methods that belong to scripts attached to the game object.

Serializing will reduce any specified variables into a way Unity can easily pass as network traffic. These are then deserialized when the object is built on the client side.

using UnityEngine;
using UnityEngine.Networking;

namespace Player {
    public class TestMessaging: NetworkBehaviour {
        
        private GameObject owner;
        
        private class TestMessage : MessageBase
        {
            public Vector3 position;
            public GameObject owner;
        }

        public override bool OnSerialize (NetworkWriter writer, bool initialState)
        {
            TestMessage message = new TestMessage();
            message.position = gameobject.transform.position;
            message.owner = owner;

            writer.Write(message);

            return true;
        }

        public override void OnDeserialize (NetworkReader reader, bool initialState)
        {
            TestMessage message = reader.ReadMessage<TestMessage>();
            gameobject.transform.position = message.position;
            owner = message.owner;
        }
    }
}

The above code shows how we define the message we're going to pass between server to clients as TestMessage. This can contain some relatively complex objects, such as a Vector3, or even a NetworkIdentityed GameObject.

The data is then serialized and passed through an existing NetworkWriter object, ready for spawning on the clients. When the data is received by the client, they deserialize the message and re-assign the values.