> ## Documentation Index
> Fetch the complete documentation index at: https://docs.playflowcloud.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Backfill

> Route newly-queued players into a running match with open slots instead of always launching a new server — engine-managed, with an authoritative roster your game server polls to admit players.

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.

<Info>
  **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`).
</Info>

***

## 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:

```json theme={null}
{
  "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

| Field           | Type        | Default | Description                                                                                                                       |
| :-------------- | :---------- | :------ | :-------------------------------------------------------------------------------------------------------------------------------- |
| `enabled`       | boolean     | —       | Master switch. When `true`, newly-queued lobbies can join an open match before a fresh server is launched.                        |
| `windowSeconds` | integer ≥ 1 | `300`   | How long after a match forms it stays open to joiners. After this window the match closes to backfill.                            |
| `closeWhenFull` | boolean     | `true`  | Close backfill automatically once the match reaches capacity.                                                                     |
| `skill`         | object      | `null`  | Optional skill gate. A joining lobby's average value on `field` must be within `maxDifference` of the running match's skill band. |

<Warning>
  Backfill only runs for modes where `enabled` is `true`. Modes without a `backfill` object behave **exactly as before** — no backfill path executes for them.
</Warning>

### Deprecated: `allowBackfill`

The old boolean flag still works and is treated as a shorthand:

```json theme={null}
{ "allowBackfill": true }
```

is equivalent to:

```json theme={null}
{ "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:

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

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 var                | Value                                                            |
| :--------------------- | :--------------------------------------------------------------- |
| `PLAYFLOW_INSTANCE_ID` | The server's own `instance_id` (use it to build the roster URL). |
| `PLAYFLOW_API_KEY`     | The project server API key (`pf_...`).                           |
| `PLAYFLOW_API_URL`     | The API base (e.g. `https://api.computeflow.cloud/api`).         |
| `PLAYFLOW_MATCH_ID`    | The match this server is hosting.                                |

Poll on an interval — every few seconds is plenty.

**Response (200):**

```json theme={null}
{
  "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" }
  ]
}
```

<Info>
  `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.
</Info>

### `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.

<Tabs>
  <Tab title="Unity (C#)">
    ```csharp theme={null}
    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; }
    ```
  </Tab>

  <Tab title="Godot (GDScript)">
    ```gdscript theme={null}
    # Autoload on the DEDICATED SERVER. Polls the authoritative roster and keeps
    # a player -> team map the connection handler checks before admitting.
    extends Node

    # All injected into the server container at launch.
    var api_base := OS.get_environment("PLAYFLOW_API_URL")
    var instance_id := OS.get_environment("PLAYFLOW_INSTANCE_ID")
    var server_key  := OS.get_environment("PLAYFLOW_API_KEY") # pf_...

    var _last_version := -1
    var _player_team := {} # playerId -> team
    var _http := HTTPRequest.new()

    func _ready() -> void:
        if api_base == "":
            api_base = "https://api.computeflow.cloud/api"
        add_child(_http)
        _http.request_completed.connect(_on_roster)
        var timer := Timer.new()
        timer.wait_time = 3.0
        timer.timeout.connect(_poll)
        add_child(timer)
        timer.start()

    func _poll() -> void:
        _http.request("%s/v3/servers/%s/roster" % [api_base, instance_id],
            PackedStringArray(["api-key: " + server_key]))

    func _on_roster(_r, code, _h, body: PackedByteArray) -> void:
        if code != 200: return
        var data: Dictionary = JSON.parse_string(body.get_string_from_utf8())
        if data.roster_version == _last_version: return
        _last_version = data.roster_version
        _player_team.clear()
        for e in data.roster:
            _player_team[e.playerId] = e.team

    # Call from your peer-connected / auth handler.
    func is_admitted(player_id: String) -> bool:
        return _player_team.has(player_id)
    ```
  </Tab>
</Tabs>

<Tip>
  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.
</Tip>

***

## 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.

<Warning>
  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.
</Warning>

### 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 }
}
```

| Field       | Type           | Description                                                                                                                           |
| :---------- | :------------- | :------------------------------------------------------------------------------------------------------------------------------------ |
| `accepting` | boolean        | `true` opens backfill; `false` closes it.                                                                                             |
| `skillBand` | object \| null | Optional. Replace the match's skill band for joiners: `{ field, min, max }`. Omit or send `null` to leave the current band unchanged. |

**Response (200):**

```json theme={null}
{
  "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

| Situation                                  | Outcome                                                              |
| :----------------------------------------- | :------------------------------------------------------------------- |
| Lobby queues, compatible open match exists | Lobby joins the running server; no new server launches               |
| No open match / lost the claim race        | Falls through to fresh match formation                               |
| Match fills and `closeWhenFull` is true    | Backfill closes automatically                                        |
| Backfill window (`windowSeconds`) elapses  | Match closes to joiners                                              |
| Player leaves mid-match                    | Slot freed; backfill reopens if under capacity and inside the window |
| Mode has no `backfill` / `enabled: false`  | Backfill path never runs — behaves exactly as before                 |

***

<CardGroup cols={2}>
  <Card title="Matchmaking" icon="swords" href="/lobbies/matchmaking">
    Modes, teams, skill rules, and how matches form.
  </Card>

  <Card title="Real-time Events" icon="bolt" href="/lobbies/real-time">
    Stream the `in_game` transition and server connection info via SSE.
  </Card>
</CardGroup>
