Matchmaking Integration

Allocate fleet servers for matches: the allocation API, the first-party ticket matchmaker with relaxation, attribute windows, backfill and latency placement, tickets from the game client, and the desired-capacity hint.

If your game is session-based (players queue for a match, a server runs it, then resets), Discovery can place those matches on your fleet. Matchmaking is an optional layer: a fleet lists your servers and reports live state without any of the APIs on this page. To place a match, a matchmaker asks Discovery to allocate a server: the service transactionally claims counter capacity on one eligible server, delivers the match context to it, and returns connection details. Whether you run your own matchmaker or use the built-in ticket matchmaker, everything goes through this one primitive; the built-in matchmaker has no private capabilities.

Allocations, joinable-session records, and the desired-capacity hint require a token with the Allocate (or Both) scope, issued on your Discovery app page. Those are backend APIs: never call them from a game client. Tickets accept either an allocate token from your backend or a player token from the game client itself; see Tickets from the game client below and Choosing How Players Connect. If you have no backend and only need players to find and join a listed server, you do not need this page: player tokens let the game client reserve seats and quick-join directly. The examples use https://discovery.pingcore.io; your app page shows your exact base URL. Allocation and backend ticket calls share a per-app rate budget of 300 requests per minute by default. An allocation costs one request per match, but ticket status polls draw on the same budget: one ticket polled every 2 seconds costs 30 requests per minute, so around 10 concurrently queued backend tickets saturate the default budget on polling alone. At scale, poll less aggressively and spread polls over the interval, or contact support to raise the limit. Player-token tickets draw on the player budgets instead (60 requests per minute per player).

The allocation API

POST /v1/apps/{publicId}/allocations
Authorization: Bearer dsc_...   (allocate scope)
{
  "idempotencyKey": "match-83a1",
  "claims": { "sessions": 1 },
  "filters": { "meta": { "location": "eu-west-ams" } },
  "context": { "mode": "ranked", "roster": ["p1", "p2"] }
}
  • idempotencyKey (required, 1 to 100 characters): your identifier for this allocation attempt. The same key always replays the original allocation instead of creating a new one (for 10 minutes by default), so network retries can never double-allocate.

  • claims (required): the counter capacity to claim, 1 to 10 counters, each claiming 1 to 1,000,000. For the default fleet counter model, { "sessions": 1 } claims one match slot. See the counters model.

  • filters (optional): restrict candidates. version is an exact match on the server's version; meta is a map of exact matches on server metadata. Fleet servers advertise their location as meta.location, using the platform's location identifier (us-east-nyc, eu-west-ams, and so on), so { "meta": { "location": "eu-west-ams" } } places the match in the Amsterdam zone. GET /v1/locations lists every identifier with its name and latency beacon. An identifier names a latency zone rather than a city: the zone's beacon may sit in a different city from some of the servers that advertise it (London servers advertise eu-west-ams and are measured through the Amsterdam beacon), so a client measures the zone's beacon, not each server. See Latency and Location Targeting.

  • context (optional): an opaque JSON blob delivered to the allocated game server (via the SDK watch stream on fleet servers). Use it for the roster, game mode, map, or anything else your server needs to run the match. Capped at 8192 bytes.

Success

{
  "error": false,
  "allocationId": "…",
  "serverId": "4211",
  "status": "pending",
  "ip": "203.0.113.9",
  "port": 27015,
  "expiresIn": 60,
  "replayed": false
}

Hand ip and port to your players. The allocation is pending until the game server confirms a session start; on platform-hosted fleet servers that confirmation is automatic on delivery. An allocation not confirmed within expiresIn seconds (60 by default) is released automatically, so a crashed handoff returns capacity to the pool by itself. A replayed response (same idempotency key) carries replayed: true and the original allocation's details.

No capacity

A 409 with reason: "no_capacity" means no eligible ready server can satisfy the claims and filters right now. Back off briefly, retry with the same idempotency key, and consider raising the capacity hint (below) so the autoscaler brings more servers up.

Which servers are eligible

  • Fleet (agent-tier) servers only. Heartbeat-only servers are never allocated.

  • In state ready, or in_session with spare capacity (backfill).

  • Every claimed counter must exist on the server and fit: current count plus already-pending claims plus your claim must stay within capacity.

  • All filters must match.

Among eligible servers, Discovery packs best-fit: it prefers the server with the least remaining capacity, so sessions fill already-busy servers and idle ones drain empty for the autoscaler to reap.

