Make a Multiplayer Game in Unity using PUN 2

Ever wondered what it takes to create a multiplayer game inside Unity?

Unlike single-player games, multiplayer games require a remote server that plays the role of the bridge, allowing game clients to communicate with each other.

Nowadays numerous services take care of server hosting. One such service is Photon Network, which we will be using for this tutorial.

PUN 2 is the latest release of their API which has been greatly improved compared to the legacy version.

In this post, we will be running through downloading the necessary files, setting up Photon AppID, and programming a simple multiplayer example.

Unity version used in this tutorial: Unity 2018.3.0f2 (64-bit)

Part 1: Setting up PUN 2

The first step is downloading a PUN 2 package from the Asset Store. It contains all the scripts and files required for multiplayer integration.

  • Open your Unity project then go to the Asset Store: (Window -> General -> AssetStore) or press Ctrl+9
  • Search for "PUN 2- Free" then click the first result or click here
  • Import the PUN 2 package after the Download is finished

  • On the creation page, for Photon Type select "Photon Realtime" and for the Name, type any name then click "Create"

As you can see, the Application defaults to the Free plan. You can read more about Pricing Plans here

  • Once the Application is created, copy the App ID located under the App name

  • Go back to your Unity project then go to Window -> Photon Unity Networking -> PUN Wizard
  • In PUN Wizard click "Setup Project", paste your App ID then click "Setup Project"

  • The PUN 2 is now ready!

Part 2: Creating a multiplayer game

Now let's move to the part where we actually create a multiplayer game.

The way multiplayer is handled in PUN 2 is:

  • First, we connect to the Photon Region (ex. USA East, Europe, Asia, etc.) which is also known as the Lobby.
  • Once in the Lobby, we request all the Rooms that are created in the Region, and then we can either join one of the Rooms or create our own Room.
  • After joining the room we request a list of the players connected to the Room and instantiate their Player instances, which are then synced with their local instances through PhotonView.
  • When someone leaves the Room, their instance is destroyed and they are removed from the Player List.

1. Setting up a Lobby

Let's start by creating a Lobby scene that will contain Lobby logic (Browsing existing rooms, creating new rooms, etc.):

  • Create a new C# script and call it PUN2_GameLobby
  • Create a new Scene and call it "GameLobby"
  • In the GameLobby scene create a new GameObject. Call it "_GameLobby" and assign the PUN2_GameLobby script to it

Now open the PUN2_GameLobby script:

First, we import the Photon namespaces by adding the lines below at the beginning of the script:

using Photon.Pun;
using Photon.Realtime;

Also before continuing, we need to replace the default MonoBehaviour with MonoBehaviourPunCallbacks. This step is necessary to be able to use Photon callbacks:

public class PUN2_GameLobby : MonoBehaviourPunCallbacks

Next, we create the necessary variables:

    //Our player name
    string playerName = "Player 1";
    //Users are separated from each other by gameversion (which allows you to make breaking changes).
    string gameVersion = "0.9";
    //The list of created rooms
    List<RoomInfo> createdRooms = new List<RoomInfo>();
    //Use this name when creating a Room
    string roomName = "Room 1";
    Vector2 roomListScroll = Vector2.zero;
    bool joiningRoom = false;

Then we call ConnectUsingSettings() in the void Start(). This means as soon as the game opens, it connects to the Photon Server:

    // Use this for initialization
    void Start()
    {
        //This makes sure we can use PhotonNetwork.LoadLevel() on the master client and all clients in the same room sync their level automatically
        PhotonNetwork.AutomaticallySyncScene = true;

        if (!PhotonNetwork.IsConnected)
        {
            //Set the App version before connecting
            PhotonNetwork.PhotonServerSettings.AppSettings.AppVersion = gameVersion;
            // Connect to the photon master-server. We use the settings saved in PhotonServerSettings (a .asset file in this project)
            PhotonNetwork.ConnectUsingSettings();
        }
    }

