This tutorial will explain how multiplayer can be implemented using Unity’s networking functionality. We didn’t work on an online multiplayer game before, and here will we describe how we designed the implementation for LFG: The Fork of Truth. This is a four person co-op game where each player controls one of the characters from the LFG comic. Players will be working together by combining their abilities to defeat their enemies and complete quests.

In this tutorial I explain how to create a online multiplayer game using Photon Unity Networking (PUN), instead of the standard Unity networking.

The video below is from the Kickstarter-demo we have made and shows the gameplay and multiplayer functionality which we are going to talk about.

An important decision we made at the start of the project was to implement networking first and all other code later. For each new feature we made sure it worked over the network. In the end this saved us a lot of time, because implementing it at a later stage would probably result in changing a lot of code. To follow this tutorial, a basic understanding of Unity and C# is required.

These are the things we are going to talk about:

  1. Implement server creation and joining an existing host.
  2. Spawning as a player and how objects can be created on the network.
  3. Network communication using State Synchronization and Remote Procedure Calls.
  4. Interpolating and predicting values between data packages.

Networking was a new topic for us and I found this video tutorial very useful to start with. It’s explained in Javascript, but covers the same content as the first three paragraphs.

Creating a server

So let’s get started! In the new Unity project, create a new C# script named “NetworkManager”. Add this empty script to an object in the scene, either the camera or an empty game object. It will handle hosting a server or connecting to an existing server.

To create a server, we need to initialize it on the network and register it to the master server. The initialization requires a maximum amount of players (in this case 4) and a port number (25000). For the server registration the name of the game should be unique, otherwise you might get in trouble with others projects using the same name. The room name can be any name, and in our case we eventually used the player name. Add these lines to the NetworkManager script.

private const string typeName = "UniqueGameName";
private const string gameName = "RoomName";

private void StartServer()
{
    Network.InitializeServer(4, 25000, !Network.HavePublicAddress());
    MasterServer.RegisterHost(typeName, gameName);
}

If the server is successfully initialized, OnServerInitialized() will be called. For now, we are happy to get feedback telling us the server is actually initialized.

void OnServerInitialized()
{
    Debug.Log("Server Initializied");
}

All we need now is some form of input to let us actually start the server when we want to. To test it out we create buttons using the Unity GUI. We only want to see these buttons if we have not started a server or joined one, so the button will show itself if the user is neither a client nor a server.

void OnGUI()
{
    if (!Network.isClient && !Network.isServer)
    {
        if (GUI.Button(new Rect(100, 100, 250, 100), "Start Server"))
            StartServer();
    }
}

Now it is time to test what we developed so far. When starting the project, all you should see now is a start server button (1A) . If you press this button, a message should be shown in the console indicating you just initialized a server. Afterwards the button should disappear (1B).

This MasterServer is run by Unity and could be down due to maintenance. You can download and run your own MasterServer locally. Add to NetworkManager.cs the following:

MasterServer.ipAddress = “127.0.0.1″;

Thanks to jeff77k for this addition!

Joining a server

We now have the functionality to create a server, but can not yet search for existing servers or join one of them. To achieve this, we need to send a request to the master server to get a list of HostData. This contains all data required to join a server. Once the host list is received, a message is sent to the game which triggers OnMasterServerEvent(). This function is called for several events, so we need to add a check to see if the message equals MasterServerEvent.HostListReceived. If this is the case, we can store the host list.

private HostData[] hostList;

private void RefreshHostList()
{
    MasterServer.RequestHostList(typeName);
}

void OnMasterServerEvent(MasterServerEvent msEvent)
{
    if (msEvent == MasterServerEvent.HostListReceived)
        hostList = MasterServer.PollHostList();
}

To join a server, all we need is one entry of the host list. The OnConnectedToServer() is called after we actually joined the server. We will extend this function at a later point.

private void JoinServer(HostData hostData)
{
    Network.Connect(hostData);
}

void OnConnectedToServer()
{
    Debug.Log("Server Joined");
}

