The last two chapters were about the realtime game server: the process that runs one live match, moves everything at high frequency, and treats the client's own reported position as a guess to correct rather than as truth. This chapter is about everything else a live game needs, and there is a lot of it. When you log in, buy a skin, check a leaderboard, or get matched into a game, none of that touches the realtime game server at all — it goes through a completely different set of programs called the backend (the collection of server-side services and databases that keep a live game running outside of any single match). You will learn what those services do, how they talk to each other and to a database, and two rules that keep millions of players' accounts and wallets from turning into chaos: the server owns the truth, and a repeated request must never charge anyone twice.
Open a live-service game on your phone and watch what happens before you ever see gameplay. It checks that you're logged in. It may show a shop, a set of daily quests, a leaderboard. You tap "Find Match" and wait in a queue. Only after all of that does the game finally connect you to a match — the realtime game server from the previous two chapters. Everything before that point ran through the backend.
A typical live game's backend is made of several separate jobs, usually separate programs:
These two boxes are usually written differently, deployed differently, and scaled differently, because they solve different problems. The rest of this chapter is about the right-hand box.
It helps to compare them directly, side by side, using what you already know about the game server from the previous chapters:
The game server's whole job is speed: get a position update out before the next frame, every fraction of a second, for as long as the match lasts. Once the match ends, that data can vanish — nobody needs your exact mid-fight position tomorrow. A backend service has the opposite job: a player's gold balance, owned items, and account must still be correct next week, after the server process has restarted a dozen times, and after a completely different server machine happens to answer the request. That difference in job shapes everything else in this chapter, starting with how the two kinds of server even talk.
Where the game server streams fast binary updates over UDP, backend services almost always talk over HTTP (the same protocol your browser uses to load web pages) using a style called REST (a convention for organizing HTTP requests: a URL names what you're acting on, and an HTTP verb names what action you're taking). The data itself is usually JSON (JavaScript Object Notation — a plain-text format for structured data, built from key: value pairs, that every modern language can read and write).
// C# -- a very small REST call using HttpClient
using System.Net.Http;
HttpClient http = new HttpClient();
http.BaseAddress = new Uri("https://api.mygame.com/");
// GET /shop/items --> ask the Shop Service what is currently for sale
HttpResponseMessage response = await http.GetAsync("shop/items");
string body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);
Expected output (plain text, printed to the console):
[
{ "id": "sword_01", "name": "Iron Sword", "price": 100 },
{ "id": "potion_01", "name": "Health Potion", "price": 20 }
]
Notice the client never had to know the Shop Service was written in C#, Go, or anything else — JSON is just text, readable by any language on either end. REST's verb convention keeps requests predictable: GET reads data without changing anything, POST creates something new (a purchase, an account), PUT/PATCH updates something existing, and DELETE removes it. The URL says what ("shop/items"); the verb says what to do with it ("read it").
/shop/items or /player/42/inventory is called an endpoint (one specific URL a backend service accepts requests on). A real backend has dozens of them — one per action a client can take.Logging in is the first REST call almost every session makes, and it sets up something every later call depends on. The client sends a username and password once; the server checks them against the database and, if they match, hands back a token (a piece of proof that this request really is from an already-verified player, so the server does not need to re-check a password on every single call).
There are two common shapes for that token. A session ID is a random opaque string the server stores in a lookup table (often in a fast cache — Section 12 covers Redis); every request looks the ID up to find which player it belongs to. A JWT (JSON Web Token — a token that carries the player's id and an expiry time inside itself, cryptographically signed so it cannot be forged) needs no lookup at all: any service can check the signature and read the player id directly out of the token.
// Pseudocode -- Auth Service login handler
public LoginResult HandleLogin(string username, string password)
{
PlayerRow row = db.QueryOne(
"SELECT id, password_hash FROM players WHERE username = @u", username);
if (row == null || !PasswordHasher.Verify(password, row.password_hash))
return LoginResult.Fail("invalid username or password");
string token = TokenService.CreateToken(row.id, expiresIn: TimeSpan.FromHours(4));
return LoginResult.Ok(token);
}
Expected result: a correct username/password returns a token string good for 4 hours; a wrong password or unknown username both return the exact same generic error message.
The previous chapters taught server-authoritative design for movement: the game server, not the client, decides where a player actually ended up. The exact same rule applies to your wallet. A request should describe an intent ("I want to buy this"), never a final value ("set my gold to this") — because anyone can intercept and edit a request with a free proxy tool before it reaches your server.
// BAD -- the client just tells the server the new values directly.
// Anyone with a network proxy can change these numbers before sending.
POST /player/update
{ "playerId": 42, "gold": 999999, "inventory": ["legendary_sword"] }
If a handler for that request just writes whatever it received into the database, every player who finds the request can make themselves rich in one edited request. The fix is to only ever accept an intent, and compute every resulting number on the server:
// GOOD -- the client only describes what it wants to happen.
POST /shop/buy
{ "playerId": 42, "itemId": "sword_01" }
// Server-side handler -- the ONLY place price and gold are decided.
public BuyResult HandleBuy(int playerId, string itemId)
{
Item item = catalog.GetItem(itemId); // price lives on the server
Player player = db.GetPlayer(playerId); // current gold lives on the server
if (player.Gold < item.Price)
return BuyResult.Fail("not enough gold");
player.Gold -= item.Price;
player.Inventory.Add(item.Id);
db.Save(player);
return BuyResult.Ok(player.Gold);
}
Expected result: the client's request contains no price and no gold amount at all — the handler looks both up itself from the catalog and the database, so nothing about the outcome depends on anything the client sent except which item was requested.
"price": 100 field alongside the item id and trusting it, instead of looking the price up from the server's own catalog. That field is just as editable as a raw gold value — a modified client could send "price": 1 for a legendary item. Anything that determines a cost, reward, or stat must be looked up server-side, never read from the request.If a player's gold only lived in one server process's memory, restarting that process — a routine deploy, a crash, a scheduled maintenance — would erase it, and a busy game runs many copies of each backend service anyway (Section 11), so the very next request might land on a completely different process that never saw that player before. The fix is to keep the one true copy of every player's data somewhere all of those processes can share: a database (a program specialized in storing data durably on disk and letting many other programs read and write it safely at the same time).
"On disk" also matters for a reason you already know from the C chapters: RAM is fast but empty on every restart, while disk storage survives power loss and process restarts. A database gives you that durability, plus safe concurrent access, without you writing either of those two hard problems yourself.
Databases split broadly into two families. A SQL database (also called relational — examples: PostgreSQL, MySQL, SQL Server) stores data in tables with a fixed set of columns, where rows in different tables link to each other through shared keys. A NoSQL database (a looser umbrella term — examples: MongoDB, DynamoDB, Redis) stores data more loosely: often as whole documents whose shape can differ from one entry to the next, or as simple key-to-value pairs.
SQL earns its keep when data has real structure that must stay correct: a player owns items, an item has a price, a purchase links a specific player to a specific item — exactly the kind of relationships Sections 8 and 9 build on. NoSQL earns its keep when data changes shape often, doesn't need strict cross-table relationships, or needs very high raw speed for simple lookups — a session token, a chat log, a leaderboard entry.
A schema (the set of tables and columns a database is organized into) for a very small game might look like this — three tables, one for players, one for the item catalog, and one linking the two:
CREATE TABLE players (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
gold INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE items (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
price INTEGER NOT NULL
);
CREATE TABLE inventory (
player_id INTEGER NOT NULL REFERENCES players(id),
item_id TEXT NOT NULL REFERENCES items(id),
quantity INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (player_id, item_id)
);
A primary key is the column (or columns) that uniquely identify a row — no two rows in players can share an id. REFERENCES declares a foreign key: a promise that an inventory.item_id value must match a real row that already exists in items, so you cannot accidentally record ownership of an item that was never defined.
To list everything one player owns, with readable names and prices instead of just raw ids, a query joins the two tables together:
SELECT items.name, items.price, inventory.quantity
FROM inventory
JOIN items ON items.id = inventory.item_id
WHERE inventory.player_id = 42;
Expected output (a result table):
name | price | quantity
--------------+-------+---------
Iron Sword | 100 | 1
Health Potion | 20 | 3
A JOIN combines rows from two tables wherever a key matches on both sides — here, wherever an inventory row's item_id equals an items row's id. Without it, the handler would need a separate query per item just to find its name and price, one round trip at a time.
Buying an item is really two separate writes: subtract gold from players, and add a row to inventory. If the server crashes, or the database connection drops, exactly between those two writes, the player's gold is already gone but the item never arrived. A transaction (a group of database writes that the database guarantees either all happen, or none happen — never half) closes that gap.
BEGIN TRANSACTION;
UPDATE players
SET gold = gold - 100
WHERE id = 42 AND gold >= 100; -- matches zero rows if gold is too low
INSERT INTO inventory (player_id, item_id, quantity)
VALUES (42, 'sword_01', 1)
ON CONFLICT (player_id, item_id)
DO UPDATE SET quantity = inventory.quantity + 1;
COMMIT;
Everything between BEGIN TRANSACTION and COMMIT is treated as one indivisible unit. If anything fails before COMMIT is reached — a crash, a dropped connection, a constraint violation — the database automatically performs a rollback (undoing every write made inside the transaction, as if none of it had ever run).
Finding a match is itself a backend job, separate from both the account services above and the realtime game server from earlier chapters. A player clicks "Find Match"; the client sends its skill rating (often called MMR — a number the game maintains that estimates how strong a player is, used to keep matches fair) to a matchmaking service, which holds every waiting player in a queue and groups them into balanced matches.
Some games add a lobby service: a pre-match staging area where a party can invite friends, pick loadouts, and ready up before a match even starts. It is its own small backend service too, not the game server, because nothing in a lobby needs a 60-times-a-second tick — just occasional updates, the same as any other backend endpoint.
A game with millions of concurrent players cannot run any single service, including Auth or Shop, as one process — no single machine can answer that many requests fast enough. The fix is to run many identical copies, called instances, of each service, with a load balancer (a service that receives every incoming request and forwards it to one of the available instances) sitting in front of them.
This only works if every instance is a stateless service (an instance that keeps no player-specific memory between requests — everything it needs either arrives inside the request itself, like the auth token, or is fetched fresh from the shared database or cache). Because no instance privately remembers any one player, the load balancer is free to send any request to any instance, and adding capacity is as simple as starting more identical copies — called horizontal scaling.
Two more tools handle scale beyond just adding more stateless instances. Sharding splits one large database into several smaller ones, each holding a slice of the data — players 1 through 1,000,000 on shard A, players 1,000,001 through 2,000,000 on shard B, and so on. A single database machine has a ceiling on how much data and how many queries per second it can handle; sharding spreads both the storage and the load across several machines. The trade-off is that a query needing data from two players on two different shards is much harder — most games pick a shard key, often player_id or region, that keeps everyday queries inside a single shard.
Caching attacks a different cost: many reads ask for the exact same "hot" data over and over — a leaderboard's top 100, an auth token check on nearly every request. Redis (a very fast in-memory key-value database, commonly placed in front of a slower main database as a cache) answers those repeat reads without ever touching the slower database.
// Pseudocode -- the "cache-aside" pattern
public Player GetPlayer(int playerId)
{
Player cached = redis.Get<Player>("player:" + playerId);
if (cached != null)
return cached; // cache hit -- no database query
Player fromDb = db.QueryOne(
"SELECT * FROM players WHERE id = @id", playerId);
redis.Set("player:" + playerId, fromDb, expiresIn: TimeSpan.FromMinutes(5));
return fromDb; // cache miss -- filled in for next time
}
A real backend is not one program — it is several small services (Auth, Shop, Inventory, Matchmaking), an approach called microservices (splitting a backend into small, independently deployable services, each responsible for one area, instead of one giant program that does everything). Splitting things up means the Shop team can deploy a fix without touching Auth at all, but it also means a client would otherwise need to know the address of every single service. A gateway (a single, well-known entry point every client request goes to first, before being routed onward) removes that need.
The gateway also handles cross-cutting jobs so individual services don't each reinvent them: checking that a token is present and valid before forwarding anything, decrypting TLS (the encryption that keeps requests private in transit), and rate limiting (rejecting a client that is sending far more requests than a normal player ever would, a first line of defense covered more fully in the anti-cheat chapter). Only after all of that does a request ever reach Shop Service, Inventory Service, or any other individual service, which then reads or writes the shared database, possibly by way of a cache.
Mobile networks drop packets constantly. Picture this exact order of events: the client sends POST /shop/buy; the Shop Service runs Section 9's transaction successfully, deducting gold and adding the item; it sends back 200 OK — but that response never reaches the client, lost somewhere on the way back. The client, having seen no response, assumes the request failed and automatically retries the exact same POST /shop/buy. If the handler just reruns from scratch, the player is charged for the sword twice, for one purchase they made once.
The fix is idempotency (a request is idempotent when sending the exact same request multiple times has the same effect as sending it once), built using an idempotency key (a unique id the client generates once per genuine attempt, sent alongside the request; the server remembers which keys it has already handled, and for a repeat, returns the original result instead of repeating the work).
CREATE TABLE processed_requests (
idempotency_key TEXT PRIMARY KEY,
player_id INTEGER NOT NULL,
result_json TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
public BuyResult HandleBuy(int playerId, string itemId, string idempotencyKey)
{
// Have we already handled this exact attempt before?
ProcessedRequest existing = db.QueryOne(
"SELECT result_json FROM processed_requests WHERE idempotency_key = @k",
idempotencyKey);
if (existing != null)
return BuyResult.FromJson(existing.result_json); // same answer, no re-charge
BuyResult result = DoTheActualPurchase(playerId, itemId); // Section 9's transaction
db.Execute(
"INSERT INTO processed_requests (idempotency_key, player_id, result_json) VALUES (@k, @p, @r)",
idempotencyKey, playerId, result.ToJson());
return result;
}
Expected trace:
Attempt 1 (key = "req-9f2a"): runs the purchase, gold 450 --> 350,
response sent but lost in transit
Attempt 2 (key = "req-9f2a", automatic retry): finds "req-9f2a" already
processed, returns the SAME stored result,
gold stays at 350
The client generates the key once per genuine tap — a GUID is enough — and reuses that same key for every automatic retry of that one tap; a brand-new tap next time generates a brand-new key, so it is never blocked.
ClaimDailyReward so the server decides the reward amount and checks eligibility itself, and the client's request carries no reward numbers at all.
// BAD -- the server just believes whatever the client says the reward is
public void ClaimDailyReward(int playerId, int rewardGold)
{
Player player = db.GetPlayer(playerId);
player.Gold += rewardGold;
db.Save(player);
}
// Client call:
// POST /reward/claim { "playerId": 42, "rewardGold": 5000 }
// GOOD -- client sends only an intent; server decides the amount
// and checks whether today's reward was already claimed.
public ClaimResult ClaimDailyReward(int playerId)
{
Player player = db.GetPlayer(playerId);
if (player.LastRewardClaimDate == Today())
return ClaimResult.Fail("already claimed today");
int reward = RewardTable.GetDailyReward(player.LoginStreakDays); // server decides
player.Gold += reward;
player.LastRewardClaimDate = Today();
db.Save(player);
return ClaimResult.Ok(reward);
}
// Client call now carries no numbers at all:
// POST /reward/claim { "playerId": 42 }
The client no longer sends rewardGold at all — it only asks to claim. The server looks up the real reward amount from its own RewardTable and checks LastRewardClaimDate itself, so nothing about the outcome depends on what the client sent. Same rule as Section 5: the request describes an intent, and the server computes the result.
'hat_003' (price 250 gems): it must deduct the price, add the ownership row, and make sure a crash midway cannot leave the player charged with nothing to show for it.
CREATE TABLE players (
id INTEGER PRIMARY KEY,
gems INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE cosmetics (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
price INTEGER NOT NULL
);
CREATE TABLE owned_cosmetics (
player_id INTEGER NOT NULL REFERENCES players(id),
cosmetic_id TEXT NOT NULL REFERENCES cosmetics(id),
PRIMARY KEY (player_id, cosmetic_id)
);
BEGIN TRANSACTION;
UPDATE players
SET gems = gems - 250
WHERE id = 7 AND gems >= 250;
-- if the UPDATE above matched zero rows, player 7 did not have
-- enough gems; the application checks the affected-row count here
-- and calls ROLLBACK instead of continuing:
-- if (rowsAffected == 0) { ROLLBACK; return Fail("not enough gems"); }
INSERT INTO owned_cosmetics (player_id, cosmetic_id)
VALUES (7, 'hat_003');
COMMIT;
The gems >= 250 condition in the WHERE clause means the UPDATE quietly touches zero rows instead of ever going negative when the player is short on gems. Wrapping both statements between BEGIN TRANSACTION and COMMIT means that if the process crashes or the connection drops between the UPDATE and the INSERT, the database rolls the gem deduction back too — the player is never left charged with no hat to show for it, exactly the failure traced in Section 9.
// BAD -- retrying this request grants the reward a second time
public ClaimResult ClaimStreakReward(int playerId)
{
Player player = db.GetPlayer(playerId);
player.Gold += 100;
db.Save(player);
return ClaimResult.Ok(100);
}
// GOOD -- a repeated request with the same key returns the
// original result instead of granting the reward again.
public ClaimResult ClaimStreakReward(int playerId, string idempotencyKey)
{
ProcessedRequest existing = db.QueryOne(
"SELECT result_json FROM processed_requests WHERE idempotency_key = @k",
idempotencyKey);
if (existing != null)
return ClaimResult.FromJson(existing.result_json); // same answer, no re-grant
Player player = db.GetPlayer(playerId);
player.Gold += 100;
db.Save(player);
ClaimResult result = ClaimResult.Ok(100);
db.Execute(
"INSERT INTO processed_requests (idempotency_key, player_id, result_json) VALUES (@k, @p, @r)",
idempotencyKey, playerId, result.ToJson());
return result;
}
// Client generates ONE key per genuine tap and reuses it on every retry:
// POST /reward/claim-streak { "playerId": 42, "idempotencyKey": "req-7f3c" }
The first call with key "req-7f3c" runs normally and stores its result under that key. If the response is lost and the client retries with the exact same key, the second call finds the stored row and returns it directly — player.Gold is only ever touched once for that key. A genuinely new claim, like tomorrow's streak reward, uses a brand-new key generated fresh, so it is never blocked by an old one.