Skip to main content
This guide shows you how to automatically deploy a PlayFlow game server when players are matched through PlayFab’s matchmaker, and how clients discover the server through PlayFab Player Data — which the server itself updates once it is ready. It’s the same pattern popularized by other hosting providers, mapped onto PlayFlow’s API. If you’re already invested in PlayFab for matchmaking and identity, this lets you adopt PlayFlow for server hosting without moving off PlayFab.
PlayFlow also has a native lobby and matchmaking system. If you’re starting fresh, that path is simpler — no PlayFab required. Use this bridge when you specifically want to keep PlayFab’s matchmaker or player identity.
Before getting started, we expect that:
  • You currently use PlayFab (a title, a matchmaking queue, and some registered players).
  • You have a PlayFlow project and a dedicated Linux server build ready to upload. See the Docker builds guide or the Unity quickstart if you don’t yet.

How it works

1

PlayFab matches players

PlayFab’s matchmaker fires a playfab.matchmaking.match_found event.
2

A CloudScript rule calls PlayFlow

A PlayFab Automation rule runs a CloudScript function that calls POST /api/v3/servers/start, passing the PlayFab match ID, title secret, and player list as environment variables.
3

PlayFlow launches the server

PlayFlow allocates the network ports immediately and boots your build. It injects everything the server needs as environment variables (PLAYFLOW_PORTS, your custom vars, and API credentials).
4

The server publishes its address

On startup, the server reads its own public host:port from PLAYFLOW_PORTS and writes it into each matched player’s PlayFab Player Data — keyed by the match ID.
5

Clients connect

Each client polls its PlayFab ticket until matched, then reads the connection info from Player Data and connects. Because the server only writes Player Data once it’s actually running, clients never get a stale address.

Step 1 — Prepare your PlayFlow project

1

Get a server API key

In your PlayFlow dashboard, open your project’s API Keys and copy a server key (pf_...). This key can start servers and must stay secret — treat it exactly like your PlayFab Title Secret. Never ship it in a client build.
2

Upload your server build

Upload your dedicated Linux server build and give it a name (for example space-edge). PlayFlow references builds by this name — the value becomes the version_tag you pass when starting a server, and each upload under the same name auto-increments its version. See Docker builds for the upload flow.
3

Configure a network port

In your project settings, add a port config and give it a name you’ll look up later — this guide uses game. Choose UDP for most game netcode (for example FishNet’s Tugboat transport), or TCP (optionally with TLS) for WebSocket-based clients. PlayFlow allocates the public port at launch; your server reads it back from PLAYFLOW_PORTS.

Step 2 — Create the PlayFab CloudScript function

Log in to PlayFab, open your title, and under Automation → Revisions (Legacy) deploy a new revision containing the function below. It calls PlayFlow’s start endpoint and forwards everything the server will need. Update the four variables at the top with your own values first. The PlayFlow_BuildName must match the build name you uploaded in Step 1.
Keep this function server-side. It holds your PlayFlow server key and PlayFab Title Secret — the CloudScript is the trusted interface that keeps both hidden from players.
var PlayFlow_ApiUrl     = "https://api.computeflow.cloud/api";
var PlayFlow_ApiKey     = "pf_your_server_key";   // PlayFlow server key — keep secret
var PlayFlow_BuildName  = "space-edge";           // the build NAME you uploaded
var PlayFab_TitleSecret = "TITLE_SECRET";

// PlayFlow regions with approximate coordinates, for nearest-region selection.
// Full list: https://docs.playflowcloud.com/fundamentals/regions
var PlayFlow_Regions = [
  { region: "us-east",              lat: 40.7,  lon: -74.0 },
  { region: "us-south",             lat: 32.8,  lon: -96.8 },
  { region: "us-west",              lat: 34.0,  lon: -118.2 },
  { region: "eu-west",              lat: 50.1,  lon: 8.7 },
  { region: "eu-north",             lat: 59.3,  lon: 18.1 },
  { region: "eu-uk",                lat: 51.5,  lon: -0.1 },
  { region: "ap-south",             lat: 19.1,  lon: 72.9 },
  { region: "sea",                  lat: 1.35,  lon: 103.8 },
  { region: "ap-north",             lat: 35.7,  lon: 140.4 },
  { region: "ap-southeast",         lat: -33.9, lon: 151.2 },
  { region: "south-america-brazil", lat: -23.5, lon: -46.6 },
  { region: "south-africa",         lat: -26.2, lon: 28.0 },
];

function nearestRegion(points) {
  // Average the players' locations, then pick the closest PlayFlow region.
  if (!points.length) return "us-east";
  var avgLat = points.reduce(function (s, p) { return s + p.lat; }, 0) / points.length;
  var avgLon = points.reduce(function (s, p) { return s + p.lon; }, 0) / points.length;
  var best = null, bestD = Infinity;
  PlayFlow_Regions.forEach(function (r) {
    var d = Math.pow(r.lat - avgLat, 2) + Math.pow(r.lon - avgLon, 2);
    if (d < bestD) { bestD = d; best = r.region; }
  });
  return best;
}

