Let Players Join: Reservations and Quick-Join
Hold seats on a listed server before players connect: party-atomic reservations, quick-join, and server-side join verification.
Deciding whether players should call these endpoints directly, or through your backend? Read Choosing How Players Connect first. It compares the three options and shows the setup for each.
A server list alone has a race: ten players see "1 slot free", all ten connect, nine get bounced. Seat reservations close it by holding seats on a chosen server before the player connects, atomically for the whole party, so a group of three either all get in or nobody does. The same reservation doubles as join authorization: the player presents their reservation id in the connect handshake and the game server verifies it with one HTTP call.
Reservations work on both tiers, fleet servers and heartbeat-only community servers alike. They require no server-side code: the hold expires on its own, and the verify call is optional.
The examples on this page call the endpoints from your backend with a token holding the Allocate (or Both) scope, issued on your Discovery app page. Allocate-scope tokens are secrets, so never ship them in a game client. The examples use https://discovery.pingcore.io; your app page shows your exact base URL. Reservation calls draw on their own per-app budget (600 requests per minute by default), separate from the allocation budget, and the verify endpoint shares it.
Who can call this
Reserve, quick-join, GET, and DELETE accept two kinds of bearer:
An Allocate-scope
dsc_token from your backend. Full access, budgeted per app.A player token from the game client: a short-lived JWT your login service signs, or an anonymous token Discovery issues on request. Player tokens can reserve, quick-join, and read or release their own reservations; they can also submit matchmaking tickets (see Matchmaking Integration). They cannot allocate or read fleet state, and the caps are lower: 60 requests per minute per player, 2 active reservations per player, at most 8 seats per reservation, and
contextcapped at 1024 bytes, all by default. A player only sees and releases their own holds. Setup and client snippets are on Choosing How Players Connect.
Verify only ever accepts the game server's Heartbeat-scope token. A player token presented there is an unknown credential and answers 401.
Reserve seats on a chosen server
curl -X POST "https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/servers/203.0.113.9:27015/reservations" \
-H "Authorization: Bearer dsc_YOUR_ALLOCATE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"reservationId": "party-83a1",
"seats": 3,
"playerIds": ["steam:1", "steam:2", "steam:3"],
"ttlSeconds": 60,
"context": { "party": "the crew" }
}'The {serverId} path segment is the id from the public list (ip:port by default, or whatever the server advertised).
reservationId (optional, 1 to 100 characters, letters, digits,
_,.,:,-): your idempotency anchor. The same id replays the original reservation instead of holding seats twice (for 10 minutes by default), so a network retry is safe. Omit it and every call is a fresh hold.seats (optional, default 1): how many seats to hold. Capped per reservation (100 by default).
playerIds (optional): who the seats are for. When present, the array must have exactly
seatsentries.ttlSeconds (optional, default 60, minimum 5, maximum 300): how long the hold lives.
context (optional): an opaque JSON blob stored on the reservation and returned by the verify endpoint. Capped at 8192 bytes.
You should see:
{
"error": false,
"reservationId": "party-83a1",
"serverId": "203.0.113.9:27015",
"status": "pending",
"seats": 3,
"ip": "203.0.113.9",
"port": 27015,
"createdAt": 1756750000000,
"expiresAt": 1756750060000,
"ownerKind": "backend",
"ownerPlayerId": null,
"expiresIn": 60,
"replayed": false
}ownerKind is backend for a dsc_ caller and player for a player token, in which case ownerPlayerId carries the token's sub. Hand ip and port to the party and have them connect before expiresIn runs out. A replayed response (same reservationId) has the identical shape with replayed: true, and expiresIn is always the seconds actually remaining on the hold.
A server that cannot seat the whole party answers 409 with reason: "no_seats" and available, the number of seats currently free. The hold is all or nothing: a party of 3 against 2 free seats holds nothing. A serverId that does not exist, is hidden from the public list, or is malformed answers the same 404 in all three cases, so the reservation API never confirms a server the list hides.
Releasing a hold
Availability on a server is maxPlayers minus the advertised players minus the seats on active holds. There is no join detection and no confirmation protocol your game server must implement: TTL expiry is the release mechanism. A party that never connects leaves nothing to clean up. To release earlier, delete the reservation:
GET /v1/apps/{publicId}/reservations/{reservationId}returns the reservation while the hold is live (reservationId,serverId,status,seats,playerIds,createdAt,expiresAt,ownerKind,ownerPlayerId) and 404 once it expired or was released.DELETE /v1/apps/{publicId}/reservations/{reservationId}releases the seats early. It is idempotent: releasing an unknown or already-released reservation still answers 200.
One consequence to plan for: once reserved players actually join, the server's reported players rises while the hold may still be live, so those seats are briefly counted twice. This under-admits and never over-admits, and it heals within one TTL, but it is a reason to keep holds short. Reserve when the player commits to joining, not when they open the browser.
Quick-join
When the player wants a game rather than a specific server, quick-join filters the visible servers, picks the most populated one the party still fits on (players want lively servers), and reserves atomically in one call. If the pick fills up in the race, it falls to the next candidate.
curl -X POST "https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/quick-join" \
-H "Authorization: Bearer dsc_YOUR_ALLOCATE_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"idempotencyKey": "join-77",
"seats": 2,
"playerIds": ["steam:1", "steam:2"],
"filters": { "version": "1.0.0", "meta": { "map": "coastline", "xp": { "gt": 10 } } }
}'idempotencyKey (required, 1 to 100 characters): replays like
reservationIdon the reserve endpoint.filters (optional): the same model as the public server list.
versionis an exact match; a scalarmetavalue is an exact match; an object form carries the list's typed operators, so{ "xp": { "gt": 10 } }in the body is?meta.xp[gt]=10on the list. Unknown operators are ignored, matching the list's rule.seats, playerIds, context: same semantics as the reserve endpoint. There is no
ttlSecondsfield; quick-join holds always use the default TTL (60 seconds).latency (optional): the client's measured round trip per location,
{ "eu-west-ams": 24, "us-east-nyc": 96 }, up to 32 entries by default with integer values from 0 to 10000. How to measure it is on Latency and Location Targeting.maxLatencyMs (optional, 1 to 10000): the highest latency the party accepts. Needs a
latencymap; sending it alone answers 400max_latency_without_map.
The response is a reservation, same shape as above, with the chosen server's serverId, ip, and port. Only servers the public list would show are candidates. When no visible server fits the party, the answer is 409 with reason: "no_seats" (no available field, since there is no single server to count).
Prefer quick-join for a "Play now" button; use browse plus reserve when the player picked a server from your list UI.
Quick-join by latency
With a latency map, candidates are ranked by your measurement to each server's meta.location in 25 millisecond buckets, nearest bucket first, then by player count descending within a bucket, so servers with equivalent latency still favour the livelier one. Servers with no location, or a location you did not measure, rank last. Without a map the ranking is unchanged.
maxLatencyMs turns the map into a hard filter: only servers whose location you measured at or below the ceiling are candidates, and servers with no location or no measurement are excluded. When nothing under the ceiling has room, the answer is the same 409 no_seats.
curl -X POST "https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/quick-join" \
-H "Authorization: Bearer PLAYER_TOKEN_JWT" \
-H "Content-Type: application/json" \
-d '{
"idempotencyKey": "join-78",
"seats": 2,
"latency": { "eu-west-ams": 24, "eu-west-fra": 31, "us-east-nyc": 96 },
"maxLatencyMs": 80
}'// Unity (.NET Standard 2.1) or any .NET client. latency comes from the
// measurement code on the Latency and Location Targeting page.
public static async Task<JsonDocument> QuickJoinNearbyAsync(
string playerToken, int seats, Dictionary<string, int> latency, int maxLatencyMs)
{
// Reuse the same idempotencyKey when you retry one join, so a retry replays instead of holding twice.
var body = JsonSerializer.Serialize(new
{
idempotencyKey = Guid.NewGuid().ToString("N"),
seats,
latency, // { "eu-west-ams": 24, ... }; omit unreachable beacons rather than sending 0
maxLatencyMs, // omit to keep far servers as a fallback
});
using var request = new HttpRequestMessage(HttpMethod.Post,
"https://discovery.pingcore.io/v1/apps/dscp_YOUR_PUBLIC_ID/quick-join");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", playerToken);
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
using var response = await Http.SendAsync(request);
var json = await response.Content.ReadAsStringAsync();
if ((int)response.StatusCode == 409)
{
// no_seats: no visible server under the ceiling has room. Raise maxLatencyMs or drop it.
return null;
}
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"quick-join failed ({(int)response.StatusCode}): {json}");
}
return JsonDocument.Parse(json); // read ip, port, reservationId, expiresIn
}You should see: a reservation on a server whose meta.location you measured at 80 ms or below, preferring the nearest 25 ms bucket and, within it, the fullest server that still seats the party.
The public server list takes the same measurements as query parameters, latency.<id>=<ms> for each location, with an optional maxLatencyMs filter and sort=latency to order the list by your own measurement. You should see: with sort=latency the first server's meta.location is the location with your lowest measurement. The parameters are described on Latency and Location Targeting.
Verify on the game server
Verification is how the game server authorizes a join. The player presents their reservationId in your connect handshake, and the server checks it with its own Heartbeat-scope token (the same credential it sends heartbeats with). The pattern:
Player connects and sends
reservationIdin the handshake.The server calls verify and checks
validandserverId.Admit if
validis true andserverIdis its own id; otherwise reject.
curl "https://discovery.pingcore.io/v1/reservations/verify/party-83a1" \
-H "Authorization: Bearer dsc_YOUR_HEARTBEAT_TOKEN"You should see:
{
"error": false,
"valid": true,
"reservationId": "party-83a1",
"serverId": "203.0.113.9:27015",
"seats": 3,
"playerIds": ["steam:1", "steam:2", "steam:3"],
"context": { "party": "the crew" },
"expiresAt": 1756750060000,
"ownerKind": "backend",
"ownerPlayerId": null
}Verify always answers 200 with a valid flag. Unknown, expired, released, and malformed ids all read { "valid": false }, so your server has one code path. Compare serverId against your own id; a valid reservation for a different server is still a rejection. Verification is optional: a server that ignores reservations entirely still benefits, because the browser stops overcommitting it.
Troubleshooting
409 with
reason: "no_seats"on reserve. The party does not fit the server's current availability (maxPlayersminusplayersminus active holds);availablereports the free seats. Pick another server, reserve fewer seats, or use quick-join to find one that fits.404 on reserve. The server dropped off the list (heartbeats stopped and its record expired), it is hidden (failed reachability verification), or the serverId is malformed. All three answer the same 404 on purpose. Check the public list for the app; if the server is missing there, fix its heartbeat or verification first.
Verify returns
valid: falsefor a reservation you just made. Most often the hold expired before the player connected: reserve closer to the actual connect, or raisettlSeconds(up to 300). Also check that the game server's token belongs to the same Discovery app the reservation was made in; reservations are scoped per app, so another app's token reads every id as invalid.401 or 403 on a reservation endpoint. 401 means the token is unknown or malformed. 403 means the token is known but wrong for the route: reserve, quick-join, GET, and DELETE need the Allocate scope, while verify needs the Heartbeat scope. Issue a token with the right scope on the app page, or use one with the Both scope. A 401 or 403 whose body carries a
reasonfield came from a player token; each reason is explained under Errors you may see.409 with
reason: "reservation_id_taken"on reserve or quick-join. ThereservationId(or quick-joinidempotencyKey) is already held by a reservation the caller may not see, so it cannot be replayed. This only happens to player-token callers, since a backend token sees every reservation in its app. Use a UUID per hold.400
max_latency_without_mapon quick-join.maxLatencyMswas sent without alatencymap. Measure the beacons first (Latency and Location Targeting) or omit the ceiling.Quick-join with a latency map answers 409
no_seatsalthough servers are listed. UndermaxLatencyMs, servers whose location you did not measure or measured above the ceiling are not candidates. Check the listed servers'meta.locationagainst your map, raise the ceiling, or drop it so the map only ranks.A reserve call "succeeds" but the seats or TTL are not what you sent. The response carries
replayed: true: you reused areservationId, so the original reservation was returned instead of a new hold, withexpiresInshowing the seconds it actually has left. Use a fresh id per hold, or omitreservationIdwhen you do not need retry safety.