To know whether a connection to Photon was successful, we need to implement these callbacks: OnDisconnected(DisconnectCause cause), OnConnectedToMaster(), OnRoomListUpdate(List<RoomInfo> roomList)

    public override void OnDisconnected(DisconnectCause cause)
    {
        Debug.Log("OnFailedToConnectToPhoton. StatusCode: " + cause.ToString() + " ServerAddress: " + PhotonNetwork.ServerAddress);
    }

    public override void OnConnectedToMaster()
    {
        Debug.Log("OnConnectedToMaster");
        //After we connected to Master server, join the Lobby
        PhotonNetwork.JoinLobby(TypedLobby.Default);
    }

    public override void OnRoomListUpdate(List<RoomInfo> roomList)
    {
        Debug.Log("We have received the Room list");
        //After this callback, update the room list
        createdRooms = roomList;
    }

Next is the UI part, where the Room browsing and Room creation are done:

    void OnGUI()
    {
        GUI.Window(0, new Rect(Screen.width / 2 - 450, Screen.height / 2 - 200, 900, 400), LobbyWindow, "Lobby");
    }

    void LobbyWindow(int index)
    {
        //Connection Status and Room creation Button
        GUILayout.BeginHorizontal();

        GUILayout.Label("Status: " + PhotonNetwork.NetworkClientState);

        if (joiningRoom || !PhotonNetwork.IsConnected || PhotonNetwork.NetworkClientState != ClientState.JoinedLobby)
        {
            GUI.enabled = false;
        }

        GUILayout.FlexibleSpace();

        //Room name text field
        roomName = GUILayout.TextField(roomName, GUILayout.Width(250));

        if (GUILayout.Button("Create Room", GUILayout.Width(125)))
        {
            if (roomName != "")
            {
                joiningRoom = true;

                RoomOptions roomOptions = new RoomOptions();
                roomOptions.IsOpen = true;
                roomOptions.IsVisible = true;
                roomOptions.MaxPlayers = (byte)10; //Set any number

                PhotonNetwork.JoinOrCreateRoom(roomName, roomOptions, TypedLobby.Default);
            }
        }

        GUILayout.EndHorizontal();

        //Scroll through available rooms
        roomListScroll = GUILayout.BeginScrollView(roomListScroll, true, true);

        if (createdRooms.Count == 0)
        {
            GUILayout.Label("No Rooms were created yet...");
        }
        else
        {
            for (int i = 0; i < createdRooms.Count; i++)
            {
                GUILayout.BeginHorizontal("box");
                GUILayout.Label(createdRooms[i].Name, GUILayout.Width(400));
                GUILayout.Label(createdRooms[i].PlayerCount + "/" + createdRooms[i].MaxPlayers);

                GUILayout.FlexibleSpace();

                if (GUILayout.Button("Join Room"))
                {
                    joiningRoom = true;

                    //Set our Player name
                    PhotonNetwork.NickName = playerName;

                    //Join the Room
                    PhotonNetwork.JoinRoom(createdRooms[i].Name);
                }
                GUILayout.EndHorizontal();
            }
        }

        GUILayout.EndScrollView();

        //Set player name and Refresh Room button
        GUILayout.BeginHorizontal();

        GUILayout.Label("Player Name: ", GUILayout.Width(85));
        //Player name text field
        playerName = GUILayout.TextField(playerName, GUILayout.Width(250));

        GUILayout.FlexibleSpace();

        GUI.enabled = (PhotonNetwork.NetworkClientState == ClientState.JoinedLobby || PhotonNetwork.NetworkClientState == ClientState.Disconnected) && !joiningRoom;
        if (GUILayout.Button("Refresh", GUILayout.Width(100)))
        {
            if (PhotonNetwork.IsConnected)
            {
                //Re-join Lobby to get the latest Room list
                PhotonNetwork.JoinLobby(TypedLobby.Default);
            }
            else
            {
                //We are not connected, estabilish a new connection
                PhotonNetwork.ConnectUsingSettings();
            }
        }

        GUILayout.EndHorizontal();

        if (joiningRoom)
        {
            GUI.enabled = true;
            GUI.Label(new Rect(900 / 2 - 50, 400 / 2 - 10, 100, 20), "Connecting...");
        }
    }

And lastly, we implement another 4 callbacks: OnCreateRoomFailed(short returnCode, string message), OnJoinRoomFailed(short returnCode, string message), OnCreatedRoom(), and OnJoinedRoom().

