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

# Matchmaking

> Skill-based matchmaking, asymmetric teams, auto-expanding MMR buckets, queue stats, and region selection — all through one endpoint.

PlayFlow's matchmaking system finds compatible opponents for your players and launches a shared game server. It supports symmetric teams, asymmetric roles, skill-based filtering, region preferences, and handles concurrency safely.

**A matchmaking game is just a group of lobbies pointing to the same server.** When two lobbies match, they both transition to `in_game` with the same `matchId` and the same `server` connection info.

## The Full Flow

<Steps>
  <Step title="Players group up in a lobby">
    One or more players join a lobby (as a party).
  </Step>

  <Step title="Host starts matchmaking">
    `POST /v3/lobbies/{config}/me/matchmaking` with a `mode` name.

    Lobby status → `in_queue`. `matchmaking.mode` populates. Queue stats begin streaming via SSE.
  </Step>

  <Step title="System searches for compatible lobbies">
    Other lobbies in the same config + mode are evaluated. Rules applied (MMR, region, party size). Candidates sorted by wait time.
  </Step>

  <Step title="Match formed">
    All matched lobbies atomically claim each other. Status → `matched`.
  </Step>

  <Step title="Game server launches">
    One dedicated server starts. All matched lobbies get the same `matchId` and `server.network_ports[]`.
  </Step>

  <Step title="Players connect">
    Clients read `server.network_ports[0].host:external_port` from their lobby and connect to the game server.
  </Step>
</Steps>

***

## Configuring Matchmaking

Matchmaking modes are defined in your **lobby config** in the PlayFlow dashboard. Each config can have multiple modes:

```json theme={null}
{
  "id": "ranked",
  "name": "ranked",
  "enabled": true,
  "timeout": 300,
  "serverSettings": {
    "serverSize": "small",
    "gameBuild": "default"
  },
  "matchmaking": {
    "modes": {
      "1v1": {
        "teams": 2,
        "playersPerTeam": 1,
        "minPlayersPerTeam": 1,
        "timeout": 60,
        "maxPartySize": 1,
        "allowBackfill": false,
        "rules": [
          { "type": "difference", "field": "mmr", "maxDifference": 100 }
        ]
      },
      "5v5": {
        "teams": 2,
        "playersPerTeam": 5,
        "minPlayersPerTeam": 3,
        "timeout": 120,
        "maxPartySize": 5,
        "allowBackfill": true
      }
    }
  }
}
```

<Info>
  **No config? No problem.** If you don't set up matchmaking in the dashboard, the `/me/matchmaking` endpoint returns a `400` asking you to configure modes. All *lobby* operations still work without config (they use defaults).
</Info>

***

## Starting Matchmaking

```bash theme={null}
POST /v3/lobbies/ranked/me/matchmaking
x-player-id: host_player
Content-Type: application/json

{
  "mode": "5v5"
}
```

**Response** — lobby transitions to `in_queue` with queue stats:

```json theme={null}
{
  "status": "in_queue",
  "matchmaking": {
    "mode": "5v5",
    "startedAt": "2026-04-08T20:30:00Z",
    "queueStats": {
      "playersSearching": 42,
      "lobbiesInQueue": 8,
      "avgWaitSeconds": 12.3
    }
  },
  ...
}
```

<Warning>
  Only the **host** can start matchmaking. Non-hosts get `403 Not host`.
</Warning>

### When a Match Is Found

When the system matches your lobby with others, the SSE stream emits an update:

```
event: lobby_updated
data: {
  "status": "in_game",
  "matchId": "abc-123",
  "server": {
    "instance_id": "srv-xyz",
    "status": "launching",
    "region": "us-east",
    "network_ports": [{"name":"game_udp","host":"203.0.113.42","external_port":30234,"internal_port":7770,"protocol":"udp","tls_enabled":false}]
  },
  ...
}
```

All matched lobbies get the **same** `matchId` and **same** `server`. Clients connect to `server.network_ports[0].host:external_port`. The `server` block is the same shape as `GET /v3/servers/{instance_id}` — server is a primitive the lobby wraps around.

### Cancel Matchmaking

```bash theme={null}
DELETE /v3/lobbies/ranked/me/matchmaking
x-player-id: host_player
```

Lobby returns to `waiting`.

***

## Match Confirmation

CS2-style "Accept Match" flow. For ranked or competitive modes, you often want players to accept a match before the server launches. PlayFlow supports this natively — add `matchConfirmation` to the mode:

```json theme={null}
{
  "mode": "ranked_5v5",
  "matchConfirmation": {
    "enabled": true,
    "timeoutSeconds": 10
  }
}
```