handlers.StartPlayFlowDeployment = function (args, context) {
  var QueueName = context.playStreamEvent.Payload.QueueName;
  var MatchId   = context.playStreamEvent.Payload.MatchId;

  var members = multiplayer.GetMatch({
    QueueName: QueueName,
    MatchId: MatchId,
    EscapeObject: false,
    ReturnMemberAttributes: false,
  }).Members;

  var locations = [];
  var playerIds = [];

  members.forEach(function (member) {
    var profile = entity.GetProfile({ Entity: member.Entity });
    var masterId = profile.Profile.Lineage.MasterPlayerAccountId;

    var loc = server.GetPlayerProfile({
      PlayFabId: masterId,
      ProfileConstraints: { ShowLocations: true },
    });

    var l = loc.PlayerProfile.Locations && loc.PlayerProfile.Locations[0];
    if (l) locations.push({ lat: l.Latitude, lon: l.Longitude });
    playerIds.push(masterId);
  });

  var region = nearestRegion(locations);

  var response = JSON.parse(
    http.request(
      PlayFlow_ApiUrl + "/v3/servers/start",
      "post",
      JSON.stringify({
        name: "match-" + MatchId.slice(0, 20),
        region: region,
        version_tag: PlayFlow_BuildName,
        environment_variables: {
          PLAYFAB_MATCH_ID: MatchId,
          PLAYFAB_TITLE_SECRET: PlayFab_TitleSecret,
          PLAYFAB_MATCH_PLAYERS_ID_LIST: playerIds.join(","),
        },
      }),
      "application/json",
      { "api-key": PlayFlow_ApiKey }
    )
  );

  log.info("PlayFlow server starting", response);
};
PlayFlow returns the instance_id, a status of launching, and the allocated network_ports synchronously from this call — the ports are reserved before the machine boots. This guide has the server publish the address (Step 4) so that connection info appears in Player Data exactly when the server is ready. If you’d rather publish from CloudScript, you can instead poll GET /api/v3/servers/{instance_id} until status is running and read network_ports from the response.

Step 3 — Add the automation rule

Under Automation → Rules, create a new rule:
  • Event Type: playfab.matchmaking.match_found
  • ActionType: Execute Entity CloudScript
  • CloudScript Function: StartPlayFlowDeployment
Now every completed match automatically launches a dedicated PlayFlow server.

Step 4 — Publish the address from the server

When PlayFlow starts your server, it injects these environment variables into the container (alongside any environment_variables you passed from CloudScript):
VariableDescription
PLAYFLOW_PORTSJSON array of the server’s public ports: [{ "name", "internal_port", "external_port", "protocol", "host", "tls_enabled" }]
PLAYFLOW_INSTANCE_IDThis server’s instance ID
PLAYFLOW_API_KEY / PLAYFLOW_API_URLCredentials + base URL to call the PlayFlow API back if needed
PLAYFLOW_REGIONThe region the server launched in
Your custom varsPLAYFAB_MATCH_ID, PLAYFAB_TITLE_SECRET, PLAYFAB_MATCH_PLAYERS_ID_LIST
On startup, read the public address for your named port out of PLAYFLOW_PORTS, then write host:external_port into each matched player’s Player Data, keyed by the match ID.
PlayerDataUpdateService.cs
public class PlayerDataUpdateService : NetworkBehaviour
{
    [SerializeField] private string ServerPortName = "game"; // must match your PlayFlow port config name

    private PlayfabRestApiService _playfab;

    public override void OnStartServer()
    {
        _playfab = FindObjectOfType<PlayfabRestApiService>();

        string matchId      = Environment.GetEnvironmentVariable("PLAYFAB_MATCH_ID");
        string titleSecret  = Environment.GetEnvironmentVariable("PLAYFAB_TITLE_SECRET");
        string[] playerIds  = Environment.GetEnvironmentVariable("PLAYFAB_MATCH_PLAYERS_ID_LIST")?.Split(',');
        string portsJson    = Environment.GetEnvironmentVariable("PLAYFLOW_PORTS");

        if (string.IsNullOrEmpty(portsJson))
        {
            Debug.LogError("PLAYFLOW_PORTS not set — is this running on PlayFlow?");
            return;
        }

        // PLAYFLOW_PORTS is a JSON array; find the port config by name.
        PlayFlowPort[] ports = JsonConvert.DeserializeObject<PlayFlowPort[]>(portsJson);
        PlayFlowPort game = Array.Find(ports, p => p.name == ServerPortName);
        if (game == null)
        {
            Debug.LogError($"No PlayFlow port named '{ServerPortName}'");
            return;
        }

        string connection = $"{game.host}:{game.external_port}";

        foreach (string playerId in playerIds)
        {
            if (!string.IsNullOrEmpty(playerId))
                _playfab.UpdatePlayerData(titleSecret, playerId, matchId, connection);
        }
    }