These callbacks are used to determine whether we Joined/Created the room or if there were any issues during the connection.

Here is the final PUN2_GameLobby.cs script:

using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;

public class PUN2_GameLobby : MonoBehaviourPunCallbacks
{

    //Our player name
    string playerName = "Player 1";
    //Users are separated from each other by gameversion (which allows you to make breaking changes).
    string gameVersion = "0.9";
    //The list of created rooms
    List<RoomInfo> createdRooms = new List<RoomInfo>();
    //Use this name when creating a Room
    string roomName = "Room 1";
    Vector2 roomListScroll = Vector2.zero;
    bool joiningRoom = false;

    // Use this for initialization
    void Start()
    {
        //This makes sure we can use PhotonNetwork.LoadLevel() on the master client and all clients in the same room sync their level automatically
        PhotonNetwork.AutomaticallySyncScene = true;

        if (!PhotonNetwork.IsConnected)
        {
            //Set the App version before connecting
            PhotonNetwork.PhotonServerSettings.AppSettings.AppVersion = gameVersion;
            // Connect to the photon master-server. We use the settings saved in PhotonServerSettings (a .asset file in this project)
            PhotonNetwork.ConnectUsingSettings();
        }
    }

    public override void OnDisconnected(DisconnectCause cause)
    {
        Debug.Log("OnFailedToConnectToPhoton. StatusCode: " + cause.ToString() + " ServerAddress: " + PhotonNetwork.ServerAddress);
    }

    public override void OnConnectedToMaster()
    {
        Debug.Log("OnConnectedToMaster");
        //After we connected to Master server, join the Lobby
        PhotonNetwork.JoinLobby(TypedLobby.Default);
    }

    public override void OnRoomListUpdate(List<RoomInfo> roomList)
    {
        Debug.Log("We have received the Room list");
        //After this callback, update the room list
        createdRooms = roomList;
    }

    void OnGUI()
    {
        GUI.Window(0, new Rect(Screen.width / 2 - 450, Screen.height / 2 - 200, 900, 400), LobbyWindow, "Lobby");
    }

    void LobbyWindow(int index)
    {
        //Connection Status and Room creation Button
        GUILayout.BeginHorizontal();

        GUILayout.Label("Status: " + PhotonNetwork.NetworkClientState);

        if (joiningRoom || !PhotonNetwork.IsConnected || PhotonNetwork.NetworkClientState != ClientState.JoinedLobby)
        {
            GUI.enabled = false;
        }

        GUILayout.FlexibleSpace();

        //Room name text field
        roomName = GUILayout.TextField(roomName, GUILayout.Width(250));

        if (GUILayout.Button("Create Room", GUILayout.Width(125)))
        {
            if (roomName != "")
            {
                joiningRoom = true;

                RoomOptions roomOptions = new RoomOptions();
                roomOptions.IsOpen = true;
                roomOptions.IsVisible = true;
                roomOptions.MaxPlayers = (byte)10; //Set any number

                PhotonNetwork.JoinOrCreateRoom(roomName, roomOptions, TypedLobby.Default);
            }
        }

        GUILayout.EndHorizontal();

        //Scroll through available rooms
        roomListScroll = GUILayout.BeginScrollView(roomListScroll, true, true);

        if (createdRooms.Count == 0)
        {
            GUILayout.Label("No Rooms were created yet...");
        }
        else
        {
            for (int i = 0; i < createdRooms.Count; i++)
            {
                GUILayout.BeginHorizontal("box");
                GUILayout.Label(createdRooms[i].Name, GUILayout.Width(400));
                GUILayout.Label(createdRooms[i].PlayerCount + "/" + createdRooms[i].MaxPlayers);

                GUILayout.FlexibleSpace();

                if (GUILayout.Button("Join Room"))
                {
                    joiningRoom = true;

                    //Set our Player name
                    PhotonNetwork.NickName = playerName;

                    //Join the Room
                    PhotonNetwork.JoinRoom(createdRooms[i].Name);
                }
                GUILayout.EndHorizontal();
            }
        }

        GUILayout.EndScrollView();

        //Set player name and Refresh Room button
        GUILayout.BeginHorizontal();

        GUILayout.Label("Player Name: ", GUILayout.Width(85));
        //Player name text field
        playerName = GUILayout.TextField(playerName, GUILayout.Width(250));

        GUILayout.FlexibleSpace();

        GUI.enabled = (PhotonNetwork.NetworkClientState == ClientState.JoinedLobby || PhotonNetwork.NetworkClientState == ClientState.Disconnected) && !joiningRoom;
        if (GUILayout.Button("Refresh", GUILayout.Width(100)))
        {
            if (PhotonNetwork.IsConnected)
            {
                //Re-join Lobby to get the latest Room list
                PhotonNetwork.JoinLobby(TypedLobby.Default);
            }
            else
            {
                //We are not connected, estabilish a new connection
                PhotonNetwork.ConnectUsingSettings();
            }
        }

        GUILayout.EndHorizontal();

        if (joiningRoom)
        {
            GUI.enabled = true;
            GUI.Label(new Rect(900 / 2 - 50, 400 / 2 - 10, 100, 20), "Connecting...");
        }
    }

