Introduction

I try to build a lightweight multiplayer networking framework by using Photon PUN’s free tier.

The source code is available on GitHub.

Network Manager

Manage the connection to Photon server, room joining/leaving, and scene loading.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
public class NetworkManager : MonoBehaviourPunCallbacks
{
private static NetworkManager _instance;
private bool _isConnected = false;
private bool _isRoomListUpdated = false;
private List<RoomInfo> _roomList;

private void Awake()
{
if (_instance && _instance != this)
{
Destroy(gameObject);
return;
}

// Singleton pattern
_instance = this;
DontDestroyOnLoad(gameObject);

// Connect to Photon server
PhotonNetwork.AutomaticallySyncScene = true;
PhotonNetwork.ConnectUsingSettings();
}

public override void OnConnectedToMaster()
{
Debug.Log("Connected to Master Server");
PhotonNetwork.JoinLobby();
}

public override void OnJoinedLobby()
{
Debug.Log("Joined Lobby");
_isConnected = true;
}

public override void OnJoinedRoom()
{
var room = PhotonNetwork.CurrentRoom;
Debug.Log($"Joined Room: {room.Name}");
Debug.Log($"Players: {room.PlayerCount}/{room.MaxPlayers}");
Debug.Log($"IsOpen: {room.IsOpen}, IsVisible: {room.IsVisible}");

SceneManager.sceneLoaded += OnRoomSceneLoaded;
SceneManager.sceneLoaded += OnGameSceneLoaded;
PhotonNetwork.LoadLevel("Room");
}

public override void OnLeftRoom()
{
Debug.Log("Left Room");

if (PhotonNetwork.IsConnected)
PhotonNetwork.Disconnect();
PhotonNetwork.LoadLevel("Menu");
}

public override void OnRoomListUpdate(List<RoomInfo> roomList)
{
_roomList = roomList;
_isRoomListUpdated = true;
}

private static void OnRoomSceneLoaded(Scene scene, LoadSceneMode mode)
{
if (scene.name == "Room")
{
PhotonNetwork.Instantiate("Player", Vector3.zero, Quaternion.identity);
SceneManager.sceneLoaded -= OnRoomSceneLoaded;
}
}

private static void OnGameSceneLoaded(Scene scene, LoadSceneMode mode)
{
if (scene.name == "Game")
{
PhotonNetwork.Instantiate("Player", Vector3.zero, Quaternion.identity);
SceneManager.sceneLoaded -= OnGameSceneLoaded;
}
}
}

Lobby

Script will hide/show certain UI elements based on connection status.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class Lobby : MonoBehaviour
{
public List<GameObject> waitingConnectedObjects;
private bool _isConnected = false;

private void Awake()
{
// Disable connected objects until connected to Photon server
foreach (var obj in waitingConnectedObjects)
obj.SetActive(false);
}

private void Update()
{
if (Input.GetKeyDown(KeyCode.Escape))
SceneManager.LoadScene("Menu");

// Enable connected objects
if (!_isConnected && NetworkManager.Instance().IsConnected())
{
_isConnected = true;
foreach (var obj in waitingConnectedObjects)
obj.SetActive(true);
}
}
}

Host / Join Room