By extending the GUI with some additional buttons, the functions we just created can be called. There will be two buttons now at the start, one to start the server and another to refresh the host list. A new button is created for every server and it will connect the user to the corresponding room.

void OnGUI()
{
    if (!Network.isClient && !Network.isServer)
    {
        if (GUI.Button(new Rect(100, 100, 250, 100), "Start Server"))
            StartServer();

        if (GUI.Button(new Rect(100, 250, 250, 100), "Refresh Hosts"))
            RefreshHostList();

        if (hostList != null)
        {
            for (int i = 0; i < hostList.Length; i++)
            {
                if (GUI.Button(new Rect(400, 100 + (110 * i), 300, 100), hostList[i].gameName))
                    JoinServer(hostList[i]);
            }
        }
    }
}

This is a good point to do another test (2A). One thing to note is that testing multiplayer takes a bit longer, particularly because you always require two instances of the game. To run two instances on the same computer this settings needs to be checked. Go to File > Build Settings > Player Settings > Run in Background and enable it. Now create a new build, launch it and press “Start Server”. Now we can test our new functionality in the Unity editor. After refreshing your list, another button should appear (2B) allowing you to connect the two instances of your game (2C).

Spawning a player

Now we should be able to connect multiple players to one another, we can now extend the code with game mechanics. Set up a simple scene with a floor plane, player and some lighting. Add a rigidbody to the player and freeze the rotations, so we will not get strange behaviour while moving.

Scene Setup

Create a Player-script, add it to the player object and add the following code:

public class Player : MonoBehaviour
{
    public float speed = 10f;

    void Update()
    {
        InputMovement();
    }

    void InputMovement()
    {
        if (Input.GetKey(KeyCode.W))
            rigidbody.MovePosition(rigidbody.position + Vector3.forward * speed * Time.deltaTime);

        if (Input.GetKey(KeyCode.S))
            rigidbody.MovePosition(rigidbody.position - Vector3.forward * speed * Time.deltaTime);

        if (Input.GetKey(KeyCode.D))
            rigidbody.MovePosition(rigidbody.position + Vector3.right * speed * Time.deltaTime);

        if (Input.GetKey(KeyCode.A))
            rigidbody.MovePosition(rigidbody.position - Vector3.right * speed * Time.deltaTime);
    }
}

Next, add the network view component to the player object (Component > Miscellaneous > Network View). This will enable us to send data packages over the network to synchronize the player. The state synchronization field is automatically set to “reliable delta compressed”. This means that synchronized data will be sent automatically, but only if its value changed. So for example if you move as a player, your position will be updated on the server. By setting it to “off” there is no automatic synchronization at all and you have to do it manually. For now set it to “reliable”, later in this tutorial these options will be discussed in more detail. Add the player object to the hierarchy to make it a prefab, so we can instantiate it on the network.

In the NetworkManager-script add a public game object variable for the player prefab. In the new function SpawnPlayer(), the prefab will be instantiated on the network, so all clients will see this object within their game. It requires a position, rotation and group, so I would suggest creating a spawn point.

The code in OnServerInitialized() and OnConnectedToServer() needs to be changed to spawn the player.

public GameObject playerPrefab;

void OnServerInitialized()
{
    SpawnPlayer();
}

void OnConnectedToServer()
{
    SpawnPlayer();
}

private void SpawnPlayer()
{
    Network.Instantiate(playerPrefab, new Vector3(0f, 5f, 0f), Quaternion.identity, 0);
}

Time for another test! Create a new build and run two instances again. You will notice that you can control all players connected, not just your own. This shows one important aspect that will be used often when creating a multiplayer game: who controls what object?

Spawning Players

One way to fix this problem is to build in a check on the player code so it only receives input from the user that instantiated the object. Because we set reliable synchronization on the network view, data is sent automatically across the network and no other information is required.

To implement this method, we need to check whether the object on the network “is mine” in the player script. Add the following if-statement to the Update():