    public override void OnCreateRoomFailed(short returnCode, string message)
    {
        Debug.Log("OnCreateRoomFailed got called. This can happen if the room exists (even if not visible). Try another room name.");
        joiningRoom = false;
    }

    public override void OnJoinRoomFailed(short returnCode, string message)
    {
        Debug.Log("OnJoinRoomFailed got called. This can happen if the room is not existing or full or closed.");
        joiningRoom = false;
    }

    public override void OnJoinRandomFailed(short returnCode, string message)
    {
        Debug.Log("OnJoinRandomFailed got called. This can happen if the room is not existing or full or closed.");
        joiningRoom = false;
    }

    public override void OnCreatedRoom()
    {
        Debug.Log("OnCreatedRoom");
        //Set our player name
        PhotonNetwork.NickName = playerName;
        //Load the Scene called GameLevel (Make sure it's added to build settings)
        PhotonNetwork.LoadLevel("GameLevel");
    }

    public override void OnJoinedRoom()
    {
        Debug.Log("OnJoinedRoom");
    }
}

2. Creating a Player prefab

In Multiplayer games, the Player instance has 2 sides: Local and Remote

A local instance is controlled locally (by us).

A remote instance, on the other hand, is a local representation of what the other player is doing. It should be unaffected by our input.

To determine whether the instance is Local or Remote we use a PhotonView component.

PhotonView acts as a messenger that receives and sends the values that need to be synced, for example, position and rotation.

So let's begin by creating the player instance (If you already have your player instance ready, you can skip this step).

In my case, the Player instance will be a simple Cube that is moved with the W and S keys and rotated with the A and D keys.

Here is a simple controller script:

SimplePlayerController.cs

using UnityEngine;

public class SimplePlayerController : MonoBehaviour
{

    // Update is called once per frame
    void Update()
    {
        //Move Front/Back
        if (Input.GetKey(KeyCode.W))
        {
            transform.Translate(transform.forward * Time.deltaTime * 2.45f, Space.World);
        }
        else if (Input.GetKey(KeyCode.S))
        {
            transform.Translate(-transform.forward * Time.deltaTime * 2.45f, Space.World);
        }

        //Rotate Left/Right
        if (Input.GetKey(KeyCode.A))
        {
            transform.Rotate(new Vector3(0, -14, 0) * Time.deltaTime * 4.5f, Space.Self);
        }
        else if (Input.GetKey(KeyCode.D))
        {
            transform.Rotate(new Vector3(0, 14, 0) * Time.deltaTime * 4.5f, Space.Self);
        }
    }
}

The next step is to add a PhotonView component.

  • Add a PhotonView component to Player Instance.
  • Create a new C# script, and call it PUN2_PlayerSync (this script will be used to communicate through PhotonView).

Open PUN2_PlayerSync script:

In PUN2_PlayerSync first thing we need to do is to add a Photon.Pun namespace and replace MonoBehaviour with MonoBehaviourPun and also add IPunObservable interface.

MonoBehaviourPun is necessary to be able to use the cached photonView variable, instead of using GetComponent<PhotonView>().

