Making Your Game Fleet-Ready

The session-based server contract: lifecycle states, the Agones-compatible SDK and its native extensions, allocation and backfill delivery, reservations, counters, and the zero-integration fallback.

This page covers what your game server binary needs to do to take part in matchmaking. A fleet server used for matchmaking runs a session-based loop: boot idle, signal ready, receive an allocation from a matchmaker, run the session, end it, and become ready again (or call Shutdown() and the platform recycles the server in place to a fresh ready state). If you only use your fleet to list servers, you may need nothing from this page: readiness and player counts work with zero game integration (see tier 3 below).

The platform side is automatic. Every fleet server runs an agent that holds a persistent connection to the Discovery service and reports the server's state; you never write heartbeat or connection code. Your game talks to a local SDK endpoint inside its own container, and the agent translates that into fleet state.

Lifecycle states

Discovery tracks every fleet server in one of four states:

  • ready: idle and allocatable. This is the warm buffer matchmakers draw from.

  • allocated: a matchmaker claimed the server for a match (a short-lived transition state).

  • in_session: a session is running. Still allocatable for backfill if its counters have spare capacity.

  • draining: out of rotation. This covers booting, unhealthy, retiring for a release, and shutting down. A draining game server is never allocated, is not on the public server list, cannot take a reservation, and is never picked by quick-join. It still shows on fleet state with its reason, and returns to the list on its own once it reports ready again.

Only fleet servers (tier: agent on the wire) can be allocated. Externally hosted servers reporting through the heartbeat API never receive allocations.

Every hosted container exposes a local REST endpoint compatible with the Agones SDK's HTTP surface, whether or not the server is currently in a fleet. The stock Agones SDKs for Unity and Unreal call this REST interface, so most of their calls work with no code changes, and any language can call it with plain HTTP. What differs from Agones is listed under The stock Agones SDKs.

Your game process finds the endpoint through the AGONES_SDK_HTTP_PORT environment variable. The port is 9358 by default; the SDK port field in the fleet's Agent Settings card overrides it, and the variable always carries the port actually in use. Apart from the two cases below, the endpoint runs on every hosted server, fleet member or not, so you can integrate the SDK unconditionally and ship one build; a server that later joins a fleet needs no changes. The endpoint binds 127.0.0.1 only; it is the game's private sidecar surface, never reachable from outside the container.

The variable is not set in two cases, and then there is no SDK endpoint at all:

  • The fleet's Agones-compatible SDK switch is off.

  • The endpoint could not start when the container booted (for example, the port was already taken).

Read the variable at startup. When it is missing, skip the SDK calls and run without them, rather than falling back to 9358; nothing is listening there.

The Agones routes

  • POST /ready: Ready(). Call once your server is initialized and able to accept a match.

  • POST /health: health pings. Accepted and answered {}; the platform runs its own health checks.

  • POST /shutdown: Shutdown(). Marks the server draining so it stops receiving allocations, and on a fleet member the platform then restarts the game process so the server comes back as a fresh ready instance. The new process starts in state Scheduled. The counter values, labels and annotations the old process set are cleared, and the new process must call Ready() again. The recycle is one-shot per shutdown request: a second Shutdown() during the restart is ignored. On a server that is not a fleet member, nothing restarts. A game that simply exits (with or without calling Shutdown()) is also rebooted automatically, within about 5 minutes.

  • POST /allocate: Allocate(). Self-allocation, for games that claim themselves. The server reads in_session straight away.

  • GET /gameserver: the current GameServer view (object_meta with name, labels and annotations; status with state, address, ports and counters). Counter values are strings, as in Agones. status.state is Scheduled until your game calls Ready(), then Ready, Allocated while a session runs, and Shutdown from Shutdown() until a new game process starts. It shows what your game asked for: a game server that is retiring for a release still reads Ready, although Discovery treats it as draining.

  • GET /watch/gameserver: a newline-delimited stream of {"result": <GameServer>} objects, the first one at once and then one on every change. This is how allocations reach your game.

  • GET /v1beta1/counters/{name} and PATCH /v1beta1/counters/{name}: the Counters surface. PATCH accepts count, countDiff, and capacity as numbers or numeric strings, and answers the counter after the update. Send whole numbers. A non-numeric value answers 400. GET answers 404 for a counter the fleet does not declare and the game never set. A capacity your game never set reads as the fleet's. Unlike Agones, an increment past capacity is not refused: the local count goes past it, and the game server reports the capacity to Discovery as its count.

  • PUT /metadata/label and PUT /metadata/annotation: SetLabel() and SetAnnotation(). See Labels and annotations.