Inspecting and cancelling

  • GET /v1/apps/{publicId}/allocations/{allocationId}: status (pending or confirmed), claims, current ip/port, and expiresAt while pending.

  • DELETE /v1/apps/{publicId}/allocations/{allocationId}: cancels a still-pending allocation and releases its claims. A confirmed allocation answers 409 (the session is running; it ends server-side). An unknown id answers 404. Cancelling a pending backfill allocation also returns its seats to the joinable-session record immediately (see Joinable sessions).

Allocation examples

# Allocate-scope token required (a heartbeat token gets a 403).
# 200 gives you { allocationId, serverId, ip, port, expiresIn }.
# 409 no_capacity means no eligible ready server right now: back off briefly
# and retry with the SAME idempotencyKey (retries can never double-allocate).
curl -X POST https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/allocations \
  -H "Authorization: Bearer YOUR_ALLOCATE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"idempotencyKey":"match-83a1","claims":{"sessions":1},"filters":{"meta":{"location":"eu-west-ams"}},"context":{"mode":"ranked","roster":["p1","p2"]}}'

The first-party matchmaker: tickets

If you do not want to write a matchmaker, submit tickets and let the service form matches. It runs a matcher every 2 seconds and allocates each formed match through the same allocation API described above, claiming { "sessions": 1 } per match (so fleet servers must advertise a sessions counter, which is the platform default).

POST /v1/apps/{publicId}/tickets
{
  "ticketId": "party-77",
  "queue": "ranked",
  "sessionSize": 8,
  "partySize": 2,
  "filters": { "meta": { "region": "eu" } },
  "context": { "partyName": "the crew" },

  "minSessionSize": 4,
  "relaxAfterSeconds": 45,
  "attributes": { "skill": 1240, "region_pref": 2 },
  "rules": { "skill": { "maxDifference": 100, "widenPerSecond": 5, "maxWiden": 400 } },
  "latency": { "us-east-nyc": 38, "eu-west-ams": 112 },
  "maxLatencyMs": 80,
  "joinInProgress": true
}

The first six fields are the basic ticket. Everything from minSessionSize down is optional, and a ticket that carries none of those fields is processed exactly as before they existed.

  • ticketId (optional, 1 to 100 characters): your identifier; resubmitting the same id is idempotent. Omit it and one is generated. A player-token caller's id may contain only letters, digits, and _ . : -.

  • queue (optional, up to 50 characters, default "default"): tickets only match within one queue.

  • sessionSize (required, 1 to 1000): total players a match needs.

  • partySize (optional, default 1): players this ticket brings. Cannot exceed sessionSize.

  • filters and context: same semantics as the allocation API. The context of every ticket in the match is delivered to the server.

  • minSessionSize and relaxAfterSeconds: accept a smaller session after a wait. Sent together or not at all. See Smaller sessions after a wait.

  • attributes and rules: numbers the matcher compares, and the windows they must fall within. See Attribute windows.

  • latency and maxLatencyMs: the client's measured round trips per location, and an optional ceiling. See Placing matches by measured latency.

  • joinInProgress (default false): allow this ticket to be placed into a session that is already running. See Joinable sessions.

Tickets only match with tickets that agree on queue, sessionSize, and filters. By default matches are exact-fill: party sizes must sum to exactly sessionSize. Relaxation, attributes and latency are constraints applied while the match is packed; they never split a queue the way a different queue or filters value does.

You should see:

{
  "error": false,
  "ticketId": "party-77",
  "status": "queued",
  "queue": "ranked",
  "expiresIn": 300,
  "ownerKind": "backend",
  "ownerPlayerId": null,
  "joinInProgress": true,
  "minSessionSize": 4,
  "relaxAfterSeconds": 45,
  "createdAt": 1756900000000,
  "expiresAt": 1756900300000
}

minSessionSize and relaxAfterSeconds echo the stored values (null when you omitted them). For a player-token ticket, relaxAfterSeconds may be higher than what you sent (see the floor under Tickets from the game client); the echoed value is the one in force.

Every 400 on submit carries a reason and a message that names the fix. The ones a basic ticket can hit are party_too_large (partySize above sessionSize) and context_too_large; the ones the optional fields add are listed under their own sections and in Troubleshooting.

What the game server receives

When the built-in matchmaker allocates a formed match, the context delivered to the game server is not one ticket's context. It is a wrapper carrying the whole match:

{
  "matchmaker": true,
  "queue": "ranked",
  "sessionSize": 8,
  "players": 6,
  "relaxed": true,
  "backfill": false,
  "location": "us-east-nyc",
  "roster": [
    { "ticketId": "party-77", "partySize": 2, "playerId": null, "attributes": { "skill": 1240 }, "context": { "partyName": "the crew" } },
    { "ticketId": "8d1f…", "partySize": 1, "playerId": "steam:7656…", "attributes": null, "context": null }
  ]
}
  • roster: one entry per ticket in the match. context is that ticket's own blob (null when the ticket omitted it); attributes is the ticket's attribute map, or null.

  • players: the sum of partySize over the roster. It equals sessionSize unless the match was relaxed or is a backfill.

  • relaxed: true when the session started below sessionSize because the oldest ticket's relaxAfterSeconds elapsed.

  • backfill: true when these tickets were placed into a session you published as joinable; roster then holds only the joining tickets and sessionSize is the joinable record's size.

  • location: the placement location chosen from the tickets' latency maps, or null when no ticket constrained placement and the tickets' filters did not pin one.

  • playerId: the player token's sub for a ticket the game client submitted, null for a backend ticket. Match it (or ticketId) against your connect handshake to admit players without a reservation.

Branch your server-side parser on matchmaker: true to tell first-party matches apart from allocations you make directly, where the context is exactly the blob you posted. A server written before these fields existed keeps working: the new keys are additive. How the context reaches your server is covered in Making Your Game Fleet-Ready.

Polling until matched

The submit response reports expiresIn (300 seconds by default). The TTL is the queue timeout. Poll until matched:

GET /v1/apps/{publicId}/tickets/{ticketId}
{
  "error": false,
  "ticketId": "party-77",
  "status": "matched",
  "queue": "ranked",
  "allocationId": "a1b2c3",
  "serverId": "4211",
  "ip": "203.0.113.9",
  "port": 27015,
  "backfill": false,
  "location": "us-east-nyc",
  "matchedAt": 1756900041000,
  "ownerKind": "backend",
  "ownerPlayerId": null,
  "createdAt": 1756900000000,
  "expiresAt": 1756900300000
}
  • status: "queued": keep polling. The match loop ticks every 2 seconds, so polling faster than that has no effect, and every poll counts against the caller's budget. With many tickets queued at once, poll each one less often. While queued, allocationId, serverId, ip, port, location and matchedAt are null.

  • status: "matched": read ip and port and hand them to the party. backfill: true means the party is joining a session already in progress.

  • 404 means the ticket expired (or never existed): the record vanishes at TTL, there is no expired status to read. Resubmit to requeue and keep showing "still searching" in your UX. The message is Unknown ticket (it may have expired).

Cancel when a party leaves the queue with DELETE /v1/apps/{publicId}/tickets/{ticketId}. A ticket that already matched answers 409; release its allocation instead if the match should not proceed.

const TICKETS_URL = 'https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/tickets';
const headers = {
  'Authorization': `Bearer ${process.env.DISCOVERY_ALLOCATE_TOKEN}`,
  'Content-Type': 'application/json',
};

async function matchmake(ticketId, partySize) {
  await fetch(TICKETS_URL, {
    method: 'POST',
    headers,
    body: JSON.stringify({ ticketId, queue: 'ranked', sessionSize: 8, partySize }),
  });

  for (;;) {
    await new Promise((resolve) => setTimeout(resolve, 2000));

    const response = await fetch(`${TICKETS_URL}/${ticketId}`, { headers });
    if (response.status === 404) return null; // expired: resubmit to requeue
    if (!response.ok) continue;               // transient error: keep polling

    const ticket = await response.json();
    if (ticket.status === 'matched') return { ip: ticket.ip, port: ticket.port };
  }
}

Smaller sessions after a wait (minSessionSize and relaxAfterSeconds)

An exact-fill queue with too few players in it never forms a match: the tickets sit until they expire. To let a thin queue start a smaller session instead, send minSessionSize (the smallest session this ticket accepts, 1 up to sessionSize) together with relaxAfterSeconds (0 up to one below the ticket TTL). Both fields or neither: sending one without the other answers 400 relaxation_incomplete with the message minSessionSize and relaxAfterSeconds must be sent together: the floor is the smallest session you accept, the delay is how long the oldest ticket waits for an exact fill first.

The delay belongs to the oldest ticket. The OLDEST waiting ticket in the group waits relaxAfterSeconds before a smaller session is considered; a younger ticket may be pulled into a smaller session before its own delay elapses. A ticket that must never join a partial session omits minSessionSize.

The matcher runs exact fills first on every tick, so a full session always wins over a partial one when the queue allows it. Only what could not exact-fill is considered for a smaller session, and every ticket in that smaller session must accept its final size: a ticket whose minSessionSize is larger than the session that would form is left in the queue, and a ticket without minSessionSize is never pulled into a partial session. A ticket without minSessionSize can therefore expire while relaxable tickets around it keep matching in smaller sessions; that is what omitting the field asks for.

The server learns the size from the delivered context: players is the number of players actually in the session and relaxed is true. A single ticket may launch alone when its partySize is at least its minSessionSize; the matcher does not second-guess a floor your backend stated (player-token tickets have a floor of their own, below).

