Unity - Peer to Peer Matchmaking
Peer to peer matchmaking is the simplest configuration in which you can run the Catena Matchmaker. It is a good first step to get up and running, even if your game utilizes dedicated game servers. You can build on top of this implementation to add dedicated game server support.
Prerequisites
In order to use the Catena Unity SDK, you must be running Catena, either locally or deployed elsewhere.
- You must have completed the Unity Authentication Guide.
Configuring The Catena Backend
Before you integrate the matchmaker into your Unity game, you first need to configure the Catena Backend properly. This configuration will differ depending on the needs of your game. Refer to the Catena Matchmaker documentation for more information.
For testing a first time integration, we recommend starting with a simple matchmaking queue definition that only requires a single player to execute the full matchmaking loop. We'll call that queue solo
.
We'll also set up a 1v1 queue to facilitate two players matchmaking together. We'll call this queue 1v1
.
The matchmaking implementation we will use is the Catena Matchmaker.
{
"Catena": {
...
"Matchmaker": {
"MatchmakingQueues": {
"solo": {
"QueueName": "Solo",
"Teams": 1,
"PlayersPerTeam": 1,
"TicketExpirationSeconds": 180,
"CustomHooks": "SimpleP2PMatchmakingHooks"
},
"1v1": {
"QueueName": "1v1 (Face Off)",
"Teams": 2,
"PlayersPerTeam": 1,
"TicketExpirationSeconds": 180,
"CustomHooks": "SimpleP2PMatchmakingHooks"
}
},
"StatusExpirationMinutes": 15
}
...
},
"PreferredImplementations": {
...
"ICatenaMatchmaker": "!CatenaMatchmaker"
...
}
}
The SimpleP2PMatchmakingHooks
custom hook is provided out of the box to bypass dedicated game server provisioning when matches are successfully made.
Matchmaking A Player
Reminder: if you have not yet completed the Unity Authentication Guide, please do so now. Once a player has authenticated against Catena and registered a session, they can then begin matchmaking.
To initiate matchmaking, you first need to register callbacks:
var catenaEntrypoint = FindObjectOfType<CatenaEntrypoint>();
var catenaPlayer = FindObjectOfType<CatenaPlayer>();
// Matchmaking Started Callback
catenaEntrypoint.OnStartMatchmakingCompleted += (_, matchmakingEventArgs) =>
{
string logString =
$"Matchmaking began with (Ticket ID: {matchmakingEventArgs.MatchmakingTicketId}), (Status Success: {matchmakingEventArgs.Status.Success})";
if (!matchmakingEventArgs.Status.Success)
{
logString += ", (Status Message: {matchmakingEventArgs.Status.Message})";
}
Debug.Log(logString);
};
// Matchmaking Finished Callback
catenaPlayer.OnFindingServer += (_, eventData) =>
{
if (string.IsNullOrEmpty(eventData))
{
Debug.Log("Failed to find a match");
return;
}
// eventData: {"Ip":"127.0.0.1","Port":1234,"ServerId":"<account-id-here>"}
Debug.Log($"Found a match - data: {eventData}");
// You may now connect to the Ip and Port provided in the eventData, beginning the match
};
// Cancel Matchmaking Callback
catenaEntrypoint.OnCancelMatchmakingCompleted += (_, status) =>
{
if (!status.Success)
{
Debug.LogError($"Failed to cancel matchmaking: {status.Message}");
return;
}
Debug.Log("Cancel Matchmaking Complete");
};
Once your callbacks are registered, you can initiate matchmaking by:
var catenaPlayer = FindObjectOfType<CatenaPlayer>();
// You will need to obtain your IP and port using whichever netcode solution you are using (i.e. Netcode For GameObjects, Mirror, etc.)
var ip = "127.0.0.1";
var port = "1234";
var address = $"{ip}:{port}";
// The Catena Matchmaker will match the player into the queue provided here
var queue_name = "solo";
var matchMetadata = new Dictionary<string, EntityMetadata> { { "queue_name", new EntityMetadata { StringPayload = queue_name } } };
// The SimpleP2PMatchmakingHooks will make the first player in the match the host, passing this address to all members in the match when the match is successfully made
var playerMetadata = new Dictionary<string, EntityMetadata> { { "address", new EntityMetadata{ StringPayload = address } } };
catenaPlayer.EnterMatchmaking(playerMetadata, matchMetadata);
You can cancel matchmaking by:
var catenaEntrypoint = FindObjectOfType<CatenaEntrypoint>();
var ticketId = "<your-ticket-id>";
catenaEntrypoint.CancelMatchmaking(ticketId);
A Practical Example
We provide two examples depending on your needs.
If you are interested in seeing Catena Matchmaking code in a Unity project that uses Netcode for GameObjects, refer to our Catena Galactic Kittens Demo. This demo extends Unity's Galactic Kittens Demo to utilize Catena Matchmaking. The commit history for the "catena" branch will show you the steps to add Catena Matchmaking to your Unity game.
Alternatively, if you either want a more bare bones reference or want a sample that utilizes Mirror Networking, refer to the guide below.
Estimated Time
Configuring a practical example should take you <30 minutes.
Prerequisites
In order to use the Catena Unity SDK, you must be running Catena, either locally or deployed elsewhere.
- Complete the Mirror Networking Guide
This guide shows you how to set up an networked game MVP in Unity in less than 10 minutes.
Mirror is not a requirement to use Catena Matchmaking. It is only used in these docs to show you a functional example of how to proceed after matchmaking.
Configure Non-Matchmaking Portions
- Add authentication to the Mirror Sample by using the Unity Authentication Guide
- Remove the
Network Manager HUD
from yourNetworkManager
object
Add Peer to Peer Matchmaking
- Configure your running Catena instance using the Configuring The Catena Backend documentation above
- Replace the contents of your
SceneManager.cs
file with the following code:
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Catena.CatenaAuthentication;
using Catena.Groups;
using CatenaUnitySDK;
using Mirror;
using Newtonsoft.Json;
public class SceneManager : MonoBehaviour
{
// Authentication
private bool _playerLoggedIn = false;
private string _username = "test01"; // default "UNSAFE" username
// Matchmaking
private bool _isMatchmaking = false;
private int _selectedMatchmakingQueueIndex = 0;
private readonly string[] _matchmakingQueues = { "solo", "1v1" };
private string _ticketID;
// ConnectionInfo is used to deserialize the returned match
private class ConnectionInfo
{
public string Ip;
public ushort Port;
public string ServerId; // In this example, the returned ServerId is the Player ID for the hosted server
}
// Server Travel
private bool _transitionHost = false;
private bool _transitionClient = false;
private bool _inTransition = false;
private ConnectionInfo _clientConnectionInfo;
void Start()
{
RegisterCallbacks();
}
void Update()
{
if (_transitionHost)
{
Debug.Log("Starting Host");
_transitionHost = false;
_inTransition = true;
NetworkManager.singleton.StartHost();
_inTransition = false;
}
if (_transitionClient)
{
Debug.Log("Starting Client");
_transitionClient = false;
if (Transport.active is PortTransport portTransport)
{
NetworkManager.singleton.networkAddress = _clientConnectionInfo.Ip;
portTransport.Port = _clientConnectionInfo.Port;
_inTransition = true;
// This is a crude way to ensure we only join after the host for the game session has started their listen server
StartCoroutine(StartClientAfterWaiting());
}
else
{
Debug.LogError("For the purposes of this demo, please use the Telepathy Transport on your NetworkManager");
}
}
}
IEnumerator StartClientAfterWaiting()
{
yield return new WaitForSeconds(1f);
NetworkManager.singleton.StartClient();
_inTransition = false;
}
void OnGUI()
{
GUILayout.BeginArea(new Rect(10, 10, Screen.width - 20, Screen.height - 20));
if (_transitionClient || _transitionHost || _inTransition) // We are connecting to another player, hide all controls during the transition
{
GUILayout.Label("Connecting To Match...");
}
else if (_playerLoggedIn)
{
if (!_isMatchmaking && !NetworkServer.active && !NetworkClient.isConnected) // Hide logout if in a match or matchmaking
{
GUILayout.Label("Authentication");
if (GUILayout.Button("Log Out"))
{
LogoutPlayer();
}
}
GUILayout.Label("Matchmaking");
if (_isMatchmaking)
{
if (GUILayout.Button("Cancel Matchmaking"))
{
var catenaEntrypoint = FindObjectOfType<CatenaEntrypoint>();
catenaEntrypoint.CancelMatchmaking(_ticketID);
}
}
else if (NetworkServer.active && NetworkClient.isConnected) // We are a host and a client
{
if (GUILayout.Button("Leave Match"))
{
NetworkManager.singleton.StopHost();
}
}
else if (NetworkClient.isConnected) // We are a client
{
if (GUILayout.Button("Leave Match"))
{
NetworkManager.singleton.StopClient();
}
}
else // We are not matchmaking and we are not connected to anything
{
_selectedMatchmakingQueueIndex = GUILayout.SelectionGrid(_selectedMatchmakingQueueIndex, _matchmakingQueues, 2);
if (GUILayout.Button("Find Match"))
{
FindMatch();
}
}
}
else
{
_username = GUILayout.TextField(_username);
if (GUILayout.Button("Log In"))
{
LoginPlayer(_username);
}
}
GUILayout.EndArea();
}
void RegisterCallbacks()
{
var catenaEntrypoint = FindObjectOfType<CatenaEntrypoint>();
var catenaPlayer = FindObjectOfType<CatenaPlayer>();
// Login Callback
catenaPlayer.OnAccountLoginComplete += (object sender, Catena.CatenaAccounts.Account account) =>
{
Debug.Log($"Player Logged In With ID: {account.Id}");
_playerLoggedIn = true;
};
// Logout Callback
catenaPlayer.OnSessionInvalid += (_, _) =>
{
Debug.Log("Player Logged Out");
_playerLoggedIn = false;
};
// Matchmaking Started Callback
catenaEntrypoint.OnStartMatchmakingCompleted += (_, matchmakingEventArgs) =>
{
_ticketID = matchmakingEventArgs.MatchmakingTicketId;
string logString =
$"Matchmaking began with (Ticket ID: {matchmakingEventArgs.MatchmakingTicketId}), (Status Sucess: {matchmakingEventArgs.Status.Success})";
if (!matchmakingEventArgs.Status.Success)
{
logString += ", (Status Message: {matchmakingEventArgs.Status.Message})";
}
Debug.Log(logString);
};
// Matchmaking Finished Callback
catenaPlayer.OnFindingServer += (_, eventData) =>
{
// We found a server or failed to find a match, we no longer need the ticket ID
_ticketID = null;
_isMatchmaking = false;
if (string.IsNullOrEmpty(eventData))
{
Debug.LogError("Failed to find a match");
return;
}
// eventData: {"Ip":"127.0.0.1","Port":1234,"ServerId":"<account-id-here>"}
Debug.Log($"Found a match - data: {eventData}");
ConnectionInfo connectionInfo;
try
{
connectionInfo = JsonConvert.DeserializeObject<ConnectionInfo>(eventData);
if (connectionInfo.ServerId == catenaPlayer.Account.Id)
{
Debug.Log("This client is the host. Starting a listen server...");
_transitionHost = true;
}
else
{
Debug.Log("This client is not the host, connecting to the host client...");
_transitionClient = true;
_clientConnectionInfo = connectionInfo;
}
}
catch (Exception e)
{
Debug.LogError($"Failed to deserialize JSON. (Error: {e}), (JSON: {eventData})");
}
};
// Cancel Matchmaking Callback
catenaEntrypoint.OnCancelMatchmakingCompleted += (_, status) =>
{
if (!status.Success)
{
Debug.LogError($"Failed to cancel matchmaking: {status.Message}");
return;
}
Debug.Log("Cancel Matchmaking Complete");
_isMatchmaking = false;
_ticketID = null;
};
}
void LoginPlayer(string username)
{
var catenaPlayer = FindObjectOfType<CatenaPlayer>();
Debug.Log("Logging Player In");
catenaPlayer.CompleteLogin(Provider.Unsafe, username);
}
void LogoutPlayer()
{
var catenaPlayer = FindObjectOfType<CatenaPlayer>();
Debug.Log("Logging Player Out");
catenaPlayer.Logout();
}
void FindMatch()
{
if (Transport.active is PortTransport portTransport)
{
if (ushort.TryParse(GUILayout.TextField(portTransport.Port.ToString()), out ushort port))
{
var catenaPlayer = FindObjectOfType<CatenaPlayer>();
var ip = NetworkManager.singleton.networkAddress;
var address = $"{ip}:{port}";
Debug.Log("Finding Match");
var matchMetadata = new Dictionary<string, EntityMetadata> { { "queue_name", new EntityMetadata { StringPayload = _matchmakingQueues[_selectedMatchmakingQueueIndex] } } };
var playerMetadata = new Dictionary<string, EntityMetadata> { { "address", new EntityMetadata{ StringPayload = address } } };
catenaPlayer.EnterMatchmaking(playerMetadata, matchMetadata);
_isMatchmaking = true;
}
else
{
Debug.LogError("Your port is invalid, please use only numbers");
}
}
else
{
Debug.LogError("For the purposes of this demo, please use the Telepathy Transport on your NetworkManager");
}
}
}
- Use ParrelSync to open a second Unity Editor. For more information on ParrelSync, refer to the Mirror Networking Guide
- Hit "Play" in both Unity editors
- Log in with
test01
in one editor andtest02
in another - Matchmake into the
solo
or1v1
queue
How This Works
We use Unity's OnGUI to create a simple user interface that interacts with Catena. Players can log in, log out, matchmake solo, matchmake 1v1, and cancel matchmaking.
To initialize matchmaking, we define a queue_name
in the match metadata and an address
in the player metadata. This address
is used by the SimpleP2PMatchmakingHooks
hook defined in your Catena configuration to notify all players in a match of the address for whoever is selected to be the host.
We use the ServerId
sent back from the matchmaker to determine which player was selected to be the host. If the ServerId
is not the current player's ID, we know we're not the host and we should connect to the IP/Port as a client.