Native routes

These are PingCore extensions, not part of Agones:

  • POST /v1/sessions/{allocationId}/ended: tells the platform the session is over so the server returns to ready.

  • POST /v1/sessions/{allocationId}/joinable and DELETE /v1/sessions/{allocationId}/joinable: publish or withdraw the open seats of a running session for backfill. See Joinable sessions.

  • GET /v1/backfills: the players the matchmaker placed into the running session. See Joinable sessions.

  • GET /pingcore/reservations and GET /pingcore/reservations/{reservationId}: the seat holds players placed on this game server. See Reservations.

Routes that answer but do nothing

The stock Agones SDKs call a few routes the fleet model has no use for. They answer in the Agones shape so the SDK does not error:

  • POST /reserve: answers {}. There is no Reserved state. Player seat holds are read from /pingcore/reservations instead.

  • The alpha player-tracking routes under /alpha/player/: connect and disconnect answer {"bool":true}, so a game that gates admission on the answer does not reject every player. Capacity writes answer {}. The reads report nothing tracked ({"count":"0"}, {"list":[]}, {"bool":false}). Report player counts with the players counter instead (PATCH /v1beta1/counters/players).

  • The beta Lists routes under /v1beta1/lists/{name} (GET, PATCH, and POST :addValue / :removeValue): always a JSON 404 {"message":"list <name> not found"}, because a fleet has no lists. A POST with any other action falls through to the 501 below.

  • Every other route answers 501 {"error":"not_implemented"}. A malformed JSON body, or one over 64 KB, gets a JSON 4xx.

When your game counts as integrated

Once your game makes any write to the SDK (any POST, PUT, PATCH or DELETE), it must also call /ready. Any write to a route listed above marks the game as SDK-integrated, including a write that is refused or does nothing, such as a /health ping or /reserve. A write to a route the SDK does not know (the 501 answer) does not. An integrated game is kept in draining until it calls /ready, which means it is hidden from players and never allocated. Reads never mark the game as integrated: polling /gameserver, watching the stream, reading a counter, GET /v1/backfills and the reservation routes. A game that never writes through the SDK falls back to health-based readiness instead (see tier 3).

The stock Agones Unity SDK starts its POST /health loop in Start(). Adding its component to your scene therefore integrates the game from its first frame, and the game server stays draining until your code calls Ready(). Call Ready() as soon as the server can accept players. If your game uses the component only for health pings and never calls Ready(), its game servers never become ready.

The stock Agones SDKs

Ready(), Shutdown(), Allocate(), the health pings, SetLabel(), SetAnnotation() and the counter calls of the stock Agones SDKs reach the routes above. These differ from Agones:

  • Shutdown() restarts your game process in place instead of replacing the game server. See Recycle policy.

  • Label and annotation keys are stored exactly as you give them. Agones adds an agones.dev/sdk- prefix; PingCore does not, so read and filter on the bare key.

  • Reserve() and the alpha player-tracking calls answer but do nothing, and every Lists call answers 404 (see Routes that answer but do nothing).

  • Ending a session without a restart, backfill and reservation holds have no stock call. Call the native routes with plain HTTP.

  • The GameServer view has no spec, and its status has no players or lists. If your SDK version's model reads those fields, GameServer() and WatchGameServer() can fail while parsing. Test both on a hosted game server before you ship.