Other 400s on these fields: min_session_too_large (minSessionSize cannot exceed sessionSize.), min_session_below_party (minSessionSize cannot be smaller than partySize: a session must hold the whole party.), and relax_after_too_long (relaxAfterSeconds must be below the ticket TTL of 300 seconds, or the ticket would expire before it could relax.).

# Two solo tickets in an 8-player queue. Neither can exact-fill, so without
# minSessionSize both would expire at the TTL. With it, a 2-player session
# starts about 30 s after the FIRST ticket was submitted.
for id in relax-a relax-b; do
  curl -X POST https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/tickets \
    -H "Authorization: Bearer YOUR_ALLOCATE_TOKEN" \
    -H "Content-Type: application/json" \
    -d "{\"ticketId\":\"$id\",\"queue\":\"casual\",\"sessionSize\":8,\"minSessionSize\":2,\"relaxAfterSeconds\":30}"
done

You should see: two tickets with sessionSize: 8, minSessionSize: 2, relaxAfterSeconds: 30 reach status: "matched" about 30 seconds after the first was submitted, and the delivered context carries players: 2, relaxed: true.

Attribute windows (skill and other numbers)

A ticket may carry attributes, a map of numbers (up to 8 keys by default for a backend caller; keys of letters, digits, _ . -, up to 50 characters; finite values). On its own an attribute is informational: it is delivered on the roster entry and constrains nothing. To constrain, add a rule for the key under rules:

  • maxDifference (required, 0 or more): how far another ticket's value may be from this ticket's value at submit time.

  • widenPerSecond (optional): how much the window grows for every second this ticket has waited. Absent means the window never widens.

  • maxWiden (optional): the most the window may grow. Absent means the widening is bounded only by the ticket's TTL.

The window at any moment is maxDifference + min(widenPerSecond * waitSeconds, maxWiden), computed from the ticket's own wait. A rule on a key the ticket does not carry itself answers 400 rule_without_attribute (rules.skill has no matching attributes.skill; a rule constrains an attribute this ticket carries.). More keys than the caller's limit answers 400 too_many_attributes (attributes has 9 keys; the limit is 8.).

Compatibility is symmetric: two tickets match only when each ticket's window admits the other's value. A wide-window ticket cannot pull a narrow-window ticket out of its band, because the narrow ticket must admit it too. A ticket with a rule on skill never matches a ticket that has no skill attribute at all; a missing attribute is a mismatch, never a wildcard. Tickets with no rules admit everyone.

Because the window widens with each ticket's own wait, a long-waiting ticket admits more peers over time while a fresh ticket still holds its band. Attributes are checked against every ticket already picked for the match, so a formed match is a set in which every pair is compatible.

# Two tickets 90 skill points apart under a 50-point window that widens by
# 5 per second: they become mutually compatible after 8 seconds of waiting.
curl -X POST https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/tickets \
  -H "Authorization: Bearer YOUR_ALLOCATE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ticketId":"skill-a","queue":"ranked","sessionSize":2,"attributes":{"skill":1200},"rules":{"skill":{"maxDifference":50,"widenPerSecond":5}}}'

curl -X POST https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/tickets \
  -H "Authorization: Bearer YOUR_ALLOCATE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ticketId":"skill-b","queue":"ranked","sessionSize":2,"attributes":{"skill":1290},"rules":{"skill":{"maxDifference":50,"widenPerSecond":5}}}'

You should see: the tickets stay queued for about 8 seconds, then match.

Attributes on a ticket are asserted by whoever submits it. From your backend (a dsc_ token) they are as trustworthy as your backend. From a game client (a player token) they are whatever the client chose to send, so Discovery refuses them by default (PLAYER_TICKET_MAX_ATTRIBUTES is 0 until the operator raises it for your app; a player ticket carrying any attribute answers 400 too_many_attributes with the limit is 0). If you want ranked matchmaking from clients, use studio-signed player tokens and have the service that mints them submit the ticket with the rating it holds, or wait for signed-claim attributes (on the roadmap).

Joinable sessions

Filling a session in progress. When a running session has open seats (a player left, or the match started below sessionSize), the server can ask the matchmaker to fill them. It works in two halves: the session publishes a joinable-session record saying how many seats are open, and tickets opt in with joinInProgress: true. On every tick the matcher fills joinable sessions first, then forms new matches from what is left.

Publishing is a backend call with an allocate-scope token. There is one record per server; publishing again replaces it.