using UnityEngine;
using Photon.Pun;

public class PUN2_PlayerSync : MonoBehaviourPun, IPunObservable

After that, we can move to create all the necessary variables:

    //List of the scripts that should only be active for the local player (ex. PlayerController, MouseLook etc.)
    public MonoBehaviour[] localScripts;
    //List of the GameObjects that should only be active for the local player (ex. Camera, AudioListener etc.)
    public GameObject[] localObjects;
    //Values that will be synced over network
    Vector3 latestPos;
    Quaternion latestRot;

Then in the void Start(), we check if the player is Local or Remote by using photonView.IsMine:

    // Use this for initialization
    void Start()
    {
        if (photonView.IsMine)
        {
            //Player is local
        }
        else
        {
            //Player is Remote, deactivate the scripts and object that should only be enabled for the local player
            for (int i = 0; i < localScripts.Length; i++)
            {
                localScripts[i].enabled = false;
            }
            for (int i = 0; i < localObjects.Length; i++)
            {
                localObjects[i].SetActive(false);
            }
        }
    }

The actual synchronization is done through the PhotonView's callback: OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info):

    public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
    {
        if (stream.IsWriting)
        {
            //We own this player: send the others our data
            stream.SendNext(transform.position);
            stream.SendNext(transform.rotation);
        }
        else
        {
            //Network player, receive data
            latestPos = (Vector3)stream.ReceiveNext();
            latestRot = (Quaternion)stream.ReceiveNext();
        }
    }

In this case, we only send the player Position and Rotation, but you can use the example above to send any value that is needed to be synced over the network, at a high frequency.

Received values are then applied in the void Update():

    // Update is called once per frame
    void Update()
    {
        if (!photonView.IsMine)
        {
            //Update remote player (smooth this, this looks good, at the cost of some accuracy)
            transform.position = Vector3.Lerp(transform.position, latestPos, Time.deltaTime * 5);
            transform.rotation = Quaternion.Lerp(transform.rotation, latestRot, Time.deltaTime * 5);
        }
    }
}

Here is the final PUN2_PlayerSync.cs script:

using UnityEngine;
using Photon.Pun;

public class PUN2_PlayerSync : MonoBehaviourPun, IPunObservable
{

    //List of the scripts that should only be active for the local player (ex. PlayerController, MouseLook etc.)
    public MonoBehaviour[] localScripts;
    //List of the GameObjects that should only be active for the local player (ex. Camera, AudioListener etc.)
    public GameObject[] localObjects;
    //Values that will be synced over network
    Vector3 latestPos;
    Quaternion latestRot;

    // Use this for initialization
    void Start()
    {
        if (photonView.IsMine)
        {
            //Player is local
        }
        else
        {
            //Player is Remote, deactivate the scripts and object that should only be enabled for the local player
            for (int i = 0; i < localScripts.Length; i++)
            {
                localScripts[i].enabled = false;
            }
            for (int i = 0; i < localObjects.Length; i++)
            {
                localObjects[i].SetActive(false);
            }
        }
    }

    public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
    {
        if (stream.IsWriting)
        {
            //We own this player: send the others our data
            stream.SendNext(transform.position);
            stream.SendNext(transform.rotation);
        }
        else
        {
            //Network player, receive data
            latestPos = (Vector3)stream.ReceiveNext();
            latestRot = (Quaternion)stream.ReceiveNext();
        }
    }

    // Update is called once per frame
    void Update()
    {
        if (!photonView.IsMine)
        {
            //Update remote player (smooth this, this looks good, at the cost of some accuracy)
            transform.position = Vector3.Lerp(transform.position, latestPos, Time.deltaTime * 5);
            transform.rotation = Quaternion.Lerp(transform.rotation, latestRot, Time.deltaTime * 5);
        }
    }
}

Now let's assign a newly created script:

  • Attach the PUN2_PlayerSync script to the PlayerInstance.
  • Drag and drop PUN2_PlayerSync into the PhotonView Observed Components.
  • Assign the SimplePlayerController to "Local Scripts" and assign the GameObjects (that you want to be deactivated for Remote players) to the "Local Objects"

  • Save the PlayerInstance to Prefab and move it to the folder called Resources (If there is no such folder, create one). This step is necessary to be able to spawn multiplayer Objects over the Network.

