Playfab Bridge
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 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'sMaster 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"
.
var Edgegap_AppName = "APP_NAME";
var Edgegap_AppVersion = "APP_VERSION";
var Edgegap_ApiToken = "token XXXX"; //include the "token" keyword
var PlayFab_TitleSecret = "TITLE_SECRET";
handlers.StartEdgegapDeployment = function (args, context) {
var QueueName = context.playStreamEvent.Payload.QueueName;
var MatchId = context.playStreamEvent.Payload.MatchId;
var GeoIpList = [];
var PlayerIds = "";
var MatchMember = multiplayer.GetMatch({
QueueName: QueueName,
MatchId: MatchId,
EscapeObject: false,
ReturnMemberAttributes: false,
}).Members;
MatchMember.forEach((Member) => {
var Profile = entity.GetProfile({
Entity: Member.Entity,
});
var Location = server.GetPlayerProfile({
PlayFabId: Profile.Profile.Lineage.MasterPlayerAccountId,
ProfileConstraints: {
ShowLocations: true,
},
});
GeoIpList.push({
ip: "1.2.3.4",
latitude: Location.PlayerProfile.Locations[0].Latitude,
longitude: Location.PlayerProfile.Locations[0].Longitude,
});
PlayerIds += Profile.Profile.Lineage.MasterPlayerAccountId + ",";
});
PlayerIds = PlayerIds.slice(0, -1);
var response = JSON.parse(
http.request(
"https://api.edgegap.com/v1/deploy",
"post",
JSON.stringify({
app_name: Edgegap_AppName,
version_name: Edgegap_AppVersion,
geo_ip_list: [...GeoIpList],
tags: [MatchId.slice(0, 20)],
env_vars: [
{
key: "PLAYFAB_MATCH_ID",
value: MatchId,
},
{
key: "PLAYFAB_TITLE_SECRET",
value: TitleSecret,
},
{
key: "PLAYFAB_MATCH_PLAYERS_ID_LIST",
value: PlayerIds,
},
],
}),
"application/json",
{ authorization: Edgegap_ApiToken }
)
);
log.info("response", response);
};
Automation Rule
Still under Automation
, navigate to the Rules
tab. Create a new rule with the following settings:
Set the
Event Type
toplayfab.matchmaking.match_found
;Add a new
Action
:Set the
Type
toExecute Entity Cloud Script
;Set the
Cloud Script Function
toStartEdgegapDeployment
.
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:
Key:
PLAYFAB_MATCH_ID
;Value:
"{ARBITRIUM_PUBLIC_IP}:{port}"
;
PlayerDataUpdateService
public class PlayerDataUpdateService : NetworkBehaviour
{
private PlayfabRestApiService _playfabService;
[SerializeField] private string KeyMatchId = "PLAYFAB_MATCH_ID";
[SerializeField] private string KeyTitleSecret = "PLAYFAB_TITLE_SECRET";
[SerializeField] private string KeyPlayerIds = "PLAYFAB_MATCH_PLAYERS_ID_LIST";
[SerializeField] private string ServerPortName = "Game Port";
private string matchID;
private string titleSecretKey;
private string[] playerIdList;
public override void OnStartServer()
{
_playfabService = FindObjectOfType<PlayfabRestApiService>();
matchID = Environment.GetEnvironmentVariable(KeyMatchId);
titleSecretKey = Environment.GetEnvironmentVariable(KeyTitleSecret);
playerIdList = Environment.GetEnvironmentVariable(KeyPlayerIds)?.Split(',');
string address = Environment.GetEnvironmentVariable("ARBITRIUM_PUBLIC_IP");
string portsMapping = Environment.GetEnvironmentVariable("ARBITRIUM_PORTS_MAPPING");
if (!string.IsNullOrEmpty(portsMapping))
{
int port = JSON.ParseString(portsMapping).GetJSON("ports").GetJSON(ServerPortName).GetInt("external");
foreach (string playerId in playerIdList)
{
if (!string.IsNullOrEmpty(playerId))
{
_playfabService.UpdatePlayerData(titleSecretKey, playerId, matchID, $"{address}:{port}");
}
}
}
else
{
Debug.Log("Port Mapping Unavailable");
}
}
...
}
PlayfabRestApiService
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.
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.
MainMenuService
public class MainMenuService : MonoBehaviour
{
[SerializeField] private Button startGameButton;
[SerializeField] private TMP_InputField playerNameInput;
[SerializeField] private TMP_Text serverStatus;
private PlayfabRestApiService _playfabService;
private void Start()
{
if (!InstanceFinder.ServerManager.GetStartOnHeadless())
{
_playfabService = FindObjectOfType<PlayfabRestApiService>();
_playfabService.OnStatusUpdate += ServerStatusUpdate;
_playfabService.OnUpdateButton += UpdateButtonState;
startGameButton.onClick.AddListener(StartGame);
playerNameInput.onValueChanged.AddListener(NameChanged);
if (PlayerPrefs.HasKey("PlayerName"))
playerNameInput.text = PlayerPrefs.GetString("PlayerName");
_playfabService.LoginPlayer();
}
}
private void OnDestroy()
{
if (!InstanceFinder.ServerManager.GetStartOnHeadless())
{
_playfabService.OnStatusUpdate -= ServerStatusUpdate;
_playfabService.OnUpdateButton -= UpdateButtonState;
}
}
private void StartGame()
{
startGameButton.interactable = false;
serverStatus.text = "";
_playfabService.CreateTicket();
}
private void ServerStatusUpdate(string status, bool isError)
{
serverStatus.text += "\n" + status;
serverStatus.color = isError ? Color.red : Color.green;
Debug.Log(status);
}
private void UpdateButtonState(bool state)
{
startGameButton.interactable = state;
serverStatus.text = "";
}
private void NameChanged(string text) => PlayerPrefs.SetString("PlayerName", text);
}
PlayfabRestApiService
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.
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.
Last updated
Was this helpful?