POST /v1/apps/{publicId}/servers/{serverId}/joinable-session
{
  "queue": "ranked",
  "openSeats": 3,
  "sessionSize": 8,
  "attributes": { "skill": 1210 },
  "sessionId": "match-83a1",
  "ttlSeconds": 60
}
  • queue (optional, default "default"): only tickets in this queue are placed into the session.

  • openSeats (required, 0 up to 1000 by default): the seats you can currently fill. Publish maxPlayers - connected - expectedJoiners, where expectedJoiners counts players the matchmaker has already sent you who have not connected yet. expectedJoiners MUST include every roster entry returned by the joinable-session GET (or GET /allocations/{id}) that has not yet connected. Above the limit answers 400 too_many_open_seats ("openSeats" is 1200; the limit is 1000.).

  • sessionSize (optional): when set, only tickets with this sessionSize qualify. Omit it to accept any.

  • attributes (optional): the session's numbers, checked against a joining ticket's rules the same way another ticket's would be. The record itself has no rules.

  • sessionId (optional, up to 100 characters): your own session or allocation id. It is echoed in the backfill context so your server can tie the joiners to the running match.

  • ttlSeconds (optional, default 60, minimum 5, maximum 300 by default): how long the record lives. Above the configured maximum answers 400 ttl_too_long ("ttlSeconds" is 900; the limit is 300.).

You should see:

{
  "error": false,
  "serverId": "4211",
  "queue": "ranked",
  "openSeats": 3,
  "sessionSize": 8,
  "allocatable": true,
  "expiresAt": 1756900060000,
  "expiresIn": 60
}

allocatable tells you whether anything can fill this record right now: the server must be live, a fleet (agent-tier) server, and in_session. A heartbeat-only server may publish and gets allocatable: false; the record is stored but never filled, because only fleet servers are allocatable. A server that is not live in the app answers 404 Unknown server.

Republish every ttlSeconds / 2 and immediately when the seat count changes. A republish replaces openSeats with what you publish but never cancels joiners already matched and not yet connected: Discovery tracks those separately and subtracts them, so the effective open seats at match time are your published openSeats minus joiners already matched but unconfirmed minus seats held by reservations on the same server. A publisher that forgets to lower openSeats for matched joiners is protected by that subtraction for 60 seconds; after that only your own count keeps the seats from being sold again, which is why the expectedJoiners rule above is a MUST.

Withdraw the record with DELETE .../joinable-session the moment your session ends. Discovery also refuses backfills to a server that is no longer in_session, but the withdraw is what stops players being matched into a lobby that is closing.

DELETE /v1/apps/{publicId}/servers/{serverId}/joinable-session

You should see { "error": false, "serverId": "4211", "withdrawn": true }, whether or not a record existed.

Tickets opt in with joinInProgress: true. Such a ticket qualifies for a joinable session when it is in the record's queue, its sessionSize matches the record's (when the record set one), its filters match the server, its rules admit the record's attributes, and the server's location is within its maxLatencyMs (when it set one). Tickets are placed oldest first while they fit in the effective open seats. A ticket that no joinable session takes still takes part in normal formation on the same tick.

The server receives the joiners as a backfill allocation. On hosted servers it is pushed to the agent as its own frame, distinct from a new allocation, so a game that does not understand backfill is never told a new session started. The delivered context is:

{
  "matchmaker": true,
  "backfill": true,
  "queue": "ranked",
  "sessionSize": 8,
  "players": 2,
  "location": "us-east-nyc",
  "sessionId": "match-83a1",
  "roster": [
    { "ticketId": "8d1f…", "partySize": 2, "playerId": "steam:7656…", "attributes": null, "context": null }
  ]
}

The current game supervisor does not yet deliver that frame to the game process; the update that does is scheduled separately. Until the supervisor update ships, backfill on hosted servers is only safe for games that admit any connecting player; games that authorise from the roster must not publish joinable records. A publisher that needs the roster of incoming players polls GET .../joinable-session (it lists every not-yet-confirmed backfill with its roster) or GET .../allocations/{allocationId}.

GET /v1/apps/{publicId}/servers/{serverId}/joinable-session
{
  "error": false,
  "serverId": "4211",
  "record": {
    "queue": "ranked",
    "openSeats": 3,
    "effectiveOpenSeats": 1,
    "sessionSize": 8,
    "sessionId": "match-83a1",
    "publisher": "backend",
    "publishedAt": 1756900000000,
    "expiresAt": 1756900060000
  },
  "backfills": [
    {
      "allocationId": "b7e2…",
      "seats": 2,
      "matchedAt": 1756900012000,
      "expiresAt": 1756900072000,
      "roster": [
        { "ticketId": "8d1f…", "partySize": 2, "playerId": "steam:7656…", "attributes": null, "context": null }
      ]
    }
  ]
}

