Session management
You can use session management to keep data when a player disconnects and accurately reassign it to the player when they reconnect.
Linking data to players
To reassign data to the correct player when they reconnect, you need to link this data to the player.
The clientId generated by Netcode for GameObjects (Netcode) can't be used, because it generates when a player connects and is disposed of when they disconnect. That ID may then be assigned to a different player if they connect to the server after the first one disconnected.
To properly link data to a specific player, we need an ID that isn't tied to the current connection, but persists through time and is unique to that player. Some options include
A login system with unique player accounts
A Globally Unique Identifier (GUID). For example, a GUID generated via
System.Guid.NewGuid()
and then saved to thePlayerPrefs
client side.
With this unique identifier, you can map each player's data (that's needed when reconnecting), such as the current state of their character (last known position, health, and the like). You then ensure this data is up to date and kept host side after a player disconnects to repopulate that player's data when they reconnect.
You can also decide to clear all data when a session completes or add a timeout to purge the data after a specific amount of time.
Reconnection
The best way to reconnect players depends on your game. For example, if you use a Player Object, a new Default Player Prefab
automatically spawns when a player connects to the game (including when they reconnect). You can use the player's earlier saved session data to update that object so that it returns to the same state before disconnecting. In those cases, you would need to keep all the important data that you want to restore and map it to the player using your identification system. You can save this data when a player disconnects or update it periodically. You can then use the OnNetworkSpawn
event on the Player Object's NetworkBehavior
(s) to get this data and apply it where needed.
In cases where we don't use the Player Object approach and instead manually attribute client ownership to NetworkObject(s), we can keep the objects that a player owns when they disconnect, and set the reconnected player as their new owner. To accomplish this, the only data we would need to keep would be the mapping between those objects and their owning player's identifier, then when a player reconnects we can use this mapping to set them as the new owner. This mapping can be as simple as a dictionary mapping the player identifier with the NetworkObjectId
(s) of the NetworkObject(s) they own. Then, in the OnClientConnectedCallback
from the NetworkManager
, the server can set the ownership of these objects.
Here is an example from the Boss Room sample, showing some simple session management. The game uses the Player Object approach and a GUID to identify unique players.
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Unity.Multiplayer.Samples.BossRoom
{
public interface ISessionPlayerData
{
bool IsConnected { get; set; }
ulong ClientID { get; set; }
void Reinitialize();
}
/// <summary>
/// This class uses a unique player ID to bind a player to a session. Once that player connects to a host, the host
/// associates the current ClientID to the player's unique ID. If the player disconnects and reconnects to the same
/// host, the session is preserved.
/// </summary>
/// <remarks>
/// Using a client-generated player ID and sending it directly could be problematic, as a malicious user could
/// intercept it and reuse it to impersonate the original user. We are currently investigating this to offer a
/// solution that handles security better.
/// </remarks>
/// <typeparam name="T"></typeparam>
public class SessionManager<T> where T : struct, ISessionPlayerData
{
SessionManager()
{
m_ClientData = new Dictionary<string, T>();
m_ClientIDToPlayerId = new Dictionary<ulong, string>();
}
public static SessionManager<T> Instance => s_Instance ??= new SessionManager<T>();
static SessionManager<T> s_Instance;
/// <summary>
/// Maps a given client player id to the data for a given client player.
/// </summary>
Dictionary<string, T> m_ClientData;
/// <summary>
/// Map to allow us to cheaply map from player id to player data.
/// </summary>
Dictionary<ulong, string> m_ClientIDToPlayerId;
bool m_HasSessionStarted;
/// <summary>
/// Handles client disconnect."
/// </summary>
public void DisconnectClient(ulong clientId)
{
if (m_HasSessionStarted)
{
// Mark client as disconnected, but keep their data so they can reconnect.
if (m_ClientIDToPlayerId.TryGetValue(clientId, out var playerId))
{
if (GetPlayerData(playerId)?.ClientID == clientId)
{
var clientData = m_ClientData[playerId];
clientData.IsConnected = false;
m_ClientData[playerId] = clientData;
}
}
}
else
{
// Session has not started, no need to keep their data
if (m_ClientIDToPlayerId.TryGetValue(clientId, out var playerId))
{
m_ClientIDToPlayerId.Remove(clientId);
if (GetPlayerData(playerId)?.ClientID == clientId)
{
m_ClientData.Remove(playerId);
}
}
}
}
/// <summary>
///
/// </summary>
/// <param name="playerId">This is the playerId that is unique to this client and persists across multiple logins from the same client</param>
/// <returns>True if a player with this ID is already connected.</returns>
public bool IsDuplicateConnection(string playerId)
{
return m_ClientData.ContainsKey(playerId) && m_ClientData[playerId].IsConnected;
}
/// <summary>
/// Adds a connecting player's session data if it is a new connection, or updates their session data in case of a reconnection.
/// </summary>
/// <param name="clientId">This is the clientId that Netcode assigned us on login. It does not persist across multiple logins from the same client. </param>
/// <param name="playerId">This is the playerId that is unique to this client and persists across multiple logins from the same client</param>
/// <param name="sessionPlayerData">The player's initial data</param>
public void SetupConnectingPlayerSessionData(ulong clientId, string playerId, T sessionPlayerData)
{
var isReconnecting = false;
// Test for duplicate connection
if (IsDuplicateConnection(playerId))
{
Debug.LogError($"Player ID {playerId} already exists. This is a duplicate connection. Rejecting this session data.");
return;
}
// If another client exists with the same playerId
if (m_ClientData.ContainsKey(playerId))
{
if (!m_ClientData[playerId].IsConnected)
{
// If this connecting client has the same player Id as a disconnected client, this is a reconnection.
isReconnecting = true;
}
}
// Reconnecting. Give data from old player to new player
if (isReconnecting)
{
// Update player session data
sessionPlayerData = m_ClientData[playerId];
sessionPlayerData.ClientID = clientId;
sessionPlayerData.IsConnected = true;
}
//Populate our dictionaries with the SessionPlayerData
m_ClientIDToPlayerId[clientId] = playerId;
m_ClientData[playerId] = sessionPlayerData;
}
/// <summary>
///
/// </summary>
/// <param name="clientId"> id of the client whose data is requested</param>
/// <returns>The Player ID matching the given client ID</returns>
public string GetPlayerId(ulong clientId)
{
if (m_ClientIDToPlayerId.TryGetValue(clientId, out string playerId))
{
return playerId;
}
Debug.Log($"No client player ID found mapped to the given client ID: {clientId}");
return null;
}
/// <summary>
///
/// </summary>
/// <param name="clientId"> id of the client whose data is requested</param>
/// <returns>Player data struct matching the given ID</returns>
public T? GetPlayerData(ulong clientId)
{
//First see if we have a playerId matching the clientID given.
var playerId = GetPlayerId(clientId);
if (playerId != null)
{
return GetPlayerData(playerId);
}
Debug.Log($"No client player ID found mapped to the given client ID: {clientId}");
return null;
}
/// <summary>
///
/// </summary>
/// <param name="playerId"> Player ID of the client whose data is requested</param>
/// <returns>Player data struct matching the given ID</returns>
public T? GetPlayerData(string playerId)
{
if (m_ClientData.TryGetValue(playerId, out T data))
{
return data;
}
Debug.Log($"No PlayerData of matching player ID found: {playerId}");
return null;
}
/// <summary>
/// Updates player data
/// </summary>
/// <param name="clientId"> id of the client whose data will be updated </param>
/// <param name="sessionPlayerData"> new data to overwrite the old </param>
public void SetPlayerData(ulong clientId, T sessionPlayerData)
{
if (m_ClientIDToPlayerId.TryGetValue(clientId, out string playerId))
{
m_ClientData[playerId] = sessionPlayerData;
}
else
{
Debug.LogError($"No client player ID found mapped to the given client ID: {clientId}");
}
}
/// <summary>
/// Marks the current session as started, so from now on we keep the data of disconnected players.
/// </summary>
public void OnSessionStarted()
{
m_HasSessionStarted = true;
}
/// <summary>
/// Reinitializes session data from connected players, and clears data from disconnected players, so that if they reconnect in the next game, they will be treated as new players
/// </summary>
public void OnSessionEnded()
{
ClearDisconnectedPlayersData();
ReinitializePlayersData();
m_HasSessionStarted = false;
}
/// <summary>
/// Resets all our runtime state, so it is ready to be reinitialized when starting a new server
/// </summary>
public void OnServerEnded()
{
m_ClientData.Clear();
m_ClientIDToPlayerId.Clear();
m_HasSessionStarted = false;
}
void ReinitializePlayersData()
{
foreach (var id in m_ClientIDToPlayerId.Keys)
{
string playerId = m_ClientIDToPlayerId[id];
T sessionPlayerData = m_ClientData[playerId];
sessionPlayerData.Reinitialize();
m_ClientData[playerId] = sessionPlayerData;
}
}
void ClearDisconnectedPlayersData()
{
List<ulong> idsToClear = new List<ulong>();
foreach (var id in m_ClientIDToPlayerId.Keys)
{
var data = GetPlayerData(id);
if (data is { IsConnected: false })
{
idsToClear.Add(id);
}
}
foreach (var id in idsToClear)
{
string playerId = m_ClientIDToPlayerId[id];
if (GetPlayerData(playerId)?.ClientID == id)
{
m_ClientData.Remove(playerId);
}
m_ClientIDToPlayerId.Remove(id);
}
}
}
}
This class allows Boss Room to handle player session data, represented by a struct T
implementing the ISessionPlayerData
interface, by providing mechanisms to initialize, get and edit that data, and to associate it to a specific player. It also handles the clearing of data that's no longer used and the reinitialization of data between sessions.
In this case, since game sessions are quite short, the session data is only cleared for disconnected players when a session ends, or if a player leaves before a session starts. This makes sure that if a player disconnects during a session and then reconnects during the next session, the game treats it as a new connection. The definition of when a session ends and when a session starts might vary from game to game, but in Boss Room, a session starts after character selection and ends when the players enter the post-game scene. In other cases, one might want to add a timeout to session data and clear it after a specified time instead.
This code is in Boss Room's utilities package so it can be easily reused. You can add this package via the Package Manager
window in the Unity Editor by selecting add from Git URL
and adding the following URL: "https://github.com/Unity-Technologies/com.unity.multiplayer.samples.coop.git?path=/Packages/com.unity.multiplayer.samples.coop#main"
Or you can directly add this line to your manifest.json
file: "com.unity.multiplayer.samples.coop": "https://github.com/Unity-Technologies/com.unity.multiplayer.samples.coop.git?path=/Packages/com.unity.multiplayer.samples.coop#main"