Who starts, assigns and shuts down the game servers
A ten-player arena lasts five minutes. If three hundred of them are played in parallel in the evening, you need three hundred server processes, each with its own ports and its own state in memory, and they have to be born and die at the pace of the matches. Nobody can do it by hand and no web balancer does it for you, because a match server is not interchangeable: it is the same reasoning as in the article on the anatomy of the game backend, brought to the scale of the fleet. The orchestrator is the component that keeps the count: how many servers exist, which ones are free, which one to assign to a new match, when to add or remove capacity.
Here we compare the two most widespread approaches, Agones (installed in your Kubernetes cluster) and Amazon GameLift Servers (a managed service). The facts come from the official documentation, read on 2026-11-12 and cited at the bottom; where I could not verify a detail I removed it. The code that really runs here is a model of the autoscaler: no cluster, no cloud account.
The life cycle of a server inside Agones
From the orchestrator's point of view, a game server is a state machine. Before installing anything it is worth having the states clear, because every decision of the autoscaler and of the allocator is a sentence about these states. The table uses the names that the Agones documentation and its SDK show.
| State | What it means | What triggers it |
|---|---|---|
| Ready | The process is up and can receive players, but no match has been assigned to it | The server calls ready() on the SDK |
| Reserved | Set aside for a limited time while waiting for players | The server calls reserve() |
| Allocated | Assigned to a match; the autoscaler does not touch it | An allocator, or the server itself with allocate() |
| Shutdown | The match is over, the pod is deleted | The server calls shutdown() |
| Unhealthy | It failed the health checks: it is not restarted, it leaves service | A missed health() within the threshold |
The middle column explains why the model holds: the one who decides is always the server (which knows whether it is ready or finished)
or the orchestrator (which knows how many are needed), never a third party. The asymmetry that matters is the Allocated row: during
a match the server cannot be moved or deleted to make room for another. That is why, when the fleet
shrinks, Agones removes only Ready servers (with the Packed strategy it starts from those on the least busy nodes,
so that whole nodes can be freed). A match in progress is never sacrificed to a saving.
Fleet and FleetAutoscaler: the manifest line by line
A Fleet is the set of identical GameServers; it is the equivalent of a Deployment, but its pods are not
fungible. The FleetAutoscaler resizes it according to a policy. I did not apply the manifest below
to any cluster (schema to adapt); I only read it as valid YAML and compared it with the
FleetAutoscaler reference, and it is the same one that the
model in the next section interprets.
apiVersion: agones.dev/v1
kind: Fleet
metadata:
name: shooter-fleet
spec:
replicas: 10
scheduling: Packed
template:
spec:
ports:
- name: default
portPolicy: Dynamic
containerPort: 7777
protocol: UDP
health:
initialDelaySeconds: 30
periodSeconds: 5
failureThreshold: 3
template:
spec:
containers:
- name: game-server
image: registry.example.com/game-server:1.2.0
resources:
requests: { cpu: 500m, memory: 512Mi }
limits: { cpu: "1", memory: 1Gi }
---
apiVersion: autoscaling.agones.dev/v1
kind: FleetAutoscaler
metadata:
name: shooter-fleet-autoscaler
spec:
fleetName: shooter-fleet
policy:
type: Buffer
buffer:
bufferSize: 5
minReplicas: 10
maxReplicas: 100
sync:
type: FixedInterval
fixedInterval:
seconds: 30
The lines that matter. portPolicy: Dynamic makes Agones pick a free port on the node, and the client
discovers it from the outcome of the allocation: so you can have dozens of servers on the same node. The
health block says the server has 30 seconds before the first check, then one is expected every 5, and after three consecutive
misses (failureThreshold) it becomes Unhealthy. scheduling: Packed piles servers onto the nodes already in use; it is the
choice designed for clusters that grow and shrink with the node autoscaler, while Distributed is for clusters
of fixed size. Finally the autoscaler: bufferSize: 5 asks to always have five Ready servers on top of the
Allocated ones, but always between minReplicas and maxReplicas, and it evaluates it every 30 seconds.
A detail that the manifest states only as a choice: replicas: 10 is the starting value, because afterwards
the autoscaler rewrites it. And the server image must be an immutable tag and never latest (a moving tag
makes it impossible to know which version is playing), so that a rollback is a one-line change.
What happens when demand rises: a model of the autoscaler
To understand why bufferSize and the 30-second interval are not details, I wrote a model. It reads the
numbers straight from fleet.yaml (min, max, interval) and layers three declared assumptions on top: a new
server takes 20 seconds to become Ready, a match lasts 300 seconds, and match requests arrive at 0.05 per
second with a two-minute peak at 0.40. These are my numbers, not measurements of a game. At each synchronization the autoscaler
sets the replicas to "Allocated plus buffer", within the limits; if requests find zero Ready servers, I count them as
requests without a server. The generator has a fixed seed, so the output is reproducible.
# Teaching model of a Fleet with a Buffer-type FleetAutoscaler.
# It reads the parameters from the fleet.yaml manifest; the rest (arrivals, match length) is invented.
import random
import yaml
docs = list(yaml.safe_load_all(open("fleet.yaml")))
policy = docs[1]["spec"]["policy"]["buffer"]
SYNC_S = docs[1]["spec"]["sync"]["fixedInterval"]["seconds"]
MIN_R, MAX_R = policy["minReplicas"], policy["maxReplicas"]
START_S = 20 # seconds between "create the server" and "Ready" (model assumption)
MATCH_S = 300 # length of a match
DURATION = 1800
def arrival_rate(t: int) -> float:
return 0.40 if 600 <= t < 720 else 0.05 # requests per second: a 2-minute peak
def run(buffer_size: int, seed: int = 7):
rng = random.Random(seed)
starting, ready, allocated = [], 0, [] # starting: seconds of Ready; allocated: end of match
rejected = served = peak = 0
for t in range(DURATION):
starting_now = [s for s in starting if s <= t]
ready += len(starting_now)
starting = [s for s in starting if s > t]
allocated = [e for e in allocated if e > t]
for _ in range(sum(1 for _ in range(10) if rng.random() < arrival_rate(t) / 10)):
if ready > 0:
ready -= 1; allocated.append(t + MATCH_S); served += 1
else:
rejected += 1 # no Ready server: the player waits or retries
if t % SYNC_S == 0: # the FleetAutoscaler works at fixed intervals
desired = max(MIN_R, min(MAX_R, len(allocated) + buffer_size))
total = ready + len(starting) + len(allocated)
if total < desired:
starting += [t + START_S] * (desired - total)
elif total > desired:
ready -= min(ready, total - desired) # only Ready servers are removed, never Allocated
peak = max(peak, ready + len(starting) + len(allocated))
return served, rejected, peak
print(f"sync every {SYNC_S} s, min {MIN_R}, max {MAX_R}, server start {START_S} s")
for buf in (5, 15, 40):
served, rejected, peak = run(buf)
print(f"bufferSize={buf:>2}: matches started {served:>3}, requests without a server {rejected:>3}, peak fleet {peak}")
# Output:
# sync every 30 s, min 10, max 100, server start 20 s
# bufferSize= 5: matches started 108, requests without a server 38, peak fleet 34
# bufferSize=15: matches started 133, requests without a server 13, peak fleet 66
# bufferSize=40: matches started 144, requests without a server 2, peak fleet 100
The output shows the trade-off in three lines. With a buffer of 5, during the peak as many as 38 requests out of 146 find the fleet empty, because new servers take 20 seconds and the autoscaler wakes up only every 30. With a buffer of 15 the rejected requests are 13 and the fleet reaches 66 servers; with 40 they are 2, but the fleet hits the ceiling of 100 and you pay for unused servers for the whole rest of the evening. The buffer is therefore a price you pay in advance to cover the start-up time: its right size is "expected peak of requests in the synchronization interval plus the start-up time", not a round number. The model does not include the start-up time of the Kubernetes node, which, if the cluster lacks capacity, adds on top and makes everything worse.
Assigning a match and talking to the SDK
A Ready server does nothing until someone assigns it a match. For this Agones offers an allocator with a gRPC
and REST interface protected by mTLS: your matchmaker must present an accepted client certificate, and it cannot
call a plain HTTP address as some examples show (the
service documentation describes the certificate and
the secret to prepare). Alternatively the matchmaker creates a GameServerAllocation resource through
the Kubernetes API. The one who decides who plays with whom is the matchmaker, which is the subject of the article
on matchmaking.
On the server side the code uses the SDK, which talks to a local sidecar. The following block is the minimal schema with the
Node.js library (@google-cloud/agones-sdk); it was not run because it requires the sidecar. Two lines make the
difference: health is called every 2 seconds, less than the 5 of the manifest, as the documentation asks
(if the ping arrives at equal or longer intervals, the checks fail and the server goes Unhealthy in the middle of a match);
and ready() is the last thing you do, after loading maps and resources, because from that moment the server is
assignable.
// Schema to adapt: requires a cluster with Agones and the SDK sidecar.
const AgonesSDK = require("@google-cloud/agones-sdk");
const agones = new AgonesSDK();
async function main() {
await agones.connect(); // resolves once connected, rejects after 30 s
// The ping must arrive at intervals SHORTER than spec.health.periodSeconds (here 5 s)
setInterval(() => agones.health((err) => console.error("health", err)), 2000);
agones.watchGameServer((gs) => {
if (gs.status.state === "Allocated") startMatch(gs.objectMeta.labels, gs.objectMeta.annotations);
});
await agones.ready(); // from here the allocator can assign us a match
}
async function endMatch() {
await saveResults();
await agones.shutdown(); // Agones deletes the pod
}
At the end of the match shutdown() asks Agones to delete the pod: the process must not exit on
its own, or the orchestrator counts it as a failure. The allocation callback reads labels and annotations, and it is the channel
through which the matchmaker passes the map and the number of expected players to the server.
Health: a single question, "are you alive?"
It is tempting to make the health check say "I am slow" when the tick rate drops. The listing computes exactly that rule (degraded below 80% of the target) on four cases.
// Health decision based on the measured tick rate. Pure function, no server.
interface Sample { readonly tickRateHz: number; readonly targetHz: number; readonly activePlayers: number }
function healthOf(s: Sample): { status: "ok" | "degraded"; httpCode: 200 | 503 } {
const degraded = s.tickRateHz < s.targetHz * 0.8;
return degraded ? { status: "degraded", httpCode: 503 } : { status: "ok", httpCode: 200 };
}
const cases: Sample[] = [
{ tickRateHz: 64, targetHz: 64, activePlayers: 10 },
{ tickRateHz: 52, targetHz: 64, activePlayers: 10 }, // 52 > 51.2: still ok
{ tickRateHz: 51, targetHz: 64, activePlayers: 10 }, // 51 < 51.2: degraded
{ tickRateHz: 0, targetHz: 64, activePlayers: 0 }, // stuck process
];
for (const c of cases) console.log(`${c.tickRateHz} Hz of ${c.targetHz}:`, healthOf(c));
// Output:
// 64 Hz of 64: { status: 'ok', httpCode: 200 }
// 52 Hz of 64: { status: 'ok', httpCode: 200 }
// 51 Hz of 64: { status: 'degraded', httpCode: 503 }
// 0 Hz of 64: { status: 'degraded', httpCode: 503 }
The calculation works, its use is dangerous. A server at 51 Hz out of 64 is declared degraded, but if that verdict
reached the orchestrator as Unhealthy, Agones would replace it in the middle of a match: it worsens the experience
it meant to protect. It is better to split the roles. The orchestrator's health check answers only "does the process
respond?" and is the only one allowed to make a server die. Degradation becomes a metric with an alarm for
you, and at most a reason not to assign new matches to that server. The same criterion applies to
GameLift's onHealthCheck: the documentation says that after three consecutive negative or missing responses the
service may terminate the process and start another.
Amazon GameLift Servers: the same problem, a managed service
GameLift solves the same problems by moving the orchestrator outside your cluster. The game server includes a server SDK: the official guide currently lists C++, C#, C++ for Unreal and Go, plus the built-in Unreal and Unity plugins. For a server written in Node.js no official SDK is listed: if your game is there, with GameLift you choose another language for the server process, or you fall back on Agones. The contract the SDK imposes is nonetheless clear and is described in words.
InitSdk()establishes the link with the service, before any other call.ProcessReady()declares the process ready, with the port and the log paths; it is called once per process.onStartGameSessionarrives in response to aCreateGameSessionrequest: this is where the match starts.onProcessTerminatearrives when the service wants to stop the process: you must shut down in an orderly way.onHealthCheckis optional; if it does not respond within 60 seconds, the process is considered unhealthy.
The list makes sense in the order in which it appears, because it tells the life cycle: you initialize, announce you are ready,
receive a match, answer for its health, stop when asked. Compared with Agones the difference in
philosophy lies in onProcessTerminate: here the service "calls you", whereas in Agones you are the one calling the
sidecar. The callback model is handier if you start from scratch; the sidecar one is more neutral with respect to language.
GameLift also offers FlexMatch matchmaking, usable with GameLift hosting or as a standalone service, for example in front of peer-to-peer games or games hosted elsewhere. Its rules are a real language (teams, player attributes, expansions over time) that deserves a section of its own: here it is enough to know that it is a separable component, and that you find the overview in the FlexMatch guide.
Spot: saving money and surviving interruption
Compute is the biggest cost item, and EC2 Spot Instances are the classic lever: spare capacity at a reduced price, with the risk of interruption. The FleetIQ guide says it plainly: Spot, although discounted, is in general not suitable for game hosting because availability varies and interruptions are possible; FleetIQ exists to make that use practical, and it is also available inside managed GameLift. The discount figures you read everywhere ("up to 90%") I could not verify on the official page and I do not use them: prices change by region and by instance type, and must be read in the price list on the day you decide.
One thing holds with any discount: the Spot interruption arrives with a two-minute notice (EC2 documentation). A five-minute match does not finish in two minutes, so either you tolerate it (refunds, restart, saving the state) or you keep the Allocated servers on On-Demand capacity and put the waiting ones on Spot. The two policies have different costs, and the choice is a product matter, not an infrastructure one: how angry does a lost match make a player?
GameLift or Agones: how to choose
The criteria below are the ones that really separate the two paths; none depends on a price, because the prices of managed services are read in the price list and those of a cluster depend on who runs it.
| Criterion | Amazon GameLift Servers | Agones |
|---|---|---|
| Where it runs | EC2 and containers managed by AWS, or on your own infrastructure with Anywhere mode | Any Kubernetes cluster |
| Who operates the fleet | The service, with your control over the scaling rules | You: cluster, upgrades, observability |
| Vendor dependency | High for the managed part | Low: it is open software |
| Matchmaking | FlexMatch, integrated or separate | You bring your own (see Open Match) |
| Server-side languages | C++, C#, Go (plus Unreal and Unity) | Any: the SDK is a sidecar with gRPC/REST |
The decisive row is "Who operates the fleet". If you already have a Kubernetes cluster and the people who run it, Agones gives you portability and control without a second system to learn. If your team is four people and none knows Kubernetes, the cost of the managed service is often lower than the cost of the time you would spend operating the cluster (reasoning, not measurement). In both cases the separation remains valid: the game talks to a thin interface (ready, assigned, over), and if you keep it thin you can change orchestrator without rewriting the simulation.
Five habits that avoid surprises in production
- A buffer sized on the start-up time, as the model shows, not on a round number.
- An orderly shutdown notice:
onProcessTerminateor the handling of the termination signal must finish the matches, save the results and call shutdown. - Images with immutable tags, to be able to say which version produced an incident.
- Logs outside the server: on the node's disk they vanish with the node.
- Rare and light health checks: every check is work taken away from the tick.
The five items have a common root: a game server is a short-lived process and must be treated as such, with everything that has to outlive it (logs, results, version) kept elsewhere. If you adopt only one, choose the first, because it is the only one the end user perceives: a buffer that is too small turns into matchmaking waits, and a disproportionate buffer into an invoice. You discover the operating cost of the rest with the metric; how to read it is in the article on the observability of the game backend.
What you can do now
You can describe the states of a game server and say who makes them change. You can read a Fleet and
FleetAutoscaler manifest explaining every field that matters, and you can simulate the effect of bufferSize before choosing a
number. You can tell a health check from a quality metric, and where to put Spot Instances. Finally you can
compare GameLift and Agones on the right criterion, that is, who operates the fleet. The logical next step is to decide who
enters which match: matchmaking with Elo and Glicko-2.
Frequently asked questions
Is Agones an alternative to Kubernetes?
No, it runs on top of it. Agones is a library for hosting, running and scaling dedicated game servers on Kubernetes, and it adds resources such as GameServer, Fleet and FleetAutoscaler. Without a Kubernetes cluster it does not work.
Can I use Node.js for the server on GameLift?
Not with an official server SDK: the AWS guide lists C++, C#, Unreal and Go. With Agones yes, because the SDK is a sidecar reachable via gRPC and REST and there is a Node.js library in the documentation.
How many servers should I keep ready in advance?
At least as many as you consume in the time it takes to create more: the peak of requests in the synchronization interval plus the server start-up time. The model in this article shows how the number of requests without a server changes as the buffer varies.
Sources
- Agones, Fleet Autoscaler Specification - the Buffer policy,
bufferSize,minReplicas,maxReplicasand fixed-interval synchronization, accessed on 2026-11-12 - Agones, Node.js Game Server Client SDK -
connect,health(errorCallback),ready,allocate,shutdown,watchGameServer, accessed on 2026-11-12 - Agones, GameServer Health Checking - the Unhealthy state and the interval of
Health()relative toperiodSeconds, accessed on 2026-11-12 - Agones, Allocator Service - the gRPC and REST service with mTLS, accessed on 2026-11-12
- Agones, Scheduling and Autoscaling - the Packed strategy and the removal of Ready servers only when scaling down, accessed on 2026-11-12
- Amazon GameLift Servers, Add GameLift to your game server with the server SDK - server SDK languages, callbacks and health check rules, accessed on 2026-11-12
- Amazon GameLift Servers FleetIQ, What is FleetIQ - the role of FleetIQ with Spot Instances, accessed on 2026-11-12
- Amazon GameLift Servers FlexMatch, What is FlexMatch - standalone use or with hosting, accessed on 2026-11-12
- Amazon EC2, Spot Instances - the two-minute interruption notice, accessed on 2026-11-12
The autoscaler model and the health calculation were run with Python 3 (PyYAML) and Node.js 24; the outputs are the real ones, but the arrivals, the match length and the start-up time are assumptions of the model. The manifest and the SDK listing were not applied to a cluster: they are schemas to adapt. No price or discount figure is reported.
Commenti
Caricamento commenti...
Sign in to comment