home / blog / network
Published: 18 December 2025 12 min read network
Dedicated server architecture: 48 players per container

Dedicated server architecture: 48 players per container

We split the authoritative game server from the backend, rebuilt matchmaking and room allocation, and measured the real cost per concurrent player.

Playtest night: 40 players, one process, one crash

Last February we ran a closed playtest with 40 people on a Friday at 21:00. The game was built around 8-player matches, and five matches were running at the same time that night. At 23:40 a third of the players dropped at once. All we had left was one line: OutOfMemoryException, with five different matchId values under it.

The cause was not a bug, it was the architecture. Five matches lived inside a single process, a single Unity headless build. A leak in one match killed the other four. What we learned that night is that per-room isolation is not an optimisation, it is a correctness requirement. We also tried a per-process memory cap, but the thing that died was still the shared process.

Over the next three months we rebuilt the game server architecture from scratch. The numbers and the rejected alternatives below are measurements from that period, all from an 8-player competitive shooter prototype. The engine is Unity 2022 LTS and the transport is our own reliable channel over UDP.

Why an authoritative server is not negotiable

In the first version the client computed movement and reported the result to the server. In the second playtest, three of the 40 players edited packets and doubled their run speed. The detection code was not the broken part, the architecture was: if the client writes positions, cheating becomes a validation race, and you lose that race. We tried enforcing a speed limit server-side, but every new movement ability meant retuning that limit.

On an authoritative server the client only sends input. Ours is an InputCommand struct: tick number, movement vector, view angles and button bits, 14 bytes in total. The server runs the simulation on a fixed 30 Hz tick and broadcasts state. The client still predicts locally, but authority never moves to it.

The cost is real: the server now runs physics for every player. In our measurements an 8-player session used about 38% of one 3.4 GHz core, including the character controller, hit-scan and visibility filtering. That single number became the input for every scaling and cost calculation that followed. That 38% is an average rather than a peak; during crowded fights we saw the same session hit 55%.

The game server and the game backend differ

A game server holds state, lives briefly, and when it dies exactly one match dies with it. The game backend, meaning accounts, inventory, progression and friend lists, is stateless, lives long, and when it dies everyone is affected. Keeping both in one process saved us two weeks in the first version and cost us two months afterwards. The rule I use: if a piece of data outlives the match, it is not the game server's business.

We drew the line like this: during a match the game server writes to no persistent database. When the match ends, a single MatchResult body is posted to the backend with per-player score, duration, damage and items used. Instead of eight servers writing to the inventory table at the same moment, we get a few hundred small requests per minute that can be queued. For players who drop mid-match we also send an interim record every 30 seconds, so a crash never wipes anyone's progress.

Authentication belongs to the backend as well. On login the player receives a sessionTicket valid for 120 seconds and hands it to the game server along with the room address, and the game server validates it against the backend in one call. The game server holds no database credentials at all. A compromised game server cannot reach inventory, it can only ruin that one match.

How the matchmaking queue and allocation work

Matchmaking is a single service for us: MatchQueue. A player entering the queue is recorded with skill rating, region and party id. The matcher scans the queue every 2 seconds, groups players in the same region within a ±100 rating band, and widens the band by 50 every 5 seconds. At 45 seconds the band hits its ceiling and wait time wins over quality.

Once eight players are found, RoomAllocator takes over. We tried the alternative: starting a fresh container per match. A Unity headless build needed 6-9 seconds to load the scene and become ready, and that is long enough to break up a group that was already formed. Instead we keep four idle sessions warm per region, and allocation completes in under 300 ms.

If the warm pool empties, new sessions spawn in the background and players see that 8-second wait at worst. Do not fix the pool size: between 21:00 and 24:00 our concurrent player count is six times the daytime level, so the pool moves from 4 to 12 on an hourly profile. With a fixed pool you either grow the queue at night or pay for idle machines all day.

Sessions per container and how scaling works

One game server process runs exactly one match and exits when the match ends. Measured usage is 180 MB RSS per session and 0.42 vCPU on average. A 2 vCPU / 4 GB node comfortably fits four sessions and six if you push it. Six sessions times eight players is 48 concurrent players per node. We tried packing in more; at the eighth session the tick started exceeding 33 ms and movement visibly degraded.

Do not tie scaling to CPU percentage. The right metric is free session count: when freeSessions drops below 8 a new node is requested, and when it climbs above 24 one node is marked for drain. Our first version, driven by CPU, kept trying to shut down nodes that still had players on them whenever matches ended and load dipped. Keep this metric per region, or spare capacity in Frankfurt will mask a shortage in Istanbul.

Shutdown always happens through drain. The node stops receiving allocations and the matches on it are allowed to end naturally. Since a match lasts at most 20 minutes for us, worst-case drain is also 20 minutes. For the same reason we use spot and preemptible machines only for the warm pool; a node carrying live matches should not be the cheap one.

Logging and crash collection belong to this layer. Each process writes one JSON object per line to stdout, and every line carries sessionId, matchId, buildId and the tick number. Because the process disappears on a crash, the collector must run independently of it and push Player.log and the crash dump out before the container dies. Until we set that up, we never found the cause of three separate crashes. Log volume is around 400 lines per session per minute, which is small enough to keep without sampling.

Region, cost, and where a relay is enough

Region selection follows ping, not player preference. Round-trip times measured from Bursa: Istanbul 18-22 ms, Frankfurt 48-55 ms, North Virginia 128-140 ms. At a 30 Hz tick one tick is 33 ms, so Frankfurt is playable and Virginia is not, at least for a competitive shooter. On login the client sends one UDP probe to three regions and joins the queue with its best two. If party members sit in different cities, the best shared region wins and the worst ping in the party decides it.

Calculate cost per concurrent player, not per machine. A 2 vCPU / 4 GB node costs us about $0.09 per hour and carries 48 players, which is $0.0019 per player-hour when full. You are never full though: our average occupancy is 35%, and once the warm pool and the minimum capacity per region are added the real figure is $0.006. At 500 concurrent players with a four-hour daily peak profile, that is roughly $270 a month. Bandwidth stayed at 8% of the total bill, at roughly 20 kbps of upstream per player.

For a three-person team that number is small next to the actual expense. The allocation service, drain logic, log pipeline and crash collection took us about a month of full-time work. If you are building a non-competitive 4-6 player co-op game and your concurrency stays in the low hundreds, a listen server over a relay is enough. The relay solves NAT, authority stays on the host, and the only price you pay is host advantage.

The threshold I use is simple: if anything touches leaderboards, ranking or an economy, you need a dedicated server and an authoritative one, otherwise start with a relay. The only thing that makes the later switch cheap is keeping game logic runnable on the server side from the start. Our GameSession class depends on no MonoBehaviour, and the same code runs in a listen server and in the headless build. Make that separation on day one and delaying the decision costs almost nothing.

← All posts