backfills lists the joiners matched to this server that the game has not confirmed yet, oldest first, each with the ticket roster you admit players against. effectiveOpenSeats is the figure the matcher will actually sell against. A backfill is visible on the joinable-session GET for at most ALLOCATION_PENDING_TTL_SECONDS (60 s) after it is matched. Poll at least every 30 s while a record is live, or persist allocationIds from your own GET /allocations polling. A publisher that polls slower than that can miss a joiner entirely and must treat unknown connecting players accordingly.

The GET has two answers. A live server with no record answers 200 with record: null and backfills: []; a server that is not live (no heartbeat) answers 404 Unknown server., so a publisher whose own server dropped off sees 404, not an empty record. There is no reason code on this route.

// Publisher loop for a running session. Republish at half the TTL and whenever
// the seat count changes; withdraw the moment the session ends.
const BASE = 'https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID';
const headers = {
  'Authorization': `Bearer ${process.env.DISCOVERY_ALLOCATE_TOKEN}`,
  'Content-Type': 'application/json',
};

async function publishOpenSeats(serverId, sessionId, maxPlayers, connected, expectedJoiners) {
  // expectedJoiners: every roster entry from the joinable-session GET (or GET /allocations/{id})
  // that has not connected yet. Without it the same seats are sold twice.
  const openSeats = Math.max(0, maxPlayers - connected - expectedJoiners);
  await fetch(`${BASE}/servers/${serverId}/joinable-session`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ queue: 'ranked', openSeats, sessionSize: 8, sessionId, ttlSeconds: 60 }),
  });
}

async function readIncomingJoiners(serverId) {
  const response = await fetch(`${BASE}/servers/${serverId}/joinable-session`, { headers });
  if (response.status === 404) return null;            // our server is no longer live
  const { backfills } = await response.json();          // record may be null after a withdraw
  return backfills.flatMap((b) => b.roster);            // admit these players on connect
}

async function withdraw(serverId) {
  await fetch(`${BASE}/servers/${serverId}/joinable-session`, { method: 'DELETE', headers });
}

You should see: a joinInProgress ticket in the same queue reaches matched on the very next tick with the joinable server's ip/port, GET .../joinable-session lists the backfill under backfills with the ticket's roster, and GET .../matchmaking/queues shows effectiveOpenSeats reduced by the party size while openSeats still shows what you published.

Placing matches by measured latency

A ticket may carry latency, a map from location identifier to the client's measured round trip in milliseconds (up to 32 entries by default, values 0 to 10000), and optionally maxLatencyMs (1 to 10000), the highest latency the player accepts. How a client measures the map is on Latency and Location Targeting; it also lists the location identifiers and beacons at GET /v1/locations.

Two modes:

  • With maxLatencyMs, the map is a hard filter. A location is acceptable to the ticket only when its measured latency is at or below the ceiling; a location the ticket has no measurement for is not acceptable. A match is placed only at a location every ceiling-bearing member accepts. When no live server advertises any acceptable location, the tickets stay queued; the matcher never falls back to a location the player ruled out.

  • Without maxLatencyMs, the map is a preference. The matcher tries the best-measured locations first and, if none of them has capacity, falls back to any ready server rather than leaving the match queued.

Placement is minimax: among the candidate locations, the matcher picks the one that minimises the worst latency across the members that sent a map (a member with no measurement for a location counts as the worst case for it). Ties fall to the lower mean, then to the identifier alphabetically. Up to 3 ranked locations are tried per match before the tickets are left for the next tick. The chosen location arrives on the delivered context as location and on the matched ticket as location.

Two 400s at submit: max_latency_without_map when maxLatencyMs is sent with no latency map (maxLatencyMs needs a latency map to apply to. Measure the locations from GET /v1/locations and send them as latency.), and no_location_within_ceiling when every measured value is above the ceiling (No measured location is within maxLatencyMs; raise the ceiling or measure more locations.). The second one is a refusal rather than a queued ticket on purpose: a ticket with no acceptable location can never be placed, so queuing it until it expired would only hide the problem. Raise the ceiling, measure more locations, or omit it. More entries than the limit answers too_many_latency_entries (latency has 40 entries; the limit is 32.). A location Discovery does not know is ignored, never refused, so a client built against a newer location list keeps working.

# A ticket that must land within 80 ms of the player. Only eu-west-ams qualifies here,
# so the match is placed there or stays queued until an Amsterdam server is ready.
curl -X POST https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/tickets \
  -H "Authorization: Bearer YOUR_ALLOCATE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ticketId":"near-me","queue":"ranked","sessionSize":2,"latency":{"eu-west-ams":24,"eu-west-fra":31,"us-east-nyc":96},"maxLatencyMs":80}'