Host / Join room with options provided by Photon PUN.

  • Add password protection feature.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class HostRoom : MonoBehaviour
{
public TMP_InputField roomNameInputField;
public List<Toggle> maxPlayersToggles;
public Toggle visibleToggle;

public void OnHostButtonClicked()
{
if (string.IsNullOrEmpty(roomNameInputField.text))
return;

var maxPlayers = 0;
foreach (var toggle in maxPlayersToggles)
{
if (toggle.isOn)
{
maxPlayers = int.Parse(toggle.name);
break;
}
}

var isVisible = visibleToggle.isOn;

var roomOptions = new Photon.Realtime.RoomOptions
{
MaxPlayers = maxPlayers,
IsVisible = isVisible,
IsOpen = true
};
PhotonNetwork.CreateRoom(roomNameInputField.text, roomOptions);
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class JoinRoom : MonoBehaviour
{
public GameObject roomEntryPrefab;
public Transform roomListContent;
public TMP_InputField roomNameInputField;
private readonly Dictionary<string, GameObject> _roomEntries = new();

private void Update()
{
if (Input.GetKeyDown(KeyCode.Escape))
PhotonNetwork.LoadLevel("Lobby");

if (!NetworkManager.Instance().IsRoomListUpdated() && _roomEntries.Count > 0)
return;

// Delete old entries
foreach (var entry in _roomEntries.Values)
Destroy(entry);
_roomEntries.Clear();

foreach (var roomInfo in NetworkManager.Instance().RoomList())
{
if (roomInfo.RemovedFromList)
continue;

// Only show open rooms with available slots
if (!roomInfo.IsOpen || roomInfo.PlayerCount >= roomInfo.MaxPlayers)
continue;

var entryObj = Instantiate(roomEntryPrefab, roomListContent);
_roomEntries[roomInfo.Name] = entryObj;

// Set room info text
var text = entryObj.GetComponentInChildren<TMP_Text>();
text.text = $"Room: {roomInfo.Name} " +
$"Players: {roomInfo.PlayerCount}/{roomInfo.MaxPlayers}";

// Set button click listener
var button = entryObj.GetComponent<Button>();
var capturedName = roomInfo.Name;
button.onClick.AddListener(() => { roomNameInputField.text = capturedName; });
}
}

public void OnJoinButtonClicked()
{
if (string.IsNullOrEmpty(roomNameInputField.text))
return;

PhotonNetwork.JoinRoom(roomNameInputField.text);
}
}

Room

Players are waiting in the room until all are ready.

  • Players can wait in different rooms(like matchmaking) before starting the game.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
public class Room : MonoBehaviourPunCallbacks
{
public Button readyButton;
private bool _isReady = false;
private const string PlayerReadyKey = "Ready";

private void Awake()
{
UpdateReadyButtonText();
readyButton.onClick.AddListener(OnReadyButtonClicked);
}

private void Update()
{
if (Input.GetKeyDown(KeyCode.Escape))
PhotonNetwork.LeaveRoom();
}

private void OnReadyButtonClicked()
{
_isReady = !_isReady;
UpdateReadyButtonText();

var props = new Hashtable
{
{ PlayerReadyKey, _isReady }
};
PhotonNetwork.LocalPlayer.SetCustomProperties(props);
}

private void UpdateReadyButtonText()
{
readyButton.GetComponentInChildren<TMP_Text>().text =
_isReady ? "Unready" : "Ready";
}

public override void OnPlayerPropertiesUpdate(Photon.Realtime.Player player, Hashtable changedProps)
{
if (!changedProps.ContainsKey(PlayerReadyKey))
return;

if (PhotonNetwork.IsMasterClient)
TryStartGame();
}

private static void TryStartGame()
{
// check if all players are ready
var readyPlayers = 0;
foreach (var player in PhotonNetwork.PlayerList)
{
if (!player.CustomProperties.TryGetValue(PlayerReadyKey, out var readyObj))
return;
if (!(bool)readyObj)
return;

readyPlayers++;
}

if (readyPlayers == PhotonNetwork.CurrentRoom.MaxPlayers)
PhotonNetwork.LoadLevel("Game");
}
}

Game

Players will keep some status from the room, e.g., appearance(color).

Use Photon custom properties to sync player appearance.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class Appearance : MonoBehaviourPunCallbacks
{
private SpriteRenderer _spriteRenderer;
private const string PlayerColorKey = "Color";

private void Awake()
{
_spriteRenderer = GetComponent<SpriteRenderer>();
if (photonView.Owner.CustomProperties.TryGetValue(PlayerColorKey, out var colorObj))
{
// Retrieve and apply the color from custom properties
var rgb = (float[])colorObj;
_spriteRenderer.color = new Color(rgb[0], rgb[1], rgb[2]);
}
else
{
// Default color if none is set
_spriteRenderer.color = Color.white;
}
}

private void Update()
{
if (!photonView.IsMine)
return;

if (SceneManager.GetActiveScene().name == "Game") return;
if (Input.GetKeyDown(KeyCode.Alpha1)) SetColor(Color.red);
else if (Input.GetKeyDown(KeyCode.Alpha2)) SetColor(Color.green);
else if (Input.GetKeyDown(KeyCode.Alpha3)) SetColor(Color.blue);
else if (Input.GetKeyDown(KeyCode.Alpha4)) SetColor(Color.yellow);
}

private void SetColor(Color color)
{
_spriteRenderer.color = color;
var rgb = new[] { color.r, color.g, color.b };
var props = new Hashtable
{
{ PlayerColorKey, rgb }
};
PhotonNetwork.LocalPlayer.SetCustomProperties(props);
}

public override void OnPlayerPropertiesUpdate(Photon.Realtime.Player targetPlayer, Hashtable changedProps)
{
if (changedProps.ContainsKey(PlayerColorKey))
{
if (photonView.Owner.Equals(targetPlayer))
{
var rgb = (float[])changedProps[PlayerColorKey];
_spriteRenderer.color = new Color(rgb[0], rgb[1], rgb[2]);
}
}
}
}

Menu

Destroy the NetworkManager instance when returning to the menu scene to avoid some bugs.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Menu : MonoBehaviour
{
private void Awake()
{
if (NetworkManager.Instance())
Destroy(NetworkManager.Instance().gameObject);
}

private void Update()
{
if (Input.GetKeyDown(KeyCode.Escape))
Application.Quit();
}

public void OnOnlineButtonClicked()
{
SceneManager.LoadScene("Lobby");
}
}