### How it works

1. Matchmaker finds a match → all lobbies transition `in_queue` → **`match_found`**. **Server is NOT launched yet.**
2. Each lobby's response now includes a confirmation block:
   ```json theme={null}
   {
     "status": "match_found",
     "matchmaking": {
       "confirmation": {
         "deadline": "2026-04-18T15:30:00Z",
         "confirmed": false
       }
     }
   }
   ```
3. Players accept or decline:
   * **Accept:** `POST /v3/lobbies/{config}/me/confirm-match`
   * **Decline:** `DELETE /v3/lobbies/{config}/me/confirm-match`
4. When **every** matched lobby accepts → server launches → `in_game`.
5. If **any** lobby declines OR the deadline passes → **all participating lobbies return to `waiting`** (matchmaking state cleared). Players re-queue when ready.

<Info>
  **Who confirms?** Any player in a lobby can accept or decline on behalf of their party. Parties confirm as a unit (one click per party, not per player).
</Info>

<Warning>
  Declining or timing out moves players to `waiting`, not back to `in_queue`. This prevents accidental re-matching. Players must explicitly press matchmake again.
</Warning>

### Full flow over SSE

The client sees the whole flow in real-time:

```
# 1. Match found, awaiting acceptance
event: lobby_updated
data: { "status": "match_found", "matchmaking": {
  "confirmation": { "deadline": "...", "confirmed": false }
}}

# 2. This player accepts
event: lobby_updated
data: { "status": "match_found", "matchmaking": {
  "confirmation": { "deadline": "...", "confirmed": true }
}}

# 3. Last player accepts → server launches
event: lobby_updated
data: { "status": "in_game", "server": { "status": "launching", "network_ports": [] }}
```

***

## Rematch (keep the same lobby)

When a match ends, the host can recycle the lobby for another round instead of making players re-queue:

```bash theme={null}
POST /v3/lobbies/ranked/me/end-match
x-player-id: host_player
```

This:

* Transitions lobby from `in_game` → `waiting`
* Stops the current game server
* Keeps all players, invite code, and settings
* Ready for `/me/start` or `/me/matchmaking` again

If the game server naturally shuts down (TTL or crash), the lobby **automatically self-heals** to `waiting` the same way — no manual call needed.

***

## Team Modes

### Symmetric teams

Traditional team structure — most competitive games. N teams of M players, everyone equal.

```json theme={null}
{
  "teams": 2,
  "playersPerTeam": 5,
  "minPlayersPerTeam": 3
}
```

* `teams` — how many teams to form
* `playersPerTeam` — target size (matchmaker tries to fill to this)
* `minPlayersPerTeam` — minimum required to start (for partial matches)

**Examples:**

| Mode                   | Config                                                 |
| :--------------------- | :----------------------------------------------------- |
| 1v1 ranked             | `teams: 2, playersPerTeam: 1`                          |
| 2v2 duos               | `teams: 2, playersPerTeam: 2`                          |
| 5v5 competitive        | `teams: 2, playersPerTeam: 5`                          |
| 100-player FFA         | `teams: 1, playersPerTeam: 100, minPlayersPerTeam: 20` |
| 50-squad battle royale | `teams: 50, playersPerTeam: 2, minTeams: 10`           |

### Asymmetric teams

Role-based team structure for games like *Dead by Daylight* (1 killer vs 4 survivors) or *Evolve* (1 monster vs 4 hunters):

```json theme={null}
{
  "teams": 2,
  "playersPerTeam": 3,
  "minPlayersPerTeam": 2,
  "teamComposition": {
    "monsters": { "boss": 1 },
    "hunters": { "medic": 1, "assault": 2, "support": 1 }
  },
  "excludedFromQueue": ["spectator"]
}
```

Players specify their role in `state.role`:

```bash theme={null}
PATCH /v3/lobbies/hunt/me
x-player-id: p1

{ "state": { "role": "boss" } }
```

Or a preference list (higher priority first):

```bash theme={null}
{ "state": { "role": ["medic", "support", "fill"] } }
```

The matchmaker:

* Places players with the most specific role first (`boss` before `fill`)
* Keeps party members together on the same team
* Uses `"fill"` as a wildcard (assigns wherever there's an open slot)
* Excludes spectators (or any role in `excludedFromQueue`)

***

## Matchmaking Rules

Rules decide which lobbies are allowed to match with each other. Add them to a mode under `rules`:

```json theme={null}
{
  "mode": "ranked_2v2",
  "rules": [
    { "type": "difference", "field": "mmr", "maxDifference": 100 }
  ]
}
```

**All rules must pass** for two lobbies to match. If any rule rejects, the matcher keeps looking.

Numeric state fields are averaged across party members. So a party with players at MMR 1400 and 1600 shows up as 1500 to the matcher. String fields (e.g. `gameVersion`) are propagated only if all party members agree on the same value.

### The 5 rule types

<CardGroup cols={2}>
  <Card title="difference" icon="scale-balanced">
    Numeric tolerance with auto-expanding buckets. Skill matching.
  </Card>

  <Card title="equals" icon="equals">
    Both sides must share a field value (version, map).
  </Card>

  <Card title="not_equals" icon="not-equal">
    Values must differ (anti-rematch).
  </Card>

  <Card title="region" icon="globe">
    Acceptable-region overlap (≥ N shared).
  </Card>

  <Card title="expression" icon="code">
    CEL escape hatch for custom logic.
  </Card>
</CardGroup>

### Skill matching

The `difference` rule pairs players whose state differs by no more than a threshold, with buckets that widen over time.

```json theme={null}
{ "type": "difference", "field": "mmr", "maxDifference": 100 }
```

Matches when `|lobbyA.mmr - lobbyB.mmr| <= maxDifference`. **Works with any numeric field**: `mmr`, `elo`, `level`, `rank`, `kd_ratio`.

**The magic**: as players wait longer, the bucket grows automatically. No extra config.

Formula: `effectiveMax = maxDifference × (1 + longestWaitTime / modeTimeout)`

Using `maxDifference: 100, timeout: 60s`:

| Wait time | Effective range   |
| :-------- | :---------------- |
| 0s        | ±100 MMR (strict) |
| 30s       | ±150 MMR          |
| 60s       | ±200 MMR          |
| 120s      | ±300 MMR          |

This is how Riot, Valve, and Blizzard all do it — they just never made you configure it.

### Equals rule

The `equals` rule pairs players only when a specific state field matches exactly on both sides.

```json theme={null}
{ "type": "equals", "field": "gameVersion" }
```

Matches only if `lobbyA.gameVersion == lobbyB.gameVersion`. Use for: game version pinning, map selection, platform gating.

### Not-equals rule

The `not_equals` rule pairs players only when a state field has different values on each side.

```json theme={null}
{ "type": "not_equals", "field": "lastOpponent" }
```

Matches only when the field values differ. Use for: anti-rematch (last two opponents shouldn't be the same), team-mixing, forced rotation.

### Region rule

The `region` rule pairs lobbies only when their acceptable-region lists overlap by at least `minOverlap` entries.

```json theme={null}
{ "type": "region", "minOverlap": 1 }
```

Matches only if both lobbies share at least `minOverlap` entries in their `acceptableRegions` list. Players set regions via their state:

```bash theme={null}
PATCH /v3/lobbies/ranked/me
{ "state": { "regions": ["us-east", "us-west"] } }
```

If `acceptableRegions` is empty, the lobby is "open to any region" (wildcard — always passes).

### Expression rule

The `expression` rule is a CEL escape hatch for logic the other four primitives don't cover. Uses [CEL](https://cel.dev/) (Common Expression Language).

```json theme={null}
{
  "type": "expression",
  "cel": "a.state.mmr > 1000 && b.state.mmr > 1000"
}
```

**Scope:** you have `a` and `b` (the two lobby tickets) and `ctx` (mode config). Each ticket exposes `id`, `partySize`, `waitSeconds`, `region`, `acceptableRegions`, `state`, `metadata`.

**More CEL examples:**

```json theme={null}
// High-tier games only (hidden MMR gating)
{ "type": "expression", "cel": "a.state.rank == b.state.rank && a.state.mmr > 2000" }

// Anti-smurf: only match if both accounts are at least a week old
{ "type": "expression", "cel": "a.state.accountAgeDays >= 7 && b.state.accountAgeDays >= 7" }

// Prefer opponents who have waited longer (priority boost)
{ "type": "expression", "cel": "a.waitSeconds + b.waitSeconds > 30" }
```

### Multiple rules — all must pass

Stack rules together:

```json theme={null}
{
  "rules": [
    { "type": "difference", "field": "mmr", "maxDifference": 100 },
    { "type": "equals",     "field": "gameVersion" },
    { "type": "region",     "minOverlap": 1 }
  ]
}
```

A candidate pair must satisfy **every** rule to match.

***

## Dynamic Version Selection

Shipping a new game version usually means a rolling update — stop old matches, cut the fleet over to the new build, and hope nothing breaks for players mid-session. That's infrastructure overhead you pay on every release.

Set `useVersionFromState: "gameVersion"` on a mode. Your client sends `gameVersion` in its player state when queueing, an `equals` rule on the same field keeps matched players on the same version, and the matchmaker launches whichever build has that `name`. No rolling updates, multiple versions live simultaneously in the same project.

### Config

```json theme={null}
{
  "id": "ranked",
  "name": "ranked",
  "enabled": true,
  "serverSettings": {
    "serverSize": "small",
    "gameBuild": "1.2.3"
  },
  "matchmaking": {
    "modes": {
      "5v5": {
        "teams": 2,
        "playersPerTeam": 5,
        "useVersionFromState": "gameVersion",
        "rules": [
          { "type": "equals",     "field": "gameVersion" },
          { "type": "difference", "field": "mmr", "maxDifference": 100 }
        ]
      }
    }
  }
}
```

`useVersionFromState` maps to a build's `name` (e.g. `1.2.3`), not its numeric version. `serverSettings.gameBuild` acts as the fallback when the field is missing from every matched player's state.

### Unity SDK

Send the version in player state before you queue:

```csharp theme={null}
using System.Collections.Generic;
using PlayFlow;
using UnityEngine;

public class VersionedMatchmaking : MonoBehaviour
{
    public void SetMatchmakingState(int playerMMR)
    {
        var matchmakingData = new Dictionary<string, object>
        {
            { "gameVersion", Application.version }, // e.g. "1.2.3"
            { "mmr", playerMMR },
            { "regions", new List<string> { "us-east", "us-west" } }
        };

        PlayFlowLobbyManagerV2.Instance.UpdatePlayerState(matchmakingData,
            onSuccess: (lobby) => Debug.Log($"Queueing as v{Application.version}"),
            onError: (error) => Debug.LogError($"State update failed: {error}")
        );
    }
}
```

### Prerequisites

* A build whose `name` matches the state value must exist and be `ready`
* Every matched player must send the field in their state — add a client-side check with a sensible fallback
* An `equals` rule on the same field is required (the dashboard and API reject the config otherwise)

### Failure modes

| Condition                                             | Outcome                                  |
| :---------------------------------------------------- | :--------------------------------------- |
| Field missing from state                              | Match fails, players return to `waiting` |
| Build with that `name` doesn't exist or isn't `ready` | Match fails, players return to `waiting` |
| No `equals` rule on the same field                    | Config save rejected (`400`)             |

<Tip>
  Gate access to old versions by deleting (or unpublishing) the old build once your player base has upgraded. The matchmaker will refuse matches that resolve to missing builds, forcing clients onto a supported version.
</Tip>

***

## Region Matching

Players specify their acceptable regions in state (or PlayFlow falls back to the lobby's region):

```bash theme={null}
PATCH /v3/lobbies/ranked/me

{ "state": { "regions": ["us-east", "us-west"] } }
```

The matchmaker:

1. Intersects all matched lobbies' region lists
2. Within the intersection, counts weighted votes (1/(index+1) by preference order)
3. Picks the highest-voted region for the server

If lobbies have no overlap, the match doesn't form — they're considered incompatible.

***

## Queue Stats

While in `in_queue`, your lobby response includes live queue stats:

```json theme={null}
{
  "matchmaking": {
    "mode": "5v5",
    "startedAt": "...",
    "queueStats": {
      "playersSearching": 42,
      "lobbiesInQueue": 8,
      "avgWaitSeconds": 12.3
    }
  }
}
```

Via **SSE**, you get push updates every 10 seconds:

```
event: queue_stats
data: { "playersSearching": 45, "lobbiesInQueue": 9, "avgWaitSeconds": 11.8 }
```

Use this to show your players "12s estimated wait" or "42 searching now" in your UI.

***

## Backfill

For long-running persistent games (e.g., battle royale with late-join):

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

When backfill is enabled, the matchmaker will add new lobbies to **existing in-progress games** that have open slots — not just create new matches. Players join the running server and jump right into the action.

***

## Party Size Limits

Prevent a 5-stack from queueing for a 1v1:

```json theme={null}
{
  "maxPartySize": 1
}
```

Lobbies with more than `maxPartySize` eligible players are rejected from queueing.

***

## Matchmaking Timeout

The `timeout` field on a matchmaking mode caps how long a player will wait in queue before the system gives up.

```json theme={null}
{ "timeout": 60 }
```

When the timeout elapses, the player returns to the lobby's `waiting` status with an explanatory event over SSE — they are **not** re-queued automatically. Players must explicitly press matchmake again.

**Typical values:**

* `30` — casual / quick play
* `60` – `120` — ranked
* `180`+ — large FFAs or niche modes where the candidate pool is thin

***

## Battle Royale

Battle royale modes are not a separate feature — they are just a particular shape of the existing `teams` / `playersPerTeam` fields. There is nothing special to enable on the backend.

**Squad BR** uses the Teams format: many small squads fight in one match. A `{ "teams": 25, "playersPerTeam": 4 }` mode fills one 100-player server from 25 four-player lobbies.

**Solo BR** uses the Free-for-all format: `{ "teams": 100, "playersPerTeam": 1 }` fills a 100-player match from 100 solo lobbies.

### Anchor-pairwise matching

When N lobbies come together to form one match, rules are evaluated **pairwise against the anchor** (the first lobby in the queue), not against a running average. For a 25-squad BR that means 24 anchor-vs-candidate evaluations — each candidate must be compatible with the anchor.

This matters because two admitted lobbies can legally be up to `2 × maxDifference` apart from each other, even though each is within `maxDifference` of the anchor.

### Rule recommendations

* **Skill (`difference` on `mmr`)** — works, but tune `maxDifference` on the conservative side. Remember two admitted squads can be `2 × maxDifference` apart.
* **`equals` on `gameVersion`** — every lobby must match the anchor exactly. Recommended.
* **`region`** — critical for BR. One cross-continent squad ruins the server for everyone.

<Warning>
  Do **not** enable `matchConfirmation` (accept match) on BR modes. 100 players × "click accept" means any single holdout cancels the match. Accept-match is for 1v1 and small team matches, not large rooms.
</Warning>

### Party semantics reminder

Numeric fields in a lobby's `state` (like `mmr`) are averaged **within** a party when it joins matchmaking together. Across parties, each party carries its own averaged value — the matchmaker compares those averages against the anchor.

### Example: 25-squad BR

```json theme={null}
{
  "matchmaking": {
    "modes": {
      "squads": {
        "teams": 25,
        "playersPerTeam": 4,
        "timeout": 180,
        "rules": [
          { "type": "difference", "field": "mmr", "maxDifference": 150 },
          { "type": "equals", "field": "gameVersion" },
          { "type": "region" }
        ]
      }
    }
  }
}
```

***

## How Matchmaking Runs

<Info>
  **Matchmaking runs inline on queue entry.** When lobby B calls `POST /me/matchmaking`, the system immediately scans for compatible lobbies. If A is already queued, B matches with A within \~100ms. No polling loops, no worker delay.

  A QStash-driven cron runs every 60s as a safety net (catches edge cases like simultaneous-entry races).
</Info>

**Concurrency safety:** The matchmaker uses Postgres `FOR UPDATE SKIP LOCKED` + a claim-then-create pattern. Two simultaneous match attempts can never claim the same lobbies or launch duplicate servers.

***

## Error Cases

| Status                    | Cause                                                    |
| :------------------------ | :------------------------------------------------------- |
| `400 Mode not found`      | The `mode` you passed isn't defined in your lobby config |
| `400 Party size exceeded` | Your lobby has more players than `maxPartySize`          |
| `400 Already in queue`    | Lobby is already in `in_queue` status                    |
| `403 Not host`            | Non-host tried to start/cancel matchmaking               |
| `409 Invalid state`       | Lobby is `in_game` or otherwise not in `waiting`         |

***

## Full Example: Ranked 1v1

```bash theme={null}
# Host creates a lobby (with MMR in state)
curl -X POST "https://api.computeflow.cloud/api/v3/lobbies/ranked" \
  -H "api-key: pfclient_..." \
  -H "x-player-id: p1" \
  -d '{"name":"1v1","maxPlayers":1,"state":{"mmr":1500}}'

# Host queues for matchmaking
curl -X POST "https://api.computeflow.cloud/api/v3/lobbies/ranked/me/matchmaking" \
  -H "api-key: pfclient_..." \
  -H "x-player-id: p1" \
  -d '{"mode":"1v1"}'

# Separately, another player does the same — they match automatically
# Both get the same matchId and server info
curl "https://api.computeflow.cloud/api/v3/lobbies/ranked/me" \
  -H "api-key: pfclient_..." \
  -H "x-player-id: p1"
# → { "status": "in_game", "server": { "ip": "...", "port": 7770 } }
```

<CardGroup cols={2}>
  <Card title="Real-time Events" icon="bolt" href="/lobbies/real-time">
    Stream matchmaking progress and queue stats via SSE.
  </Card>

  <Card title="API Reference" icon="code" href="/api-reference/lobbies-and-matchmaking/startmatchmaking">
    Full schemas for the matchmaking endpoints.
  </Card>
</CardGroup>
