Skip to main content
Backfill routes a newly-queued lobby into an already-running match that has open slots instead of always spinning up a fresh server. Players skip the launch wait and drop straight into a game in progress — ideal for battle royale late-join, persistent arenas, drop-in co-op, and any mode where a half-full match is better filled than a new one is started.
A match is a group of lobbies sharing one server. Backfill adds more lobbies to that same matchId and the same running server, up to the match’s capacity (teams × playersPerTeam).

The engine-authoritative hybrid model

PlayFlow runs backfill as an engine-authoritative hybrid:
  • The engine owns backfill by default. When a lobby queues for a backfill-enabled mode, the matcher first looks for a compatible open match. If it finds one, the lobby joins that running server; if not, it forms a fresh match as usual. The engine tracks capacity, per-team slots, the skill band, and the backfill window — you don’t have to.
  • Your game server admits players from an authoritative roster. The engine maintains the single source of truth for who belongs in the match. Your server polls GET /v3/servers/{instance_id}/roster and admits connecting players against it. This is what fixes reconnect / “denied by proxy” edge cases — the roster, not the server’s local memory, decides admission.
  • Advanced servers can optionally take control. A server that knows better when it can accept joiners (e.g., only between rounds) can open, close, or re-scope backfill itself via PATCH /v3/servers/{instance_id}/backfill and POST /v3/servers/{instance_id}/backfill/lock.
You get real backfill for free by flipping one config flag. You reach for the server-authoritative controls only when you need them.

Enabling backfill

Backfill is configured per matchmaking mode in your lobby config. Add a backfill object to the mode:
{
  "matchmaking": {
    "modes": {
      "5v5": {
        "teams": 2,
        "playersPerTeam": 5,
        "minPlayersPerTeam": 3,
        "timeout": 120,
        "maxPartySize": 5,
        "backfill": {
          "enabled": true,
          "windowSeconds": 300,
          "closeWhenFull": true,
          "skill": { "field": "mmr", "maxDifference": 150 }
        }
      }
    }
  }
}

backfill fields

FieldTypeDefaultDescription
enabledbooleanMaster switch. When true, newly-queued lobbies can join an open match before a fresh server is launched.
windowSecondsinteger ≥ 1300How long after a match forms it stays open to joiners. After this window the match closes to backfill.
closeWhenFullbooleantrueClose backfill automatically once the match reaches capacity.
skillobjectnullOptional skill gate. A joining lobby’s average value on field must be within maxDifference of the running match’s skill band.
Backfill only runs for modes where enabled is true. Modes without a backfill object behave exactly as before — no backfill path executes for them.

Deprecated: allowBackfill

The old boolean flag still works and is treated as a shorthand:
{ "allowBackfill": true }
is equivalent to:
{ "backfill": { "enabled": true, "windowSeconds": 300, "closeWhenFull": true } }
The structured backfill object takes precedence if both are present. Prefer backfill for new configs — the dashboard drops allowBackfill on the next edit. Existing projects on allowBackfill: true keep working unchanged and now get real backfill (not just candidate discovery).

What happens when a lobby queues

For a backfill-enabled mode, the matcher tries to backfill before forming a fresh match:
1

Search for an open match

The engine looks for a running match in the same project, lobby config, and mode with status: in_game and backfill_open: true, whose backfill window hasn’t expired.
2

Check compatibility

The candidate match must be region-compatible, have remaining capacity ≥ the joining lobby’s eligible players, and (if a skill gate is set) the lobby’s average skill must fall inside the match’s band.
3

Claim the slots

The engine atomically claims the open slots (assigning players to the teams with the largest deficit) and transitions the lobby to in_game with the running match’s matchId and server connection info.
4

Players connect to the running server

The joined lobby receives the same server.network_ports[] as everyone already in the match — over the existing SSE stream — and connects immediately. No launch wait.
5

No open match? Fresh formation.

If there’s no compatible open match, or another queue racer claims the last slots first, the lobby falls through to normal fresh match formation — a new server launches as usual.
When a match forms with backfill enabled, it opens to joiners for windowSeconds (or until it fills, when closeWhenFull is true). Players who leave during a match free their slots, and the engine reopens backfill if the match is still under capacity and inside its window.

Server integration: admitting players

Your game server is the authority on gameplay, but the engine is the authority on the roster. Your server should admit connecting players against the roster it polls from the engine — not against whatever it happened to see at launch. This is the single change that makes backfill (and reliable reconnects) work.

Poll the roster

