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: game servers PingCore hosts on a fleet, and self-hosted game servers that send heartbeats. They require no server-side code: the hold expires on its own, and checking the reservation when the player connects is optional. A hosted game server checks it on its local SDK endpoint, and a self-hosted one calls the verify endpoint; both are described under Check the reservation on the game server.
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, the check a self-hosted game server makes, accepts only the game server's own token with the Heartbeat or Both scope. A player token presented there is an unknown credential and answers 401, and an Allocate-only token answers 403. Hosted game servers never call verify and hold no Discovery token.
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 for the game server. A hosted game server reads it on its local SDK endpoint. Verify returns it only to a token that does not ship in the game, and only when the call names the game server with
serverId. 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.
Check the reservation on the game server
The player presents their reservationId in your connect handshake, and the game server checks it before seating them. Checking is optional: a game server that ignores reservations still benefits, because the list stops overcommitting it. How the game server checks depends on who runs it:
Hosted game servers (PingCore runs them on a fleet) read the reservation from the local SDK endpoint inside their container. No Discovery token is involved.
Self-hosted game servers (the heartbeat tier, including listen hosts in a client-hosted game) call the Discovery verify endpoint with their own token.
Hosted game servers: the local SDK endpoint
Discovery sends each hold on a hosted game server to that game server as soon as it is placed, and removes it when it is released or expires. Your game reads the holds from the local SDK endpoint, which listens on 127.0.0.1 inside the container on the port in the AGONES_SDK_HTTP_PORT environment variable. The port is 9358 unless you set SDK port in the fleet's Agent Settings card. The endpoint exists only while the Agones-compatible SDK switch in that card is on; with it off, the variable is not set.
curl "http://127.0.0.1:${AGONES_SDK_HTTP_PORT}/pingcore/reservations/party-83a1"You should see:
{
"reservationId": "party-83a1",
"serverId": "4211",
"seats": 3,
"playerIds": ["steam:1", "steam:2", "steam:3"],
"context": { "party": "the crew" },
"expiresAt": 1756750060000,
"ownerKind": "backend",
"ownerPlayerId": null
}Admit the player when
playerIdscontains the id they present. A hold withplayerIds: nullnames no players: apply your game's own rule for open seats and admit at mostseatsplayers. Your game counts those seats itself.ownerKindisplayerwhen a game client made the hold with a player token, andownerPlayerIdis then that player's id. For a signed player token, compare it with the identity of the connecting player.A 404 with
{ "message": "reservation not found" }means the id is unknown on this game server, was released, or expired. Refuse the join. A player who connects straight after reserving can arrive a moment before the hold does, so retry the lookup a few times over two or three seconds before you refuse.GET http://127.0.0.1:${AGONES_SDK_HTTP_PORT}/pingcore/reservationsanswers{ "reservations": [...] }: every live hold on this game server, with the same fields, soonest to expire first. The list is empty until the game server has connected to Discovery, and after a reconnect the holds can take a few seconds to arrive again.
Both routes are reads, so calling them does not mark your game as SDK-integrated and does not change how it reports readiness (see Making Your Game Fleet-Ready).
Self-hosted game servers: verify
A self-hosted game server calls verify with its own Heartbeat-scope (or Both-scope) token, naming itself and the joining player:
curl "https://discovery.pingcore.io/v1/reservations/verify/party-83a1?serverId=203.0.113.9:27015&playerId=steam:2" \
-H "Authorization: Bearer dsc_YOUR_HEARTBEAT_TOKEN"serverId: the id this game server appears under on the list. Its heartbeat response returns it.
playerId: the player who is connecting. This is an id in the hold's
playerIds, or, for a hold a player made with a player token, that token'ssub. 1 to 128 characters.
Verify always answers 200 with a valid flag. Unknown, expired, released, and malformed ids all read { "valid": false }, so your game server has one code path. What else the answer carries depends on the token.
Open apps: the token in your game build
On an app with Open registration, the Heartbeat token ships inside the game, so anyone can extract it. Verify called with that token answers only this:
{ "error": false, "valid": true }or { "error": false, "valid": false }. There is never a reason and never a detail field, so the game server cannot read seats, playerIds, or context. Both serverId and playerId are required: a call that leaves either out reads { "valid": false } for every join.
Because the game server cannot see the hold, Discovery decides the seat:
A hold with
playerIdsadmits exactly the listed players.A hold without
playerIdsthat a player made with a player token always admits its owner, and the owner takes one of theseats. Other players take the remaining seats, first come, first served.A hold without
playerIdsthat your backend made seats anyseatsplayers, first come, first served.A player already admitted is admitted again on a later verify, so a reconnect works. Two players racing for the last seat cannot both get it.
Two limits come with this:
An admitted seat stays taken until the hold is released or expires. A player leaving your game server does not give it back, and anyone holding the
reservationIdcan take a free seat on a hold withoutplayerIdswithout ever joining. Name the players withplayerIdswhenever you know the party, and use unguessable reservation ids (a UUID, never a sequence or a player name).valid: truedoes not prove who the player is. It says theplayerIdyou sent may take a seat. Your game server must authenticate the player itself (for example from a studio-signed player token) and refuse a second live connection with the same id. The owner's id is admitted even after the hold is full.
Private apps and Both tokens: the detailed answer
A Heartbeat token on a Private app, or a Both token on any app, gets the hold's details. A Both token is a secret, so never put it in a game build. The curl call is the same as above, and 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
}{ "valid": false, "reason": "wrong_server" }means the hold is live, but on another game server of the app. No other field is returned.{ "valid": false, "reason": "not_in_reservation" }means you sentplayerId, the hold is live on this game server, and itsplayerIdsdo not list that player. No other field is returned.playerIdis optional for these tokens. A hold withoutplayerIdsnames every player, and Discovery counts no seats for you: readseatsand count the players you admit.contextis returned when you sendserverId, and isnullwhen the hold has none.Calls without
serverIdare deprecated. Until Discovery 1.6.0 they still answer with the hold's details for whichever game server holds it, withoutcontext, and carry the response headerDeprecation: @1790640000. Discovery 1.6.0 removes that form, so sendserverIdnow.
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.Verify returns
valid: falsefor every join on an open app. The call is missingserverIdorplayerId. With the token that ships in your game, both are required, and a call without either is refused with the same{ "valid": false }as any other refusal. Also check thatserverIdis the id from this game server's heartbeat response and thatplayerIdis an id in the hold'splayerIds(or, for a hold a player made, that player's tokensub).Verify on an open app returns
valid: falsefor a party member although the hold has free seats. The hold has noplayerIdsand its seats were already taken, either by earlier joiners or by someone else who holds thereservationId. An admitted seat is not given back until the hold is released or expires. SendplayerIdswhen you reserve for a known party.Verify answers
reason: "wrong_server". The hold is live on another game server of the app. The player connected to the wrong address, orserverIdis not this game server's own id. Refuse the join.Verify answers
reason: "not_in_reservation". The hold is live on this game server, but itsplayerIdsdo not list theplayerIdyou sent. Refuse the join, or check that you send the same id format the hold was made with (steam:1, not1).Verify answers carry a
Deprecationheader and nocontext. The call left outserverId. Add?serverId=with this game server's own id; the call without it stops working in Discovery 1.6.0.The local SDK endpoint answers 404 for a reservation the player presents. On a hosted game server, the hold may not have arrived yet (the player connected straight after reserving, or the game server just reconnected to Discovery), or it was released or expired. Retry for two or three seconds before you refuse. If it never appears, check that the reservation was made on this game server's
serverIdin the fleet's Discovery app.Nothing answers on
127.0.0.1:$AGONES_SDK_HTTP_PORT. The Agones-compatible SDK switch in the fleet's Agent Settings card is off, so the local SDK endpoint is not running and the variable is not set. Turn it on; the change applies at each game server's next container restart.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 (an Allocate-only token answers 403 there). 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.