Skip to main content

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.

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

1

Players group up in a lobby

One or more players join a lobby (as a party).
2

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

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

Match formed

All matched lobbies atomically claim each other. Status → matched.
5

Game server launches

One dedicated server starts. All matched lobbies get the same matchId and server.network_ports[].
6

Players connect

Clients read server.network_ports[0].host:external_port from their lobby and connect to the game server.

Configuring Matchmaking

Matchmaking modes are defined in your lobby config in the PlayFlow dashboard. Each config can have multiple modes:
{
  "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
      }
    }
  }
}
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).

Starting Matchmaking

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:
{
  "status": "in_queue",
  "matchmaking": {
    "mode": "5v5",
    "startedAt": "2026-04-08T20:30:00Z",
    "queueStats": {
      "playersSearching": 42,
      "lobbiesInQueue": 8,
      "avgWaitSeconds": 12.3
    }
  },
  ...
}
Only the host can start matchmaking. Non-hosts get 403 Not host.

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

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:
{
  "mode": "ranked_5v5",
  "matchConfirmation": {
    "enabled": true,
    "timeoutSeconds": 10
  }
}

How it works

  1. Matchmaker finds a match → all lobbies transition in_queuematch_found. Server is NOT launched yet.
  2. Each lobby’s response now includes a confirmation block:
    {
      "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.
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).
Declining or timing out moves players to waiting, not back to in_queue. This prevents accidental re-matching. Players must explicitly press matchmake again.

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:
POST /v3/lobbies/ranked/me/end-match
x-player-id: host_player
This:
  • Transitions lobby from in_gamewaiting
  • 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.
{
  "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:
ModeConfig
1v1 rankedteams: 2, playersPerTeam: 1
2v2 duosteams: 2, playersPerTeam: 2
5v5 competitiveteams: 2, playersPerTeam: 5
100-player FFAteams: 1, playersPerTeam: 100, minPlayersPerTeam: 20
50-squad battle royaleteams: 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):
{
  "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:
PATCH /v3/lobbies/hunt/me
x-player-id: p1

{ "state": { "role": "boss" } }
Or a preference list (higher priority first):
{ "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:
{
  "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

difference

Numeric tolerance with auto-expanding buckets. Skill matching.

equals

Both sides must share a field value (version, map).

not_equals

Values must differ (anti-rematch).

region

Acceptable-region overlap (≥ N shared).

expression

CEL escape hatch for custom logic.

Skill matching

The difference rule pairs players whose state differs by no more than a threshold, with buckets that widen over time.
{ "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 timeEffective 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.
{ "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.
{ "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.
{ "type": "region", "minOverlap": 1 }
Matches only if both lobbies share at least minOverlap entries in their acceptableRegions list. Players set regions via their state:
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 (Common Expression Language).
{
  "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:
// 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:
{
  "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

{
  "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:
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

ConditionOutcome
Field missing from stateMatch fails, players return to waiting
Build with that name doesn’t exist or isn’t readyMatch fails, players return to waiting
No equals rule on the same fieldConfig save rejected (400)
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.

Region Matching

Players specify their acceptable regions in state (or PlayFlow falls back to the lobby’s region):
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:
{
  "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):
{
  "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:
{
  "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.
{ "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
  • 60120 — 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.
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.

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

{
  "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

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

StatusCause
400 Mode not foundThe mode you passed isn’t defined in your lobby config
400 Party size exceededYour lobby has more players than maxPartySize
400 Already in queueLobby is already in in_queue status
403 Not hostNon-host tried to start/cancel matchmaking
409 Invalid stateLobby is in_game or otherwise not in waiting

Full Example: Ranked 1v1

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

Real-time Events

Stream matchmaking progress and queue stats via SSE.

API Reference

Full schemas for the matchmaking endpoints.