Making Your Game Fleet-Ready

The session-based server contract: lifecycle states, the Agones-compatible SDK, allocation delivery, 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: not allocatable. This covers booting, unhealthy, and shutting down; anything that should keep the server visible but out of matchmaking.

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. Games already written against Agones work unmodified: the Unity and Unreal Agones SDKs speak this REST interface out of the box, and any language can call it with plain HTTP.

Your game process finds the endpoint through the AGONES_SDK_HTTP_PORT environment variable (default 9358), injected into the game's environment. Because the variable is present in every hosted server's environment, fleet member or not, 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.

Implemented surface:

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

  • POST /health: health pings, accepted for SDK compatibility.

  • 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 recycle is one-shot per shutdown request. 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.

  • GET /gameserver: the current GameServer view (state, address, ports, labels, annotations, counters).

  • GET /watch/gameserver: a newline-delimited stream of {"result": <GameServer>} objects, emitted 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.

  • POST /v1/sessions/{allocationId}/ended: a native extension (not part of Agones): tells the platform the session is over so the server returns to ready.

Important: once your game makes any state-writing SDK call (any POST or PATCH, including /health pings), it must also call /ready. Any of those calls marks the game as SDK-integrated, and an integrated game is kept in draining until it declares readiness. Read-only GETs (polling /gameserver, watching the stream, reading a counter) do not mark the game as integrated. A game that never writes through the SDK falls back to health-based readiness instead (see tier 3).

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 || 9358;
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.

The agent configuration

Per fleet, you can override the counter model and integration settings. There is no form for this in the workspace UI yet; set it through the platform API (PATCH /api/fleets/{fleetId} with agentConfig) or 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.

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

  • 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.

  • 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.

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 and win on key collisions.

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. Today the record is published from your backend over HTTP (POST /v1/apps/{publicId}/servers/{serverId}/joinable-session with an allocate-scope token), republished every half TTL and whenever the seat count changes, and withdrawn with DELETE the moment the session ends. The full contract, the roster read-back, and the delivered backfill context are on Matchmaking Integration. A native SDK endpoint for publishing the record from the game process itself, and delivery of the joiners' roster through the SDK, ship with a later game supervisor update and are not documented until then.

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.

The openSeats you publish is maxPlayers - connected - expectedJoiners. expectedJoiners MUST include every roster entry returned by the joinable-session GET (or GET /allocations/{id}) that has not yet connected. Discovery subtracts joiners it has already matched for 60 seconds after each match as a safety margin, but after that only your own count keeps the seats from being sold twice.