In Unity, use either the stock Agones component or the PingCore SDK's PingCore.Fleet in a game process, never both: both would send health pings and both would watch the stream. Stay on the stock component if you are porting an Agones game and one match per process is fine. Switch to the PingCore game server SDK for session end without a restart, backfill, reservation holds and connection approval.

How an allocation reaches your game

When a matchmaker allocates your server, the platform delivers the match context through the watch stream. The GameServer object flips to state Allocated and two annotations appear in object_meta.annotations:

  • pingcore.io/allocation-id: the allocation's identifier.

  • pingcore.io/allocation-context: the matchmaker's opaque context blob, JSON-encoded. This is where your roster, game mode, and map arrive.

When you allocate through the allocation API directly, the context is exactly the blob your matchmaker posted. When the match came from the first-party ticket matchmaker, the context is a wrapper carrying the whole match (matchmaker: true, the queue, the sessionSize, the actual players count, relaxed, backfill, the placement location, and a roster array with each ticket's id, party size, player id, attributes, and per-ticket context). The exact shape is documented in Matchmaking Integration.

// Watch for allocations (any language with an HTTP client works the same way).
const port = process.env.AGONES_SDK_HTTP_PORT;
if (!port) {
  // No SDK endpoint in this container (switched off, or it failed to start).
  throw new Error('AGONES_SDK_HTTP_PORT is not set: run without SDK calls');
}
const response = await fetch(`http://127.0.0.1:${port}/watch/gameserver`);

// Minimal reader for the newline-delimited stream.
async function* readLines(body) {
  const decoder = new TextDecoder();
  let buffer = '';
  for await (const chunk of body) {
    buffer += decoder.decode(chunk, { stream: true });
    let newline;
    while ((newline = buffer.indexOf('\n')) !== -1) {
      const line = buffer.slice(0, newline).trim();
      buffer = buffer.slice(newline + 1);
      if (line) yield line;
    }
  }
}

for await (const chunk of readLines(response.body)) {
  const { result: gameServer } = JSON.parse(chunk);
  const allocationId = gameServer.object_meta.annotations['pingcore.io/allocation-id'];

  if (allocationId && allocationId !== currentAllocationId) {
    currentAllocationId = allocationId;
    const context = JSON.parse(
      gameServer.object_meta.annotations['pingcore.io/allocation-context'] || '{}'
    );
    startMatch(context); // e.g. { mode: "ranked", roster: ["p1", "p2"] }
  }
}

An allocation is confirmed as a running session on delivery (Agones semantics: allocated means in use). When the match finishes, end it:

await fetch(`http://127.0.0.1:${port}/v1/sessions/${allocationId}/ended`, { method: 'POST' });

The server's session count drops, its state returns to ready (if it is healthy and not shutting down), and the pool has its capacity back.

Tier 2: minimal native integration

If you do not want the full SDK, the minimum for allocation-driven matchmaking is two HTTP calls against the same local endpoint:

  1. POST /ready when your server can accept a match.

  2. POST /v1/sessions/{allocationId}/ended when a match finishes.

You can read the allocation id and context from GET /gameserver (poll) instead of the watch stream if that is easier to embed.

Tier 3: zero integration

A game with no integration at all still works as a fleet member:

  • Readiness is derived from process health: the server reads ready when its process is up and its health checks pass.

  • Occupancy can be reported without any game code via RCON polling: the platform periodically runs a configured RCON command (every 10 seconds by default) and parses the player count out of the response. Configure it through the fleet's agent configuration (below).

The limit of tier 3: sessions can only be ended through the SDK surface. A zero-integration server that receives an allocation counts as in a session until it restarts. That makes tier 3 fine for live occupancy and server lists, but allocation-driven matchmaking needs at least tier 2. For one-match-per-boot games, the recommended pattern is to call Shutdown() when the match ends: the platform immediately restarts the game process and the server returns to ready (see the POST /shutdown entry in tier 1). The zero-integration fallback is to have the server simply exit after each match; the platform reboots it in place, but the server can sit dead for up to about 5 minutes before the reboot, so the recycle is much slower.

Counters

Allocation works by claiming capacity on named counters. Every fleet server advertises:

  • players: capacity defaults to the deployment's max player count.

  • sessions: capacity defaults to 1 (one match at a time).

Counter counts come from the best available source, in priority order: a value your game sets through the SDK counters surface, then the platform's own tracking (confirmed sessions for sessions, RCON occupancy for players), then 0.

To host multiple concurrent matches per server, raise the sessions capacity in the fleet's agent configuration and keep your counts accurate through the SDK. The first-party matchmaker claims { "sessions": 1 } per match, so it depends on the sessions counter existing; do not remove it from a custom counter set unless you also run your own matchmaker with different claims.

Labels and annotations

PUT /metadata/label and PUT /metadata/annotation take a body of { "key": "...", "value": "..." }. Both must be strings, and the key may be up to 253 characters. Each answers {} when stored, or 400 with a message.

  • Labels go into the game server's meta in Discovery, which players see on the public server list and matchmakers can filter on. The platform's own metadata (such as location) wins on a key collision: a label with that key is stored and shown in GET /gameserver, but never reaches Discovery. The merged meta may be at most 2048 bytes of JSON. A label that would push it past that is refused with 400.

  • Annotations stay inside the container. They appear only in GET /gameserver and the watch stream, never in Discovery. All the annotations your game sets may together be at most 2048 bytes of JSON.

Both calls are writes, so they mark the game as integrated (see When your game counts as integrated).

Recycle policy

If you come from Agones, the behaviour of Shutdown() is different here. On Agones, Shutdown means the GameServer is deleted and replaced. On PingCore, Shutdown recycles the server in place: the same pod, the same volume and the same server id come back as a fresh ready instance. Choose a recycle policy to control what is reset between matches.

Set it per member deployment in the Recycle cell on the fleet page (requires fleets.manage). Three policies:

  • restart (the default): the game process restarts and nothing on the disk is touched. Anything the last match wrote is still there. Use this when your game already resets its own state on boot.

  • reset-paths: the contents of the folders you list are wiped, then the game restarts. Paths are relative to the game root, at most 32 of them, no more than 255 characters each, with no leading slash, no backslash, no drive letter and no ... Use it for a session save directory or a match log directory that must not carry over. A path that does not exist is skipped, and so is one that is a symlink.

  • reinstall: the game folder is re-copied from the container image, then the game restarts. Available only when the branch delivers game files from the container image. A CDN or Steam branch would re-download the whole build after every match, so the option is refused there with reinstall_requires_image_source.

No policy deletes the volume, and none of them touches the platform's own directories outside the game root. A server that is retiring for a release does not recycle at all: it stays out of rotation until the release has updated it. See Shipping an Update.

When the container stops

When a game server's container is stopped (a scale-down, a node drain, or a release on an image branch, which recreates the pod), PingCore works in this order:

  1. It withdraws the game server's joinable record and reports it draining, so no new player is matched in.

  2. It closes the local SDK endpoint.

  3. It stops your game process. If the Command Line config sets a Shutdown Command, that is sent to the process's standard input first, with Shutdown Timeout (30 seconds by default) to exit. Then comes SIGTERM (a taskkill on Windows) with 30 seconds to exit, then a forced kill.

The deployment spec's Termination Grace Period (180 seconds by default) caps all of this. Keep it above Shutdown Timeout plus 40 seconds.

By the time your game gets the Shutdown Command or SIGTERM, the SDK endpoint refuses connections, so an SDK call from your quit path fails. Log it as information and do not retry it. Shutdown() is different: the endpoint stays up, and it is the restart that stops your game.

The agent configuration

Per fleet, you can override the counter model and integration settings. On the fleet page, the Agent Settings card shows the current values; click Edit (requires fleets.manage) to change Counters, SDK port, the Agones-compatible SDK switch, and RCON occupancy. The same settings are available through the platform API (PATCH /api/fleets/{fleetId} with agentConfig) and the platform's MCP update_fleet tool. Pass null to clear back to defaults.

{
  "counters": [
    { "name": "players", "capacity": 32 },
    { "name": "sessions", "capacity": 4 }
  ],
  "integration": {
    "agonesSdk": true,
    "sdkPort": 9358,
    "rcon": {
      "playerCountCommand": "status",
      "playerCountPattern": "(\\d+) players",
      "intervalMs": 10000
    }
  }
}
  • counters: name from A-Z a-z 0-9 _ . - (up to 50 characters), capacity 0 to 1,000,000.

  • integration.agonesSdk: set false to disable the local SDK endpoint entirely. AGONES_SDK_HTTP_PORT is then not set, and none of the routes on this page, the reservation routes included, are available.

  • integration.sdkPort: override the SDK port (1024 to 65535). The env variable follows.

  • integration.rcon: the tier 3 occupancy poll. playerCountCommand is required to enable it; playerCountPattern is an optional regex whose first capture group is the count (without it, the first integer in the response is used); intervalMs defaults to 10000.

  • Changes to counters and rcon reach running game servers within about five minutes, with no restart. Changes to agonesSdk and sdkPort take effect for each server on its next container restart; the SDK endpoint is bound before the game process starts.

The configuration is validated when you save it, so one bad counter name cannot silently break every server in the fleet.

Location targeting

Every fleet server automatically advertises its location in its metadata (meta.location, using the platform's location identifier, such as eu-west-ams or us-east-nyc). GET /v1/locations on the Discovery service lists every identifier with its display name and latency beacon; no token is needed. 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 (servers in London advertise eu-west-ams and are measured through the Amsterdam beacon), so a client measures the zone's beacon, not each server. Matchmakers use the identifier as an allocation filter, "filters": { "meta": { "location": "eu-west-ams" } }, and clients send their measured latency per identifier so the first-party matchmaker can place a match where the party's connection is best; see Latency and Location Targeting. Labels your game sets through the SDK are merged into the same metadata, but the platform's own keys win on a collision, so a game server cannot move itself to another location with a label. See Labels and annotations.

Joinable sessions

A running session with open seats can ask the first-party matchmaker to fill them by publishing a joinable-session record: which queue it accepts and how many seats are open. Tickets that opted in with joinInProgress: true are then placed into the running session before new matches are formed. The ticket side, the matching rules and the full backfill context are on Matchmaking Integration.

On a fleet game server, publish the record from the game process through the local SDK. A backend can publish the same record over HTTP instead (POST /v1/apps/{publicId}/servers/{serverId}/joinable-session with an Allocate-scope token). Use one publisher per game server: each publish replaces the record.

Publishing open seats

POST /v1/sessions/{allocationId}/joinable
{ "queue": "ranked", "openSeats": 3, "sessionSize": 8, "attributes": { "skill": 1210 }, "ttlSeconds": 60 }
  • allocationId (path): the running session's allocation id. It becomes the record's sessionId and comes back in every backfill's context, so you can tie joiners to the match.

  • openSeats (required): a non-negative integer.

  • queue (optional, default "default"), sessionSize (optional, a positive integer), attributes (optional, an object of numbers) and ttlSeconds (optional, at least 5; the record lives 60 seconds by default when you omit it): the same fields and limits as the HTTP record.

The call answers 200 with the record as it was sent to Discovery, or 400 with a message naming the bad field. Republish whenever the seat count changes. While the game server is connected, the platform also re-sends your last record every 30 seconds, so the record stays live between changes; keep ttlSeconds above 30.

DELETE /v1/sessions/{allocationId}/joinable withdraws the record and always answers 200. You rarely need it: ending the session with POST /v1/sessions/{allocationId}/ended withdraws the record too, and so does retiring for a release or a container stop.

Every joining player claims one seat on the game server's players counter. Keep openSeats within that counter's free capacity (capacity minus count). Discovery does not store a record that asks for more, so nothing is backfilled from it, and a record you published earlier stays live as it was until it expires. The local call still answers 200, because the check happens in Discovery (the HTTP route answers the same case with 409 seats_exceed_capacity). A join-in-progress game needs a players capacity of at least its match size. The fleet page shows each member deployment's players capacity and warns when a game server already holds more players than that; raise it with a players counter in Agent Settings.

Receiving the joiners

When the matchmaker places tickets into your session, the joiners arrive as a backfill. A backfill is never delivered as a new allocation, so your game is not told a new session started.

  • GET /v1/backfills answers { "backfills": [ { "allocationId", "sessionId", "claims", "context", "deliveredAt" } ] }: every backfill not yet ended, oldest first. context.roster lists the incoming players and sessionId is the session they join. deliveredAt is epoch milliseconds.

  • The most recent backfill also appears on the GameServer view as the annotations pingcore.io/backfill-id and pingcore.io/backfill-context, so a game already reading the watch stream sees it there.

  • As soon as a backfill arrives, the platform lowers your published openSeats by the joiners' party sizes, so the same seats are not sold again.

  • A backfill stays on the list until you end it with POST /v1/sessions/{backfillAllocationId}/ended, or end the session it joined, which ends all of that session's backfills.

Admit connecting players against those rosters. When you republish, the openSeats you send is maxPlayers - connected - expectedJoiners, and expectedJoiners MUST include every roster entry from GET /v1/backfills that has not connected yet. Your publish replaces the lowered record, so a count that leaves out the joiners sells their seats twice.

const port = process.env.AGONES_SDK_HTTP_PORT;
if (!port) {
  // No SDK endpoint in this container (switched off, or it failed to start).
  throw new Error('AGONES_SDK_HTTP_PORT is not set: run without backfill');
}
const SDK = `http://127.0.0.1:${port}`;

// Seats held by players matched into this session who have not connected yet.
// connectedIds holds whatever your connect handshake carries: the roster
// entry's playerId for a player-token ticket, its ticketId for a backend one.
async function expectedJoiners(connectedIds) {
  const { backfills } = await (await fetch(`${SDK}/v1/backfills`)).json();
  return backfills
    .flatMap((b) => b.context?.roster ?? [])
    .filter((entry) => !connectedIds.has(entry.playerId ?? entry.ticketId))
    .reduce((seats, entry) => seats + entry.partySize, 0);
}

async function publishOpenSeats(allocationId, maxPlayers, connectedPlayers, connectedIds) {
  const openSeats = Math.max(0, maxPlayers - connectedPlayers - (await expectedJoiners(connectedIds)));
  const response = await fetch(`${SDK}/v1/sessions/${allocationId}/joinable`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ queue: 'ranked', openSeats, sessionSize: 8 }),
  });
  if (!response.ok) throw new Error((await response.json()).message);
}

Reservations

When a player or your backend reserves seats on a hosted game server (including quick-join), the hold is delivered to the game server itself. Read it from the local SDK:

  • GET /pingcore/reservations answers { "reservations": [...] }: every live hold on this game server, soonest to expire first.

  • GET /pingcore/reservations/{reservationId} answers the one hold the connecting player presents. Admit the player when playerIds contains their id, within seats (a hold with playerIds: null names nobody; the linked page below covers that case). A 404 {"message":"reservation not found"} means unknown, released or expired. Right after the game server connects or reconnects, holds can take a few seconds to arrive, so retry briefly before you refuse the join.

These routes are reads, so they do not mark the game as integrated. A hosted game server never calls Discovery's verify endpoint and needs no Discovery token. The routes exist only while the SDK endpoint does (see the start of tier 1). The fields and the admission rules are on Reservations and Quick-Join.