You should see: once matched, GET .../tickets/near-me carries location: "eu-west-ams" and the server's ip/port, and the delivered context carries the same location.

Tickets from the game client

The three ticket routes (submit, poll, cancel) accept a player token in place of the allocate token, so a game with no backend can queue for a match directly. Player tokens are studio-signed JWTs from your login service or anonymous tokens Discovery issues on request; the setup for each is on Choosing How Players Connect. A backend dsc_ caller sees no change at all.

A player-token ticket is bounded (defaults on a PingCore Discovery deployment):

  • partySize at most 8 (400 party_too_large_for_player: Player tokens may submit a party of at most 8 players.).

  • sessionSize and minSessionSize at least 2 (400 session_too_small_for_player).

  • partySize strictly smaller than both sessionSize and minSessionSize (400 party_fills_session_for_player).

  • relaxAfterSeconds raised to at least 10 seconds (the value is raised silently and echoed back; no error).

  • No attributes until the operator raises the limit for your app (400 too_many_attributes with the limit is 0).

  • context at most 1024 bytes (400 context_too_large).

  • ticketId of letters, digits and _ . : - only (400 invalid_ticket_id: ticketId may contain only letters, digits, and _ . : - (1 to 100 characters). Omit it to have one generated.).

  • One queued ticket at a time (409 too_many_tickets).

  • 60 requests per minute per player and 6000 player requests per minute across the app, the same budgets as reservations.

A ticket from a player token cannot ask for a session smaller than 2 players (session_too_small_for_player), and its relaxAfterSeconds is raised to at least 10 seconds; the ticket response echoes the value in force. This stops a single client draining one dedicated server per ticket. If your game needs solo sessions started from the client, issue studio-signed player tokens and have the backend that mints them submit the ticket with a dsc_ token after its own check.

A player token's party can never fill a session on its own: partySize must be smaller than both sessionSize and minSessionSize (party_fills_session_for_player), so at least one other ticket is always needed before a server is committed. Full-party sessions (a lobby that IS the whole session) are submitted from your backend with a dsc_ token. Two tickets from the same player are never placed in the same match either, so a player cannot fill a session from their own tickets when the operator allows more than one.

The 409 for a second ticket is { "error": true, "reason": "too_many_tickets", "limit": 1, "active": 1, "message": "You already have 1 ticket in the queue. Cancel it with DELETE /v1/apps/{publicId}/tickets/{ticketId} or wait for it to match or expire, then retry." }.

A player owns its tickets. GET and DELETE on a ticket another player (or your backend) submitted answer the same 404 as an unknown or expired ticket, Unknown ticket (it may have expired). Submitting with a ticketId that belongs to a ticket the player cannot see answers 409 ticket_id_taken (That ticketId is already in use. Choose a fresh id (a UUID) or omit ticketId to have one generated, then retry.). Your backend's allocate token still sees and cancels every ticket in the app.