void Update()
{
    if (networkView.isMine)
    {
        InputMovement();
    }
}

If you do another test now, you will see you can only control one of the players.

Another solution could be to send all input to the server, which will then convert your data to actual movement and send back your new position to everyone on the network. The advantage is that everything is synchronized on the server. This prevents players from cheating on their local client. A disadvantage of this method is the latency between the client and server, which may result in the user having to wait to see the actions he performed.

State Synchronization

There are two methods of network communication. The first is State Synchronization and the other is Remote Procedure Calls, which will be covered in another paragraph. State Synchronization constantly updates values over the network. This approach is useful for data that changes often, like player movement. In the function OnSerializeNetworkView() the variables are sent or received and will synchronize them quick and simple. To show you how this works, we will write the code that synchronizes the player’s position.

Go to the network view component on the player’s prefab. The observed field contains the component that will be synchronized. The transform is automatically added to this field, which results in the position, rotation and scale being updated depending on the sendrate. Drag the component of the player script in the observed field so we can write our own synchronization method.

Add OnSerializeNetworkView() to the player script. This function is automatically called every time it either sends or receives data. If the user is writing to the stream, it means he is sending the data. By using stream.Serialize() the variable will be serialized and received by other clients. If the user receives the data, the same serialization-function is called and can now be set to store the data locally. Note that the order of variables should be the same for sending and receiving data, otherwise the values will be mixed up.

void OnSerializeNetworkView(BitStream stream, NetworkMessageInfo info)
{
    Vector3 syncPosition = Vector3.zero;
    if (stream.isWriting)
    {
        syncPosition = rigidbody.position;
        stream.Serialize(ref syncPosition);
    }
    else
    {
        stream.Serialize(ref syncPosition);
        rigidbody.position = syncPosition;
    }
}

Make another build and run it. The results should be the same as before, but now we have granted ourselves control over the movement and how the synchronization works.

Interpolation

You might have noticed latency issues between the two instances due to the sendrate. The standard settings in Unity is that a package is being tried to send 15 times per second. For testing purposes, we will change the sendrate. To do this, first go to the network settings at (Edit> Project Settings > Network). Then, change the sendrate to 5, resulting is less data packages being sent. If you would do another build and test, the latency should be clearer.

To smooth the transition from the old to the new data values and fix these latency issues, interpolation can be used. There are several options how this can be implemented. For this tutorial, we will interpolate between the current position and the new position received after synchronization.

OnSerializeNetworkView() needs to be extended with to store all the required data: the current position, new position and delay between updates.

private float lastSynchronizationTime = 0f;
private float syncDelay = 0f;
private float syncTime = 0f;
private Vector3 syncStartPosition = Vector3.zero;
private Vector3 syncEndPosition = Vector3.zero;

void OnSerializeNetworkView(BitStream stream, NetworkMessageInfo info)
{
    Vector3 syncPosition = Vector3.zero;
    if (stream.isWriting)
    {
        syncPosition = rigidbody.position;
        stream.Serialize(ref syncPosition);
    }
    else
    {
        stream.Serialize(ref syncPosition);

        syncTime = 0f;
        syncDelay = Time.time - lastSynchronizationTime;
        lastSynchronizationTime = Time.time;

        syncStartPosition = rigidbody.position;
        syncEndPosition = syncPosition;
    }
}

We had a check in Update() whether the object is controlled by the player. We need to add the functionality that when this is not the case, we will use interpolation between the synchronized values.

void Update()
{
    if (networkView.isMine)
    {
        InputMovement();
    }
    else
    {
        SyncedMovement();
    }
}

private void SyncedMovement()
{
    syncTime += Time.deltaTime;
    rigidbody.position = Vector3.Lerp(syncStartPosition, syncEndPosition, syncTime / syncDelay);
}

Create a new build and test it, you should now see the transition looks better between updates.

Prediction

Though the transitions look smooth, you notice a small delay between the input and the actual movement. This is because the position is updated after the new data is received. Until we invent time travel, all we can do is predict what is going to happen based on the old data.