    [Serializable]
    private class PlayFlowPort
    {
        public string name;
        public int external_port;
        public string host;
        public string protocol;
        public bool tls_enabled;
    }
}
The PlayFab write itself is unchanged from any PlayFab server integration — authenticate with the Title Secret and call Server/UpdateUserData:
PlayfabRestApiService.cs (server)
public async void UpdatePlayerData(string secretKey, string playerId, string key, string value)
{
    if (!_http.DefaultRequestHeaders.Contains("X-SecretKey"))
        _http.DefaultRequestHeaders.Add("X-SecretKey", secretKey);

    var data = new JObject { [key] = value };
    var body = new JObject { ["PlayFabId"] = playerId, ["Data"] = data };

    var content  = new StringContent(body.ToString(), Encoding.UTF8, "application/json");
    var response = await _http.PostAsync($"https://{TitleID}.playfabapi.com/Server/UpdateUserData", content);

    if (!response.IsSuccessStatusCode)
        Debug.Log($"Could not update Player Data for {playerId}: {(int)response.StatusCode}");
}

Step 5 — Connect from the client

The client side is pure PlayFab and identical to any PlayFab matchmaking integration — PlayFlow isn’t involved until the client reads the address. The flow:
1

Log in

Authenticate the player with PlayFab (for example LoginWithCustomID) and store the entity + session tokens.
2

Create a ticket

Call Match/CreateMatchmakingTicket on your queue and keep the returned ticket ID.
3

Wait for a match

Poll Match/GetMatchmakingTicket every few seconds until Status is Matched, then store the MatchId.
4

Read the address & connect

Poll Client/GetUserData with the MatchId as the key until the server has written it, then set your transport’s address/port and connect. Retry for a few seconds — the server writes Player Data the moment it boots, which is typically a few seconds after the match.
Reading the address and connecting (client)
private async void StartConnectionAttempt()
{
    var body = new JObject { ["Keys"] = new JArray { _matchId } };
    var content = new StringContent(body.ToString(), Encoding.UTF8, "application/json");

    while (true)
    {
        var res  = await _http.PostAsync($"https://{TitleID}.playfabapi.com/Client/GetUserData", content);
        var json = JObject.Parse(await res.Content.ReadAsStringAsync());
        var data = json["data"]?["Data"];

        var entry = data?[_matchId]?["Value"]?.ToString();
        if (!string.IsNullOrEmpty(entry))
        {
            string address = entry.Split(':')[0];
            if (ushort.TryParse(entry.Split(':')[1], out ushort port))
            {
                InstanceFinder.TransportManager.Transport.SetClientAddress(address);
                InstanceFinder.TransportManager.Transport.SetPort(port);
                InstanceFinder.ClientManager.StartConnection();
            }
            return;
        }

        await Task.Delay(3000); // server not ready yet — retry
    }
}
Have the client remove its match entry from Player Data once connected (Client/UpdateUserData with KeysToRemove) so stale connection info doesn’t accumulate.

Step 6 — Build, upload, and test

1

Build a headless Linux server

Produce a dedicated Linux server build with your netcode configured to start on headless and listen on the internal port that matches your PlayFlow port config.
2

Upload it to PlayFlow

Upload the build under the same name you referenced as PlayFlow_BuildName in the CloudScript (space-edge here). See Docker builds.
3

Run two clients

Launch two PlayFab-authenticated clients and start matchmaking on both. When PlayFab matches them, your rule fires, PlayFlow boots the server, the server publishes its address, and both clients connect to the same deployment.

Why this works well on PlayFlow

  • The server is handed its own public address. PLAYFLOW_PORTS contains the resolved host and external_port for every named port — no self-lookup or metadata call required.
  • Readiness is automatic. Because the server writes Player Data in OnStartServer, connection info only appears once the process is actually up. Clients that poll can’t grab a dead address.
  • The server can talk back to PlayFlow. PLAYFLOW_API_KEY and PLAYFLOW_API_URL are injected too, so the server can stop itself when the match ends (DELETE /api/v3/servers/{instance_id}) or update its own metadata — no extra secrets to manage.
  • Set a TTL as a safety net. Pass a ttl (seconds) on POST /servers/start so an abandoned match can’t leave a server running forever. See the API reference.
When you’re ready to simplify further, PlayFlow’s native matchmaking removes the PlayFab round-trip entirely — the matchmaker launches the server and hands the connection details straight to your players over a real-time channel.