Admitting Players
What a client sends your game server when it connects, how the game server decides whether to let it in for reservations, matches, backfill and LAN games, and every reason it can refuse.
This page is what your game server must do to decide whether each connection may take a seat, on any engine. The client sends a join ticket over your own netcode, the game server decides, and a refused client gets a reject reason back.
The join ticket's schema and example payloads are in the contracts/handshake folder of the Unity SDK repository. The Unity SDK implements everything here; see The Game Server SDK.
How a player gets to a seat
The game client gets a player token from Discovery, anonymous or signed by your studio (see Choosing How Players Connect).
The client asks Discovery for a game server: quick join, a reservation on a game server the player picked, or a matchmaking ticket.
Discovery answers with an address and a port, plus the reservation or the match that holds the player's place.
The client connects to that address and sends a join ticket in its first connection message.
The game server checks the join ticket against its evidence: the hold, the match roster or a backfill.
The game server gives the player a seat in its session.
When the session is over, the game server ends it and is ready for the next one.
This page covers steps 4 to 6. The others are on Reservations and Quick-Join, Matchmaking Integration and Making Your Game Fleet-Ready.
The join ticket
The client sends one JSON object in the first message of its connection. It is not a matchmaking ticket.
{ "v": 1, "kind": "match", "protocolVersion": 3,
"playerId": "anon:0b84e1c9-437c-4962-ab0a-7ffdd2123607",
"ticketId": "q3Vx9mKc0TzR1bN4wYp7Lg",
"allocationId": "9b2f6c1e-4d7a-4f0e-8a3b-2c5d7e9f1a40",
"displayName": "Bob" }v (always): the payload format version. Only
1exists.kind (always):
reservation,match,backfillorlan.protocolVersion (always): your game's own integer network protocol version, 0 or more. Not a build version.
playerId (always): the player id from the player token, for example
anon:<uuid>. 1 to 100 characters.reservationId (for
reservation): the reservation the player holds. 1 to 100 characters.ticketId (for
matchandbackfill): the matchmaking ticket the player's party submitted. 1 to 100 characters.allocationId (for
matchandbackfill): the match the ticket was placed in. 1 to 100 characters.displayName (optional): a name to show other players, at most 32 characters. Treat it as untrusted text.
The kind says how the player got here: reservation after quick join or a reservation, match after a matchmaking ticket placed in a new session (or a backend allocation whose id your backend handed out), backfill after a ticket submitted with joinInProgress was placed in a running session, and lan for a direct connection to a listen host started LAN only.
Decode it the same way on every engine:
The payload is at most 1024 bytes of UTF-8, so it fits in one transport packet.
It is strict UTF-8 and strict JSON: exactly one object, no comments, no duplicate keys and no unknown keys. String lengths count characters (code points), not bytes.
Check
vbefore any other field, so a client from a later version is toldunsupported_versioninstead of being told its payload is malformed.
Ticket ids are secrets
The game server admits a matched player on the ticketId alone, so it must be impossible to guess:
Mint it in the client as 128 random bits (22 characters of base64url), and never let game code choose it.
Never write it to a log, an analytics event, a crash report or the screen.
To match a join across client and game server logs, log its ticket ref: the first 12 lower-case hex characters of the SHA-256 of the
ticketId(UTF-8).Besides the client and the matched game server, brand members with
discovery.viewsee queued ticket ids under Queued tickets on the Discovery app's Matchmaking card. Grant that permission only to people you trust with them.
Make reservation ids unguessable too, because whoever holds the id of a hold that names no players can take one of its seats.
The decision
Check every connection in this order:
The payload decodes.
protocolVersionequals your game's, elseprotocol_mismatch.The game server is not shutting down, else
stopping.The rule for your hosting mode and the ticket's kind, below. Within it: the evidence first, then one live connection per
playerId(duplicate_player), then the seat count.Your game's own rule, for example whether a session is open and a seat is free.
Choose the hosting mode once at startup, from what the process finds (the local SDK endpoint answers, a heartbeat token was supplied, or the host was started LAN only), never from anything a client sends. Missing evidence, or evidence of the wrong kind for the mode, refuses the join as reservation_unverifiable.
The whole decision, a session claim included, must finish within 10 seconds, else approval_timeout. Hold the connection pending meanwhile, and set your transport's pending-connection timeout to 15 seconds or more: Netcode for GameObjects drops a pending client after 10 seconds by default.
Hosted on a fleet
A hosted game server reads all of its evidence from the local SDK endpoint and never calls Discovery.
reservation: read the hold, looking again for up to 2 seconds if it has not arrived yet.
Unknown, released or expired:
reservation_invalid.The endpoint does not answer:
reservation_unverifiable.A hold whose
playerIdslists players admits only those players.A hold whose
playerIdsisnulladmits up toseatsplayers, thenroster_full.A hold on an idle game server is accepted too (see Solo play on an idle game server).
match: the
allocationIdmust equal the game server's current allocation, elseallocation_mismatch(also when there is none).A matchmaker match (its context has a roster): the
ticketIdmust be in the roster, matched on the ticket id alone, elsenot_in_roster. Each ticket then admits up to itspartySizeplayers, first come, thenroster_full.A backend allocation without a roster (its context parsed, and has no
rosteror an empty one, and is not from the matchmaker): admit on theallocationIdalone, up to theplayerscounter's capacity (your game's own player limit when that capacity is 0), thenroster_full.A matchmaker match with an empty roster, a context that does not parse, and a self-allocation (id
self-<ms>) are never treated as rosterless. Everymatchticket for them isnot_in_roster.
backfill: there must be a delivered backfill with this
allocationIdwhosesessionIdis the current allocation. Wait up to 5 seconds for it, elsebackfill_unknown. Then apply the roster and party checks of a matchmaker match.lan:
kind_not_accepted.
Self-hosted dedicated game server
A self-hosted game server is never given a match. It checks reservations with Discovery's verify call and its own heartbeat-scope token.
reservation: call verify with your own
serverIdand the joiningplayerId.valid: false,wrong_serverornot_in_reservation:reservation_invalid.No answer, or any status other than 200 (429 and 503 included):
reservation_unverifiable.A detailed answer is valid only when its
serverIdis yours and itsplayerIdsisnullor contains the player. One that names players admits only those; one that names none admits up to itsseats, thenroster_full.A verdict-only answer (what an open app's token gets) admits once per player. Discovery counts the seats.
match, backfill and lan:
kind_not_accepted.
Listen host
Started online: the same rules as a self-hosted dedicated game server.
Started LAN only: a
lanticket is admitted. Every other kind iskind_not_accepted, because the host cannot verify.
Let a listen host's own local player in at once, without a join ticket.
Your game's own rule
After the hosting-mode rule accepts, your game decides whether there is a session to join and a seat in it. It answers only not_in_session, server_full or refused_by_game. Send refused_by_game for any other answer, and when your rule or the evidence lookup fails.
Take the seat in the same step that reads the seat count, so two pending connections cannot both take the last one.
Solo play on an idle game server
A lone player gets a game through quick join, which can hold a seat on an idle hosted game server. A hold does not stop the matchmaker giving that game server to a match while the player is in it. So a game that wants solo play claims a session first; a game that does not answers not_in_session while no session is open.
When your game claims, a hosted reservation join that the rules above and your own rule accepted on an idle game server is approved only after the claim:
Hold the player's seat, as for any accepted join.
Self-allocate through the local SDK endpoint, and wait for the new allocation to show on the game server. Players joining at the same moment share one request. Send nothing while an allocation is current, because yours would replace it. After a request whose answer was lost, or whose allocation did not show within 2 seconds, send no second request until an allocation shows or 10 seconds pass.
Approve the connection on the hold. The self-allocation's id is guessable, so it never admits a
matchjoin.
If a match became current first, nothing was claimed: ask your own rule again with that match as the current allocation. If the claim fails, refuse with refused_by_game; if the deadline passes or the connection closes, approval_timeout; if the game server begins shutting down, stopping. Every refusal gives the seat back.
Your game must end every session it claims, by ending the session or recycling. Until then the game server is never given a match. End a claimed session nobody joined too, because a failed or late claim can still land after the player was refused. A short lobby timeout covers it.
Two overlaps cannot be prevented: a match that lands while the claim is in flight is replaced (its players are refused allocation_mismatch), and one that lands after it replaces the claim, leaving the solo player with no session.
Where the evidence comes from
The hold: read it from the local SDK endpoint by its reservation id. A hold past its
expiresAt, or one the endpoint does not know, is unknown, released or expired. See Reservations and Quick-Join.The current allocation and its roster: the allocation the local SDK endpoint reports for this game server. A matchmaker match's context carries
matchmaker: trueand arosterof{ticketId, partySize, playerId, attributes, context}entries. See Matchmaking Integration.Backfills: the backfills the local SDK endpoint delivered. Each carries
allocationId,sessionId(the allocation it joins) andcontext.roster.Verify on a self-hosted game server: Discovery's verify call, with the game server's heartbeat-scope token. See Reservations and Quick-Join.
Reading evidence never counts as a write to the SDK. Claiming a session does, so a game that claims sessions must also call ready (see When your game counts as integrated).
Seat counting
Keep a ledger of who you let in, so one hold, ticket or allocation never brings in more players than the limits above. Take a seat when you approve a connection, and give it back when the player disconnects or the approval cannot be delivered. One playerId holds one live connection; a reconnect after a disconnect is fine.
A party ticket's roster entry carries only the submitter's playerId. The submitter shares the ticket id with the party, each member connects with their own playerId, and the ticket admits up to partySize of them.
On an allocation without a roster, the allocationId is the only thing that keeps a stranger out. If your backend allocates without a roster, mint the id with at least 128 random bits, or leave it out so Discovery mints a UUID.
Reject reasons
The game server sends the reason to the client exactly as written here, and the client turns it into a message for the player.
The connection carried no join ticket (
payload_empty). Set the payload before the client connects.The payload is over 1024 bytes (
payload_too_large). Keep every field within its limit; check the client's encoder.The payload is not strict UTF-8, not one JSON object, or repeats a key (
payload_malformed). Fix the client's encoder.The payload breaks the schema: a field is missing, extra, of the wrong type or too long (
payload_invalid). Check the client's encoder against the example payloads.The client sent a
vother than 1 (unsupported_version). Ship the game server update.The client's
protocolVersionis not the game server's (protocol_mismatch). The player runs another version of the game. Give each protocol its own matchmaking queue.The game server is shutting down (
stopping). Find another game server.This hosting mode never accepts the ticket's kind, such as a
matchticket sent to a self-hosted game server (kind_not_accepted). Check the client's join path.The hold is unknown, released, expired, on another game server, or names other players (
reservation_invalid). Most often it expired before the player connected. Reserve again.The evidence could not be read, or was the wrong kind for the hosting mode (
reservation_unverifiable). Retry. If it repeats on a hosted game server, check that the Agones-compatible SDK switch is on in the fleet's Agent Settings.The ticket names another allocation than the game server's current one, or there is none (
allocation_mismatch). The match moved on or ended. Find a new match.The
ticketIdis not in the match's or backfill's roster (not_in_roster). Find a new match. If every player gets it, check that the client sends the ticket id it submitted.The ticket, the hold or the allocation has no seat left for this player (
roster_full). The party has more players than it was matched or reserved for.No delivered backfill with this
allocationIdjoins the current session, even after 5 seconds (backfill_unknown). Find a new match.This
playerIdalready has a live connection (duplicate_player). Often a second copy of the game is running; close it.The decision did not finish within 10 seconds, or the connection closed first (
approval_timeout). Retry. If it repeats, check that your transport keeps a pending connection for at least 15 seconds.Your game's rule found no open session to join (
not_in_session). Try quick play or find a match.Your game's rule found every seat taken (
server_full). Pick another game server.Your game's rule refused, or the rule or the evidence lookup failed (
refused_by_game). The game server's log has the detail.
What the join ticket never carries
Never put a player token, a Discovery token or any other credential in the join ticket, or anything secret in displayName: anything on the path can read and replay the payload.
A successful check says this playerId may take a seat. It does not prove who the player is, because an anonymous player id is whatever the client says. For ranked or paid play, use studio-signed player tokens and authenticate the player on your own channel as well.
Checking your implementation
Run the payloads in contracts/handshake/examples/ through your decoder (each must decode to its kind), and the one in contracts/handshake/examples/invalid/ (it must be refused).