In this tutorial we will create a small online multiplayer game using Photon Unity Networking (PUN). This project will be similar to “How to create an online multiplayer game with Unity”, but instead of the standard Unity networking, we will use PUN.

Photon is a real-time multiplayer game development framework which has server and cloud services. Even though the networking implementation of the Kickstarter demo for LFG: The Fork of Truth was build with Unity networking, it was build with PUN in the back of our minds. An advantage of Photon is that it does not require hosting, so the player who created the room can leave the game without causing crashes at the clients. Also, PUN is more stable and the source code is available to fix any issues you might have. An important note is that the free version of Photon requires Unity Pro for iOS and Android. With Photon Plus a free Unity licence is sufficient. 

For our projects, we prefer to implement the networking functionality early during development. This means for each new feature, we make sure it also works over the network. In the end this saves us a lot of time, because implementing it at a later stage results 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. Installing the Photon Unity Networking package
  2. Implement server creation and joining an existing host.
  3. Spawning as a player and how objects can be created on the network.
  4. Network communication using State Synchronization and Remote Procedure Calls.
  5. Interpolating and predicting values between data packages.

More tutorials can be found on their official website.

Installation

Before we can get into the code, we need to install PUN. You can find it here on the Asset Store. This package also contains several demos, which are not required for this tutorial, but can give you a nice idea what the possibilities are.

Once the project is downloaded, open the PUN Wizard (Window > Photon Unity Networking). Fill in your email and press send. You will receive a mail with a link to your account which contains an AppID. Copy this ID and paste it in the field “Your AppID”, set your cloud region and save these settings.

Network connection

Create a new script called NetworkManager and add it to an empty object in the scene. This script will handle the network connection and spawn connected players.

When we start the game, we want to connect to the Photon network. This will enable us to host and join rooms of our game based on the AppID. For the function call PhotonNetwork.ConnectUsingSettings() we can add the version number of the game as a string.

void Start()
    {
        PhotonNetwork.ConnectUsingSettings("0.1");
    }

The boolean PhotonNetwork.connected only checks if we are connected to the Photon network itself, not if we are connected to another player’s server. As long as we’re not connected a text label is drawn to show us what the status is.

After the connection has been established, we need to connect to a room. A room can be created with the function call PhotonNetwork.CreateRoom(), where you have to define a unique room name. In the example below an unique ID is added to the name. Other parameters can be used to hide or close the room or set the maximum number of players.

For all rooms in the list, a button is added so it can be joined. This list is updated via the network message OnReceivedRoomListUpdate(), which is called automatically every time a room is added or removed.

After connecting to a room, either by creating or joining one, the function OnJoinedRoom() is called. Add a debug log here so we can see whether we are actually connected.

private const string roomName = "RoomName";
private RoomInfo[] roomsList;

void OnGUI()
{
    if (!PhotonNetwork.connected)
    {
        GUILayout.Label(PhotonNetwork.connectionStateDetailed.ToString());
    }
    else if (PhotonNetwork.room == null)
    {
        // Create Room
        if (GUI.Button(new Rect(100, 100, 250, 100), "Start Server"))
            PhotonNetwork.CreateRoom(roomName + Guid.NewGuid().ToString("N"), true, true, 5);

        // Join Room
        if (roomsList != null)
        {
            for (int i = 0; i < roomsList.Length; i++)
            {
                if (GUI.Button(new Rect(100, 250 + (110 * i), 250, 100), "Join " + roomsList[i].name))
                    PhotonNetwork.JoinRoom(roomsList[i].name);
            }
        }
    }
}

void OnReceivedRoomListUpdate()
{
    roomsList = PhotonNetwork.GetRoomList();
}
void OnJoinedRoom()
{
    Debug.Log("Connected to Room");
}

This is a good point to do our first test. 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, create a new build. Afterwards launch the game and press “Start Server”. Now we can test our new functionality in the Unity editor. After refreshing your list, another button should appear allowing you to connect the two instances of your game.

Spawning a player

Now that we are able to connect multiple players, we can 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.

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 Photon view component to the player object (Component > Miscellaneous > Photon View). This will enable us to send data packages over the network to synchronize the player. Observe option 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”. Drag the transform component into the observe field. At the bottom the field serialization is now added, where you can state which values from the transform to synchronize. Set its value to “Only Position”.

In the hierarchy, create a folder called “Resources”. In it, place the player object from the scene to make it a prefab. This is needed to instantiate the object on the network.

In the NetworkManager-script add a public game object variable for the player prefab. When OnJoinedRoom() is called, we want to create the player object. Instantiating on a Photon network requires the prefab to be placed in the Resources folder. The prefab parameter for PhotonNetwork.Instantiate() is a string instead of a game object.

public GameObject playerPrefab;

void OnJoinedRoom()
{
 // Spawn player
 PhotonNetwork.Instantiate(playerPrefab.name, Vector3.up * 5, 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?

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. In the player script change “Monobehaviour” to “Photon.Monobehaviour” to allow Photon networking in this class.

public class Player : Photon.MonoBehaviour
{
    ...

    void Update()
    {
        if (photonView.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 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 performed actions.

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
OnPhotonSerializeView() 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 Photon view component on the player’s prefab. The observed field contains the component that will be synchronized. Drag the component of the player script in the observed field so we can write our own synchronization method.

Add OnPhotonSerializeView() to the player script. This function is automatically called every time it either sends or receives data. If the user is the one updating the object, he writes to the stream. This occurs automatically based on the sendrate. The clients receive the stream and can apply the data that was send to the object. In the example below, the user sends the rigidbody’s position with stream.SendNext() and this is received by the clients with stream.ReceiveNext(). The order of data send should be the same as the order in which the data is received, otherwise values will get mixed up.

void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
    if (stream.isWriting)
        stream.SendNext(rigidbody.position);
    else
        rigidbody.position = (Vector3)stream.ReceiveNext();
}

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. 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.

OnPhotonSerializeView() needs to be extended 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 OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
    if (stream.isWriting)
    {
        stream.SendNext(rigidbody.position);
    }
    else
    {
        syncEndPosition = (Vector3)stream.ReceiveNext();
        syncStartPosition = rigidbody.position;

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

We had a check in Update() whether the object is controlled by the player. If this is not the case, we will use interpolation between the synchronized values.

void Update()
{
    if (photonView.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 OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
    if (stream.isWriting)
    {
        stream.SendNext(rigidbody.position);
        stream.SendNext(rigidbody.velocity);
    }
    else
    {
        Vector3 syncPosition = (Vector3)stream.ReceiveNext();
        Vector3 syncVelocity = (Vector3)stream.ReceiveNext();

        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 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 where we used these 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, vectors and quaternions. So not all parameters can be sent, but this can be solved. For example a color can be send by converting it to a vector or quaternion.

An RPC is sent by calling photonView.RPC(), in which you define the function name and parameters. Also the Photon targets are 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 (photonView.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 (photonView.isMine)
        photonView.RPC("ChangeColorTo", PhotonTargets.OthersBuffered, color);
}

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

Conclusion

I hope you enjoyed reading this short tutorial about Photon Unity Networking.

The game now contains the functionality to create or join a Photon room, instantiate a player after the connecting is established, and move around individually. With these techniques you should be able to extend the game with other mechanics, like player rotations and shooting projectiles.

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