GET /api/v3/servers/{instance_id}/roster
api-key: pf_your_server_key
Authenticate with your project server API key (pf_...) and use the server’s own instance ID. At launch the platform injects these environment variables into your server container — read them rather than hardcoding:
Env varValue
PLAYFLOW_INSTANCE_IDThe server’s own instance_id (use it to build the roster URL).
PLAYFLOW_API_KEYThe project server API key (pf_...).
PLAYFLOW_API_URLThe API base (e.g. https://api.computeflow.cloud/api).
PLAYFLOW_MATCH_IDThe match this server is hosting.
Poll on an interval — every few seconds is plenty. Response (200):
{
  "match_id": "match-abc",
  "instance_id": "srv-xyz",
  "status": "in_game",
  "capacity": 10,
  "current_players": 7,
  "backfill_open": true,
  "backfill_deadline": "2026-07-11T20:35:00Z",
  "roster_version": 4,
  "teams": {
    "team_1": {
      "capacity": 5,
      "players": [
        { "playerId": "p1", "lobbyId": "lob-1" },
        { "playerId": "p2", "lobbyId": "lob-1" }
      ]
    },
    "team_2": {
      "capacity": 5,
      "players": [ { "playerId": "p9", "lobbyId": "lob-4" } ]
    }
  },
  "roster": [
    { "playerId": "p1", "team": "team_1", "lobbyId": "lob-1", "joinedAt": "2026-07-11T20:30:00Z" },
    { "playerId": "p2", "team": "team_1", "lobbyId": "lob-1", "joinedAt": "2026-07-11T20:30:00Z" },
    { "playerId": "p9", "team": "team_2", "lobbyId": "lob-4", "joinedAt": "2026-07-11T20:31:12Z" }
  ]
}
status is one of in_game, locked, or completed. Keep admitting while it’s in_game; once it’s locked or completed, backfill is closed — stop admitting new joiners and treat the match as sealed.

roster_version — cheap change detection

roster_version is a monotonically increasing counter bumped on every roster change (a backfill joiner admitted, a player released, a backfill state toggle). Store the last version you saw and only re-diff your admission list when it changes:
  • Version unchanged → nothing to do.
  • Version increased → a backfill joiner arrived (or a player left). Reconcile your local player list with roster.

Admission rule

When a player connects (or reconnects), check whether their playerId is in roster:
  • In the roster → admit them, and place them on their assigned team.
  • Not in the roster → reject the connection.
Because the roster is authoritative and refreshed on every change, a player who was admitted, dropped, and reconnects is still on the roster — so your server admits them instead of denying them at the proxy. Backfill and reconnect resilience are the same mechanism.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;

// Runs on the DEDICATED SERVER build. Polls the authoritative roster and
// keeps a local admit-set the connection handler checks against.
public class BackfillRoster : MonoBehaviour
{
    // All injected into the server container at launch.
    string apiBase    = System.Environment.GetEnvironmentVariable("PLAYFLOW_API_URL")
                        ?? "https://api.computeflow.cloud/api";
    string instanceId = System.Environment.GetEnvironmentVariable("PLAYFLOW_INSTANCE_ID");
    string serverKey  = System.Environment.GetEnvironmentVariable("PLAYFLOW_API_KEY"); // pf_...

    long lastVersion = -1;
    readonly Dictionary<string, string> playerTeam = new(); // playerId -> team

    void Start() => StartCoroutine(PollLoop());

    IEnumerator PollLoop()
    {
        var wait = new WaitForSeconds(3f);
        while (true)
        {
            using var req = UnityWebRequest.Get($"{apiBase}/v3/servers/{instanceId}/roster");
            req.SetRequestHeader("api-key", serverKey);
            yield return req.SendWebRequest();

            if (req.result == UnityWebRequest.Result.Success)
            {
                var roster = JsonUtility.FromJson<RosterResponse>(req.downloadHandler.text);
                if (roster.roster_version != lastVersion)
                {
                    lastVersion = roster.roster_version;
                    playerTeam.Clear();
                    foreach (var e in roster.roster) playerTeam[e.playerId] = e.team;
                    Debug.Log($"Roster v{lastVersion}: {playerTeam.Count}/{roster.capacity} players");
                }
            }
            yield return wait;
        }
    }

    // Call this from your transport's connection-approval callback.
    public bool IsAdmitted(string playerId, out string team) =>
        playerTeam.TryGetValue(playerId, out team);
}

[System.Serializable] public class RosterEntry { public string playerId; public string team; public string lobbyId; public string joinedAt; }
[System.Serializable] public class RosterResponse { public string match_id; public int capacity; public int current_players; public long roster_version; public RosterEntry[] roster; }
Pull is primary. The engine also best-effort mirrors the roster onto your instance so the endpoint is fast, but your server should treat GET /roster as the source of truth and poll it — don’t rely on a push you might miss.

Optional: server-authoritative control

Most games never need this — the engine opens and closes backfill for you based on the window and capacity. But if your server has better knowledge of when it can accept joiners (e.g., only between rounds, or once a boss phase starts), it can take control.
These endpoints require a server API key (pf_...). Client keys (pfclient_...) are allowed unless the project’s client_key_server_control setting is false, in which case they are rejected with 403. The read-only GET /roster endpoint accepts either key.

Open or close backfill (and re-scope skill)

PATCH /api/v3/servers/{instance_id}/backfill
api-key: pf_your_server_key
Content-Type: application/json

{
  "accepting": true,
  "skillBand": { "field": "mmr", "min": 1400, "max": 1600 }
}
FieldTypeDescription
acceptingbooleantrue opens backfill; false closes it.
skillBandobject | nullOptional. Replace the match’s skill band for joiners: { field, min, max }. Omit or send null to leave the current band unchanged.
Response (200):
{
  "match_id": "match-abc",
  "instance_id": "srv-xyz",
  "status": "in_game",
  "backfill_open": true,
  "backfill_deadline": "2026-07-11T20:35:00Z",
  "skill_band": { "field": "mmr", "min": 1400, "max": 1600 },
  "roster_version": 5
}

Close backfill (shorthand)

Close the match to further joiners — a convenience shorthand for PATCH ... { "accepting": false }:
POST /api/v3/servers/{instance_id}/backfill/lock
api-key: pf_your_server_key
Returns the same ServerBackfillState shape as PATCH. Both bump roster_version, so a polling server sees the change on its next GET /roster.

Behavior reference

SituationOutcome
Lobby queues, compatible open match existsLobby joins the running server; no new server launches
No open match / lost the claim raceFalls through to fresh match formation
Match fills and closeWhenFull is trueBackfill closes automatically
Backfill window (windowSeconds) elapsesMatch closes to joiners
Player leaves mid-matchSlot freed; backfill reopens if under capacity and inside the window
Mode has no backfill / enabled: falseBackfill path never runs — behaves exactly as before

Matchmaking

Modes, teams, skill rules, and how matches form.

Real-time Events

Stream the in_game transition and server connection info via SSE.