3. Creating a Game Level

GameLevel is a Scene that is loaded after joining the Room and it's where all the action happens.

  • Create a new Scene and call it "GameLevel" (Or if you want to keep a different name, make sure to change the name in this line PhotonNetwork.LoadLevel("GameLevel"); at the PUN2_GameLobby.cs).

In my case, I will be using a simple Scene with a Plane:

  • Now create a new script and call it PUN2_RoomController (This script will handle the logic inside the Room, like spawning the players, showing the players list, etc.).

Open PUN2_RoomController script:

Same as PUN2_GameLobby we begin by adding Photon namespaces and replacing MonoBehaviour with MonoBehaviourPunCallbacks:

using UnityEngine;
using Photon.Pun;

public class PUN2_RoomController : MonoBehaviourPunCallbacks

Now let's add the necessary variables:

    //Player instance prefab, must be located in the Resources folder
    public GameObject playerPrefab;
    //Player spawn point
    public Transform spawnPoint;

To instantiate the Player prefab we are using PhotonNetwork.Instantiate:

    // Use this for initialization
    void Start()
    {
        //In case we started this demo with the wrong scene being active, simply load the menu scene
        if (PhotonNetwork.CurrentRoom == null)
        {
            Debug.Log("Is not in the room, returning back to Lobby");
            UnityEngine.SceneManagement.SceneManager.LoadScene("GameLobby");
            return;
        }

        //We're in a room. spawn a character for the local player. it gets synced by using PhotonNetwork.Instantiate
        PhotonNetwork.Instantiate(playerPrefab.name, spawnPoint.position, Quaternion.identity, 0);
    }

And a simple UI with a "Leave Room" button and some additional elements such as Room name and the list of connected Players:

    void OnGUI()
    {
        if (PhotonNetwork.CurrentRoom == null)
            return;

        //Leave this Room
        if (GUI.Button(new Rect(5, 5, 125, 25), "Leave Room"))
        {
            PhotonNetwork.LeaveRoom();
        }

        //Show the Room name
        GUI.Label(new Rect(135, 5, 200, 25), PhotonNetwork.CurrentRoom.Name);

        //Show the list of the players connected to this Room
        for (int i = 0; i < PhotonNetwork.PlayerList.Length; i++)
        {
            //Show if this player is a Master Client. There can only be one Master Client per Room so use this to define the authoritative logic etc.)
            string isMasterClient = (PhotonNetwork.PlayerList[i].IsMasterClient ? ": MasterClient" : "");
            GUI.Label(new Rect(5, 35 + 30 * i, 200, 25), PhotonNetwork.PlayerList[i].NickName + isMasterClient);
        }
    }

Finally, we implement another PhotonNetwork callback called OnLeftRoom() which is called when we leave the Room:

    public override void OnLeftRoom()
    {
        //We have left the Room, return back to the GameLobby
        UnityEngine.SceneManagement.SceneManager.LoadScene("GameLobby");
    }

Here are the final PUN2_RoomController.cs script:

using UnityEngine;
using Photon.Pun;

public class PUN2_RoomController : MonoBehaviourPunCallbacks
{

    //Player instance prefab, must be located in the Resources folder
    public GameObject playerPrefab;
    //Player spawn point
    public Transform spawnPoint;

    // Use this for initialization
    void Start()
    {
        //In case we started this demo with the wrong scene being active, simply load the menu scene
        if (PhotonNetwork.CurrentRoom == null)
        {
            Debug.Log("Is not in the room, returning back to Lobby");
            UnityEngine.SceneManagement.SceneManager.LoadScene("GameLobby");
            return;
        }

        //We're in a room. spawn a character for the local player. it gets synced by using PhotonNetwork.Instantiate
        PhotonNetwork.Instantiate(playerPrefab.name, spawnPoint.position, Quaternion.identity, 0);
    }