One method to predict the next position is by taking the velocity into account. A more accurate end position can be calculated by adding the velocity multiplied by the delay.

void OnSerializeNetworkView(BitStream stream, NetworkMessageInfo info)
{
    Vector3 syncPosition = Vector3.zero;
    Vector3 syncVelocity = Vector3.zero;
    if (stream.isWriting)
    {
        syncPosition = rigidbody.position;
        stream.Serialize(ref syncPosition);

        syncVelocity = rigidbody.velocity;
        stream.Serialize(ref syncVelocity);
    }
    else
    {
        stream.Serialize(ref syncPosition);
        stream.Serialize(ref syncVelocity);

        syncTime = 0f;
        syncDelay = Time.time - lastSynchronizationTime;
        lastSynchronizationTime = Time.time;

        syncEndPosition = syncPosition + syncVelocity * syncDelay;
        syncStartPosition = rigidbody.position;
    }
}

After building and testing the game again, you will notice the transitions are still smooth and the latency between your input and the actual movement seem less. There are also a few corner-cases where behaviour might seem a little strange if the latency is too high. If the player starts moving, the other clients still predict you would stand still. Set the sendrate at network settings back to 15 updates per second for better results.

For our Kickstarter demo, we used a navmesh to walk around and this seemed to make interpolation and prediction better. The sendrate was set to 5 and as a user you could barely notice the delay. This tutorial will not go any deeper into the integration of the navmesh, but for your future projects it might be worth to consider this approach.

Remote Procedure Calls

Another method of network communication is Remote Procedure Calls (RPCs), which is more useful for data that does not constantly change. A good example what we used these for in our Kickstarter demo is dialog. In this paragraph we will change the color of a player over the network.

What RPCs do is a function call on a network view component and this component searches the correct RPC function. By adding [RPC] in front of the function, it can be called over the network. This approach is only able to send integers, floats, strings, networkViewIDs, vectors and quaternions. So not all parameters can be sent, but this can be solved. To send a game object, we should add a network view component to this object so we can use its networkViewID. To send a color, we should convert it to a vector or quaternion.

An RPC is sent by calling networkView.RPC(), in which you define the function name and parameters. Also the RPC mode is required: “Server” sends the data to the server only, “Others” to everyone on the server except yourself and “All” sends it to everyone. The last two also have the functionality to set is as buffered, this results in newly connected players receiving all these buffered values. Because we send this data package every frame now, there is no need to buffer it.

To integrate this functionality to our tutorial game, Update() needs to call the function to check for input and change the material to a random color. The RPC function changes the color based on the input and if the player object is controlled by the user, he will send an RPC to all others on the network.

void Update()
{
    if (networkView.isMine)
    {
        InputMovement();
        InputColorChange();
    }
    else
    {
        SyncedMovement();
    }
}

private void InputColorChange()
{
    if (Input.GetKeyDown(KeyCode.R))
        ChangeColorTo(new Vector3(Random.Range(0f, 1f), Random.Range(0f, 1f), Random.Range(0f, 1f)));
}

[RPC] void ChangeColorTo(Vector3 color)
{
    renderer.material.color = new Color(color.x, color.y, color.z, 1f);

    if (networkView.isMine)
        networkView.RPC("ChangeColorTo", RPCMode.OthersBuffered, color);
}

Now create another build and if you run the game, you will see the player’s colors can be changed.

Changing Color

Conclusion

I hope this tutorial was a good introduction to implement networking to a Unity game. The subjects we have addressed are:

  • How to create and join a server
  • How to find active hosts
  • Player or object creation over the network
  • Methods of implementing network communication
  • How to interpolate and predict synchronized data

You can download the entire project here.

As you might have noticed there are a lot of choices that need to be made along the way, depending on the game’s requirements. This tutorial briefly explained our implementation for the Kickstarter demo of LFG: The Fork of Truth.

If you have any questions, suggestions or comments, feel free to use the comment section below.