Poll GET .../tickets/{ticketId} every 2 seconds, the loop cadence. At 30 polls a minute a player uses half of its 60-per-minute budget; honour Retry-After on a 429. A 404 means the ticket expired: resubmit. When the ticket is matched, the client connects to ip/port and presents its ticketId and player id in your connect handshake; the server authorises against roster[].playerId (the token's sub) and roster[].ticketId from the delivered context. No reservation is created and the verify endpoint is not involved.

Tickets on an open-registration app are accepted like every other player call, but they never match: only fleet (agent-tier) servers are allocatable, and open apps have no fleet servers.

# From the game client with a player token (anonymous or studio-signed).
# 200 => queued; the echoed relaxAfterSeconds is the value in force (at least 10).
# 400 session_too_small_for_player / party_fills_session_for_player => the
#   client asked for a session it could fill alone; raise sessionSize.
# 409 too_many_tickets => cancel the previous ticket first.
curl -X POST https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/tickets \
  -H "Authorization: Bearer PLAYER_TOKEN_JWT" \
  -H "Content-Type: application/json" \
  -d '{"queue":"casual","sessionSize":8,"partySize":1,"minSessionSize":2,"relaxAfterSeconds":30}'

You should see: the submit answers 200 with ownerKind: "player", ownerPlayerId set to the token's sub, and relaxAfterSeconds: 30; a second submit from the same token while the first is queued answers 409 too_many_tickets.

Taking a whole Steam or Epic Online Services lobby into one ticket from the lobby leader's client is covered on Using Steam and EOS Lobbies with Discovery.

The desired-capacity hint

The autoscaler keeps each location's configured ready buffer warm on its own. When your matchmaker can see demand coming (queue depth, a scheduled event), tell the fleet ahead of time:

PUT /v1/apps/{publicId}/desired-capacity
{ "desiredReadyServers": 12 }

The hint is how many ready servers you want warm app-wide (0 to 100,000). The autoscaler splits it evenly across the fleet's member deployments (rounded up) and takes the larger of that share and each deployment's configured Ready Buffer, within each deployment's Max.

The hint is TTL'd (600 seconds by default; the response reports expiresIn). When your matchmaker goes quiet, the fleet falls back to the configured ready buffer instead of pinning stale demand forever. Write the hint every matchmaker tick, and write 0 explicitly when demand drops rather than just going silent, so a previous shortfall decays immediately.

curl -X PUT https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/desired-capacity \
  -H "Authorization: Bearer YOUR_ALLOCATE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"desiredReadyServers": 12}'

If you use the first-party ticket matchmaker, it maintains the hint automatically: every tick that sees queued tickets writes the number of formed matches it could not allocate (including an explicit 0 once capacity catches up). Ticks with an empty queue write nothing, so a hint written by your own tooling is never clobbered by an idle queue. Backfills are not counted as demand: they need no new server. The hint is a single number for the app and does not say where the demand is, so when latency-constrained tickets sit queued read the per-location counts in the workspace's Matchmaking card and raise Min on the deployment in the zone that is short. See Fleet Observability.

Running a third-party matchmaker

Directors and matchmakers you already run (an Open Match director, a custom lobby service) integrate the same way the first-party one does, using the same API:

  1. Form the match yourself, from your own queue.

  2. POST .../allocations with an idempotency key, your claims, and a location filter.

  3. Deliver ip/port to your players; the context blob reaches the server automatically.

  4. On 409 no_capacity, back off, retry with the same key, and raise the capacity hint.

Observe the pipeline end to end in your workspace: see Fleet Observability. The same page covers the three matchmaking read endpoints (GET .../matchmaking/queues, .../tickets, .../matches) your own tooling can call.

Troubleshooting

  • 400 relaxation_incomplete. You sent minSessionSize without relaxAfterSeconds or the other way round. Send both (the floor and the delay) or neither.

  • Tickets never relax. relaxAfterSeconds at or above the ticket TTL (300 seconds by default) is refused with relax_after_too_long; anything below it works, and the smaller session forms on the first 2-second tick after the OLDEST ticket has waited that long, so allow for the loop cadence. A player ticket's delay is never shorter than 10 seconds; the submit response shows the value in force.

  • Attribute tickets never match. One side lacks the attribute the other side has a rule on: a rule on skill never admits a ticket without skill, and both windows must admit each other. A player ticket carrying any attribute answers too_many_attributes with the limit is 0 until the operator raises the cap for your app; until then, submit rated tickets from your backend.

  • 400 no_location_within_ceiling at submit. Every measured location is above maxLatencyMs. Raise the ceiling, measure more locations, or omit it so the map becomes a preference.

  • A constrained ticket stays queued and nothing reports no_capacity. No ready server exists at any location the ticket accepts this tick. The workspace's Matchmaking card shows queued tickets per location, so you can see which zones are starved; raise maxLatencyMs on the client, or Min on the deployment in that zone.

  • Backfill never fires. Check, in order: the server is in_session (a ready server is not backfilled, it is allocated); the record's queue is the ticket's queue; the record's sessionSize, if set, equals the ticket's; the ticket carries joinInProgress: true; the record has not expired (republish every ttlSeconds / 2). If all of that holds, every seat is held by reservations or earlier backfills: compare openSeats with effectiveOpenSeats in the workspace.

  • Backfill matched but nobody arrived in-game. The server has no agent or runs an old supervisor, so the push never reached the game: the workspace shows "Delivered to agent: no". Read the joiners from GET .../joinable-session instead. A dash in that column means no backfill has been pushed yet.

  • Backfill matched but the joiner is gone from GET .../joinable-session. You polled slower than every 30 seconds: an entry is visible for 60 seconds after the match. Persist allocationIds from GET /allocations polling instead.

  • The same seat was sold twice. Your openSeats republish did not count the joiners already listed under backfills. expectedJoiners must include every not-yet-connected roster entry from the joinable-session GET or GET /allocations/{id}.

  • 400 session_too_small_for_player. A player-token ticket asked for sessionSize or minSessionSize below 2. Raise it, or submit solo sessions from your backend with a dsc_ token after your own check.

  • 400 party_fills_session_for_player. A player token's partySize equals sessionSize or minSessionSize. Lower the party, raise the session, or submit the full party from your backend.

  • 409 too_many_tickets. The player already has a queued ticket (1 by default). Cancel it with DELETE .../tickets/{ticketId} or wait for it to match or expire; a matched ticket frees the slot.