    void OnGUI()
    {
        if (PhotonNetwork.CurrentRoom == null)
            return;

        //Leave this Room
        if (GUI.Button(new Rect(5, 5, 125, 25), "Leave Room"))
        {
            PhotonNetwork.LeaveRoom();
        }

        //Show the Room name
        GUI.Label(new Rect(135, 5, 200, 25), PhotonNetwork.CurrentRoom.Name);

        //Show the list of the players connected to this Room
        for (int i = 0; i < PhotonNetwork.PlayerList.Length; i++)
        {
            //Show if this player is a Master Client. There can only be one Master Client per Room so use this to define the authoritative logic etc.)
            string isMasterClient = (PhotonNetwork.PlayerList[i].IsMasterClient ? ": MasterClient" : "");
            GUI.Label(new Rect(5, 35 + 30 * i, 200, 25), PhotonNetwork.PlayerList[i].NickName + isMasterClient);
        }
    }

    public override void OnLeftRoom()
    {
        //We have left the Room, return back to the GameLobby
        UnityEngine.SceneManagement.SceneManager.LoadScene("GameLobby");
    }
}
  • Create a new GameObject in the 'GameLevel' scene and call it "_RoomController"
  • Attach the PUN2_RoomController script to the _RoomController Object
  • Assign the PlayerInstance prefab and a SpawnPoint Transform to it then save the Scene

  • Add both MainMenu and GameLevel to the Build Settings.

4. Making a test Build

Now it's time to make a build and test it:

Everything works as expected!

Bonus

RPC

In PUN 2, RPC stands for Remote Procedure Call, it's used to call a function on Remote clients that are in the same room (You can read more about it here).

RPCs have many uses, for example, let's say you need to send a chat message to all the players in the Room. With RPCs, it's easy to do so:

[PunRPC]
void ChatMessage(string senderName, string messageText)
{
    Debug.Log(string.Format("{0}: {1}", senderName, messageText));
}

Notice the [PunRPC] before the function. This attribute is necessary if you plan to call the function via RPCs.

To call the functions marked as RPC, you need a PhotonView. Example call:

PhotonView photonView = PhotonView.Get(this);
photonView.RPC("ChatMessage", RpcTarget.All, PhotonNetwork.playerName, "Some message");

Pro tip: If your replace MonoBehaviour in your script with MonoBehaviourPun or MonoBehaviourPunCallbacks you can skip PhotonView.Get() and use photonView.RPC() directly.

Custom Properties

In PUN 2, Custom Properties is a Hashtable that can be assigned to a Player or the Room.

This is useful when you need to set persistent data that doesn't need to be changed frequently (ex. Player Team Name, Room Game Mode, etc.).

First, you have to define a Hashtable, which is done by adding the line below at the beginning of the script:

//Replace default Hashtables with Photon hashtables
using Hashtable = ExitGames.Client.Photon.Hashtable;

The example below sets the Room properties called "GameMode" and "AnotherProperty":

        //Set Room properties (Only Master Client is allowed to set Room properties)
        if (PhotonNetwork.IsMasterClient)
        {
            Hashtable setRoomProperties = new Hashtable();
            setRoomProperties.Add("GameMode", "FFA");
            setRoomProperties.Add("AnotherProperty", "Test");
            PhotonNetwork.CurrentRoom.SetCustomProperties(setRoomProperties);
        }

        //Will print "FFA"
        print((string)PhotonNetwork.CurrentRoom.CustomProperties["GameMode"]);
        //Will print "Test"
        print((string)PhotonNetwork.CurrentRoom.CustomProperties["AnotherProperty"]);

Player properties are set similarly:

            Hashtable setPlayerProperties = new Hashtable();
            setPlayerProperties.Add("PlayerHP", (float)100);
            PhotonNetwork.LocalPlayer.SetCustomProperties(setPlayerProperties);

            print((float)PhotonNetwork.LocalPlayer.CustomProperties["PlayerHP"]);

To remove a specific property just set its value to null.

            Hashtable setPlayerProperties = new Hashtable();
            setPlayerProperties.Add("PlayerHP", null);
            PhotonNetwork.LocalPlayer.SetCustomProperties(setPlayerProperties);

Additional tutorials:

Sync Rigidbodies Over Network Using PUN 2

PUN 2 Adding Room Chat

Links
Unity 6