This guide will show you how to set up a game with PlayFab so as to automatically deploy a server on Edgegap after players have been matched through the PlayFab matchmaker. The clients will access the information to connect to the server via the PlayFab Player Data, which will be updated by the server once it's ready. Before getting started, we expect that:
You currently use Playfab;
You have already created and deployed an application with Edgegap before.
You can find the final sample project on our GitHub. This sample was tested using Unity v2021.3.10 and Fishnet v3.11.14 as its netcode.
Setting Up PlayFab Cloud Script
Create New Function
Log on to your PlayFab account, then navigate to your game's overview page on the dashboard. In the Automation tab, create a new Cloud Script function that will send a request to the Edgegap API to deploy a new server.
You will need to pass a list of the players' latitude/longitude locations in the request, so the the server can be deployed in the best possible location for everyone.
You also need to pass some custom environment variables as key-value pairs, namely:
the Match ID generated by PlayFab's matchmaker;
a PlayFab Title Secret Key, which can be found in your title settings;
a list of each player's Master Player Account Id.
These variables will be used to update the players' PlayFab Player Data with the information needed to connect to the server. Keep track of the keys you use to set these variables; in this sample, they are "PLAYFAB_MATCH_ID", "PLAYFAB_TITLE_SECRET", and "PLAYFAB_MATCH_PLAYERS_ID_LIST".
The simplest way to create this function is to deploy a new revision in the Revisions (Legacy) tab, adding the following code to it. Make sure to update the Edgegap_AppName, Edgegap_AppVersion, Edgegap_ApiToken and PlayFab_TitleSecret variables at the top with your own values first.
The app name and version will be used later on when containerizing the game server on Edgegap.
The list of player IDs is stored here as a single string, with each ID separated by a comma.
This Cloud Script function creates an interface between the game client and the Edgegap API, which keeps your Edgegap API token and PlayFab Title Secret hidden from the players.
Automation Rule
Still under Automation, navigate to the Rules tab. Create a new rule with the following settings:
Set the Event Type to playfab.matchmaking.match_found;
Add a new Action:
Set the Type to Execute Entity Cloud Script;
Set the Cloud Script Function to StartEdgegapDeployment.
Matchmaker Queue & Players
In the Multiplayer tab, under Matchmaking, make sure to have a queue available, otherwise create a new one. For quick testing, set it up to simply match any two players together.
You will need enough players registered to your Playfab title to satisfy the matchmaker's rules, so in this case only two will do. You can create new ones under the Players tab if needed. For this sample project, make sure to keep track of those players' Custom ID and Title Player Account ID for testing later.
Editing The Game
Server
After opening the game sample in the Unity editor, we added a new empty gameObject to the BattleScene found under Assets/SpaceEdge/Scenes. We then attached a new C# script called PlayerDataUpdateService to it. It contains a callback function to be executed once the server is ready.
We also created a second script called PlayfabRestApiService, which contains the various requests that need to be sent to PlayFab's different APIs.
In PlayerDataUpdateService.cs, we retrieve a few of the environment variables injected in the deployment:
Custom variables:
PLAYFAB_MATCH_ID;
PLAYFAB_TITLE_SECRET;
PLAYFAB_MATCH_PLAYERS_ID_LIST
Default Edgegap variables:
ARBITRIUM_PUBLIC_IP;
ARBITRIUM_PORTS_MAPPING;
In ARBITRIUM_PORTS_MAPPING, we need to retrieve the specific external port value that will allow the client to connect to the server; in this sample, it is the one named "Game Port".
Using the PLAYFAB_TITLE_SECRET as authentification, we send a request to the PlayFab Server API to update the Player Data of each corresponding ID in PLAYFAB_MATCH_PLAYERS_ID_LIST with the following key-value pair:
public class PlayfabRestApiService : MonoBehaviour
{
...
[SerializeField] private string TitleID = "TITLE_ID";
private const string TypeHeaderValue = "application/json";
private readonly HttpClient _playfabHttpClient = new();
private HttpResponseMessage _request;
private StringContent _requestData;
private string _response;
...
public async void UpdatePlayerData(string secretKey, string playerID, string key, string value)
{
if (!_playfabHttpClient.DefaultRequestHeaders.Contains("X-SecretKey"))
{
_playfabHttpClient.DefaultRequestHeaders.Add("X-SecretKey", secretKey);
}
var dataProperty = new JSON();
dataProperty.Add(key, value);
var requestJson = new JSON();
requestJson.Add("PlayFabId", playerID);
requestJson.Add("Data", dataProperty);
_requestData = new StringContent(requestJson.CreateString(), Encoding.UTF8, TypeHeaderValue);
_request = await _playfabHttpClient.PostAsync($"https://{TitleID}.playfabapi.com/Server/UpdateUserData", _requestData);
_response = await _request.Content.ReadAsStringAsync();
if (!_request.IsSuccessStatusCode)
{
Debug.Log($"Could Not Update PlayerData of ID {playerID}, Error {(int)_request.StatusCode} With Message: \n{_response}");
}
}
}
Client
The client-side setup will be managed in the MainMenu scene found under Assets/SpaceEdge/Scenes. We created a new script called MainMenuService to manage the UI of the scene and display various messages to the player, and added new functions to PlayfabRestApiService.cs. We attached the PlayfabRestApiService script to a new gameObject in this scene as well.
The first thing the player needs to do is login with PlayFab, which will allow them to send additional requests to PlayFab's Client and Multiplayer APIs.
We use the LoginWithCustomID endpoint for simplicity, however there are more suitable ways to implement the login depending on the situation (e.g.: with mobile games).
One the player has logged in and both the X-Authorization and X-EntityToken headers have been properly set up for later requests, they can create a new matchmaking ticket using the Start Game UI button. This will send a request to PlayFab's multiplayer API and store the ticket ID for later use.
Afterwards, the game client will periodically check the ticket's status every few seconds until a match has been found or the ticket gets cancelled. Once a match is found, the match ID gets stored as a variable.
The client will then periodically send requests to PlayFab's Client API in order to get the player's Title Player Data, using the match ID as the key for the specific data that needs to be retrieved. Once the data is found, it sets the server's address and external port value in the netcode's transport component, then connects to the server.
Once the client gets connected and started properly, a request gets sent to remove the connection information from that player's PlayFab Player Data thanks to a callback function.
It is important to make sure these key-value pairs get deleted, preferably before the deployment gets terminated. This is to avoid cluttering the Player Data with too many unnecessary entries.
public class PlayfabRestApiService : MonoBehaviour
{
[SerializeField] private string PlayerCustomID = "PLAYER_CUSTOM_ID";
[SerializeField] private string PlayerTitleID = "PLAYER_TITLE_ID";
[SerializeField] private string TitleID = "TITLE_ID";
[SerializeField] private string QueueName = "QUEUE_NAME";
private const string TypeHeaderValue = "application/json";
private readonly HttpClient _playfabHttpClient = new();
private HttpResponseMessage _request;
private StringContent _requestData;
private string _response;
private string _ticketID = "";
private string _matchID = "";
public Action<string, bool> OnStatusUpdate;
public Action<bool> OnUpdateButton;
private static PlayfabRestApiService _instance = null;
private void Awake()
{
_playfabHttpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(TypeHeaderValue));
if (_instance is null)
{
DontDestroyOnLoad(this);
_instance = this;
}
else
{
Destroy(this);
}
}
public async void LoginPlayer()
{
var requestJson = new JSON();
requestJson.Add("CustomID", PlayerCustomID);
requestJson.Add("CreateAccount", false);
requestJson.Add("TitleID", TitleID);
_requestData = new StringContent(requestJson.CreateString(), Encoding.UTF8, TypeHeaderValue);
_request = await _playfabHttpClient.PostAsync($"https://{TitleID}.playfabapi.com/Client/LoginWithCustomID", _requestData);
_response = await _request.Content.ReadAsStringAsync();
if (_request.IsSuccessStatusCode)
{
var responseJson = JSON.ParseString(_response);
string token = responseJson.GetJSON("data").GetJSON("EntityToken").GetString("EntityToken");
_playfabHttpClient.DefaultRequestHeaders.Add("X-EntityToken", token);
string sessionTicket = responseJson.GetJSON("data").GetString("SessionTicket");
_playfabHttpClient.DefaultRequestHeaders.Add("X-Authorization", sessionTicket);
OnUpdateButton?.Invoke(true);
OnStatusUpdate?.Invoke("Login Successful", false);
}
else
{
OnStatusUpdate?.Invoke($"Unable To Login Player, Error {(int)_request.StatusCode} With Message: \n{_response}", true);
}
}
public async void CreateTicket()
{
var requestJson = new JSON();
var entityProperty = new JSON();
entityProperty.Add("Id", PlayerTitleID);
entityProperty.Add("Type", "title_player_account");
var creatorProperty = new JSON();
creatorProperty.Add("Entity", entityProperty);
requestJson.Add("Creator", creatorProperty);
requestJson.Add("GiveUpAfterSeconds", 90);
requestJson.Add("QueueName", QueueName);
_requestData = new StringContent(requestJson.CreateString(), Encoding.UTF8, TypeHeaderValue);
_request = await _playfabHttpClient.PostAsync($"https://{TitleID}.playfabapi.com/Match/CreateMatchmakingTicket", _requestData);
_response = await _request.Content.ReadAsStringAsync();
if (_request.IsSuccessStatusCode)
{
var responseJson = JSON.ParseString(_response);
_ticketID = responseJson.GetJSON("data").GetString("TicketId");
OnStatusUpdate?.Invoke($"Ticket Created, ID #{_ticketID}", false);
GetMatchIdAttempt();
}
else
{
OnUpdateButton?.Invoke(true);
OnStatusUpdate?.Invoke($"Unable To Create Ticket, Error {(int)_request.StatusCode} With Message: \n{_response}", true);
}
}
private async void GetMatchIdAttempt()
{
bool isMatchFound = false;
var requestJson = new JSON();
requestJson.Add("EscapeObject", true);
requestJson.Add("QueueName", QueueName);
requestJson.Add("TicketId", _ticketID);
_requestData = new StringContent(requestJson.CreateString(), Encoding.UTF8, TypeHeaderValue);
while (!isMatchFound)
{
_request = await _playfabHttpClient.PostAsync($"https://{TitleID}.playfabapi.com/Match/GetMatchmakingTicket", _requestData);
_response = await _request.Content.ReadAsStringAsync();
if (_request.IsSuccessStatusCode)
{
var responseJson = JSON.ParseString(_response);
string ticketStatus = responseJson.GetJSON("data").GetString("Status");
isMatchFound = ticketStatus == "Matched";
if (isMatchFound)
{
_matchID = responseJson.GetJSON("data").GetString("MatchId");
OnStatusUpdate?.Invoke($"Ticket Has Been Matched, ID #{_matchID}", false);
StartConnectionAttempt();
}
else if (ticketStatus == "Canceled")
{
isMatchFound = true;
string reason = responseJson.GetJSON("data").GetString("CancellationReason");
OnUpdateButton?.Invoke(true);
OnStatusUpdate?.Invoke($"Ticket Has Been Cancelled Due To {reason}", false);
}
else
{
OnStatusUpdate?.Invoke("Match Not Found, Retrying In 10 Seconds...", false);
await Task.Delay(10000);
}
}
else
{
isMatchFound = true;
OnUpdateButton?.Invoke(true);
OnStatusUpdate?.Invoke($"Could Not Get Ticket Info, Error {(int)_request.StatusCode} With Message: \n{_response}", true);
}
}
}
private async void StartConnectionAttempt()
{
bool gotConnectionInfo = false;
var requestJson = new JSON();
requestJson.Add("Keys", new List<string> { _matchID });
_requestData = new StringContent(requestJson.CreateString(), Encoding.UTF8, TypeHeaderValue);
while (!gotConnectionInfo)
{
_request = await _playfabHttpClient.PostAsync($"https://{TitleID}.playfabapi.com/Client/GetUserData", _requestData);
_response = await _request.Content.ReadAsStringAsync();
if (_request.IsSuccessStatusCode)
{
var responseJson = JSON.ParseString(_response);
var dataObject = responseJson.GetJSON("data").GetJSON("Data");
if (dataObject.Count > 0)
{
gotConnectionInfo = true;
string connectionInfo = dataObject.GetJSON(_matchID).GetString("Value");
string serverAddress = connectionInfo.Split(':')[0];
if (ushort.TryParse(connectionInfo.Split(':')[1], out ushort serverPort))
{
InstanceFinder.TransportManager.Transport.SetPort(serverPort);
InstanceFinder.TransportManager.Transport.SetClientAddress(serverAddress);
Debug.Log($"Connecting To Server Using Port {serverPort} And IP {serverAddress}");
InstanceFinder.ClientManager.StartConnection();
}
else
{
OnUpdateButton?.Invoke(true);
OnStatusUpdate?.Invoke($"Error While Parsing Server Port Value", true);
}
}
else
{
OnStatusUpdate?.Invoke("No Connection Data Found, Retrying In 10 Seconds...", false);
await Task.Delay(10000);
}
}
else
{
gotConnectionInfo = true;
OnUpdateButton?.Invoke(true);
OnStatusUpdate?.Invoke($"Could Not Get Player Data, Error {(int)_request.StatusCode} With Message: \n{_response}", true);
}
}
}
public async void RemovePlayerData()
{
var requestJson = new JSON();
requestJson.Add("KeysToRemove", new string[] { _matchID });
_requestData = new StringContent(requestJson.CreateString(), Encoding.UTF8, TypeHeaderValue);
_request = await _playfabHttpClient.PostAsync($"https://{TitleID}.playfabapi.com/Client/UpdateUserData", _requestData);
_response = await _request.Content.ReadAsStringAsync();
if (!_request.IsSuccessStatusCode)
{
Debug.Log($"Could Not Update PlayerData, Error {(int)_request.StatusCode} With Message: \n{_response}");
}
}
...
}
PlayerDataUpdateService
public class PlayerDataUpdateService : NetworkBehaviour
{
private PlayfabRestApiService _playfabService;
...
public override void OnStartClient()
{
_playfabService = FindObjectOfType<PlayfabRestApiService>();
_playfabService.RemovePlayerData();
}
}
Building Game Server & Containerizing
Before containerizing the server build, make sure to enable the Start On Headless option in the NetworkManager gameObject.
Open the Edgegap plugin with the Tools/Edgegap Hosting toolbar menu. Verify your Edgegap API Token and either create or load an application for the game. Make sure the port value matches that of the Tugboat component of the NetworkManager gameObject. Select the UDP protocol, then enter a New Version Tag. Make sure that both the app name and version match the values that were used when setting up the Cloud Script.
Once this is properly set up, click on Build and Push, which will automatically containerize your game server and create a new application version on the Edgegap dashboard after a short waiting period.
When creating the app version, the plugin will name the port `"Game Port"` by default.
If you choose to manually containerize and create your app version instead of using the plugin, make sure to use that name when selecting which port to use.
Testing
Make sure to first disable the Start On Headless option in the NetworkManager gameObject. Set the PlayfabRestApiService script with the Custom ID and Title ID of your first player, along with your game's Title ID and Queue Name. In the Build Settings, create a new client build with these settings.
Once the build is complete, change the Custom ID and Title ID values to that of your second player. You can create a separate client build if you want, although the Unity editor in play mode should work just as well.
If both clients login properly on launch, click on both instance's Start Game button. After a short while, they will be matched through PlayFab's matchmaker, then connect to the same deployment on Edgegap once the server has finished setting up.