14.3 Backend, Servers & Databases

Phase 14 · Networking & Live-Service · Study time: 60–120 h

The server side of a live game — accounts, matchmaking, persistence and scaling to millions of concurrent players.

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.

1. What's Behind a "Live" Game

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:

+---------------------------+ | Your Device | | (the game client) | +-------------+-------------+ | +----------------+----------------+ | | v v +---------------------------+ +---------------------------+ | Game Server (realtime) | | Backend (HTTP services) | | - runs ONE live match | | - accounts / login | | - 20-60 updates/second | | - inventory / currency | | - UDP, built for speed | | - shop, leaderboards | | - see Ch. 14.1 and 14.2 | | - matchmaking, lobby | +---------------------------+ +---------------------------+

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.

2. The Game Server vs. Backend Services: Two Different Jobs

It helps to compare them directly, side by side, using what you already know about the game server from the previous chapters:

Game Server Backend Service lifetime one match, then gone forever (until account deleted) state in memory, temporary in a database, durable talks over UDP, custom binary HTTP, JSON (this chapter) frequency 20-60 times per second occasional -- once per action example data player position, hp gold balance, owned items, rank

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.

3. Talking Over HTTP: REST and JSON

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").

Tip A URL like /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.

4. Accounts and Login: Tokens and Sessions

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

1. Client --> Auth Service : POST /login { "user":"kay", "pass":"hunter2" } 2. Auth Service --> Database : look up "kay", check the stored password hash 3. Database --> Auth Service : row found, password matches 4. Auth Service --> Client : 200 OK { "token":"abc123..." } Later, on every OTHER request, the client sends the token instead of the password: 5. Client --> Inventory Service : GET /inventory Authorization: Bearer abc123... 6. Inventory Service --> Client : 200 OK { "items": [ ... ] } Inventory Service trusts the token instead of asking for the password again -- it only checks that the token is valid and has not expired.

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.

Tip Never return a different message for "no such user" versus "wrong password." Two different messages let an attacker discover which usernames actually exist on your server just by trying logins, one at a time.

5. Never Trust the Client: The Server Owns Your Stuff

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.

Common mistake Sending a "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.

6. Databases: Why Player Data Lives in One, Not in RAM

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

Backend instance A -->+ Backend instance B -->+ Backend instance C -->+---> Database Backend instance D -->+ It does not matter which instance answers a request -- all of them read and write the same shared database, so a player sees one consistent gold balance no matter which instance happened to handle the request.

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

7. SQL vs. NoSQL in Plain Terms

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 table (players) NoSQL document (a session) +----+----------+------+ { | id | username | gold | "sessionId": "abc123", +----+----------+------+ "playerId": 42, | 1 | kay | 350 | "expiresAt": "2026-07-18T10:00:00Z" | 2 | tim | 120 | } +----+----------+------+ fixed columns, every row flexible shape, fields can has exactly the same shape differ freely between entries

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.

Tip Most real live games use both at once: SQL for accounts, inventory, and currency, where a bug that silently duplicates gold is a disaster; NoSQL or an in-memory cache for sessions, leaderboards, or chat, where raw speed matters more and losing one entry occasionally is not a catastrophe.

8. A Tiny Schema and a Query

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.

9. Transactions: A Purchase Can't Half-Complete

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

WITHOUT a transaction, if the server crashes after step 1: 1. gold: 450 --> 350 (done) 2. add sword_01 to inventory (never happens -- crash!) Result: player lost 100 gold and got nothing. A real bug. WITH a transaction, if the server crashes after step 1: 1. gold: 450 --> 350 (done, but not committed yet) 2. add sword_01 to inventory (never happens -- crash!) The database automatically ROLLBACKs: gold goes back to 450. Result: exactly as if the purchase never started. No bug.
Common mistake Writing the gold update and the inventory insert as two separate, un-grouped statements "because they usually both succeed anyway." Rare failures are exactly what transactions are for — a purchase system that works 99.9% of the time still means real players losing currency for nothing at real scale.

10. Matchmaking and Lobby Services

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.

1. Client A, B, C --> Matchmaking Service : POST /matchmaking/queue { "mmr": 1500, "mode": "3v3" } 2. Matchmaking Service : holds every waiting player in a queue, groups three players with close mmr values into one balanced match 3. Matchmaking Service --> Allocator : reserve a Game Server for this match 4. Allocator --> Matchmaking Service : "serverIp": "10.0.4.7", "port": 7777 5. Matchmaking Service --> Client A, B, C : 200 OK { "serverIp": "10.0.4.7", "port": 7777, "token": "m-91a" } 6. Client A, B, C --> Game Server (10.0.4.7:7777) : connect directly, using the short-lived token from step 5 This last step reaches the realtime Game Server from Ch. 14.1 -- the Matchmaking Service itself is never involved in gameplay.

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.

11. Scaling: Stateless Services Behind a Load Balancer

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.

+------------------+ Clients ------> | Load Balancer | (millions) +---+----+----+----+ | | | +--------+ | +--------+ v v v Instance 1 Instance 2 Instance 3 (add more under load) | | | +--------------+--------------+ | v Shared Database

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.

12. Sharding and Caching With Redis

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
}
Request comes in for player 42 | v Check Redis cache for key "player:42" | +-- found (cache hit) --> return cached data immediately | (no database query at all) | +-- not found (cache miss) | v Query the SQL database for player 42 | v Store the result in Redis for next time | v Return the data to the caller
Common mistake Caching a value and then forgetting to update or remove it when the underlying data changes. If a player buys an item, but a cached copy of their profile still shows the old gold amount, they will see stale data until that cache entry expires or is explicitly invalidated (updated or removed on write, not just left to time out).

13. The Full Request Path: Client → Gateway → Services → Database

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.

Client | | every request goes to ONE address first v Gateway (checks the auth token, terminates TLS encryption, applies rate limits, then routes by URL) | +-- /login/* --> Auth Service -->+ +-- /shop/* --> Shop Service -->+ +-- /inventory/* --> Inventory Service -->+--> Database +-- /matchmaking/* --> Matchmaking Service -->+ (+ Redis cache in front of it)

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.

14. Reliability and Idempotency: A Retried "Buy" Must Not Charge Twice

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.

Tip Section 9's transaction and this section's idempotency key solve two different halves of the same problem. The transaction makes one request safe internally (all its writes happen together, or none do). The idempotency key makes repeating that same request safe too — no matter how many times the network causes it to be sent, its effect happens exactly once.

15. Glossary

16. Exercises

Exercise 1 — Fix the Untrusted Client The reward-claim handler below trusts a value the client sends directly, instead of computing it on the server. Rewrite 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 }
Show answer

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

Exercise 2 — Write the Purchase Transaction Given this schema, write a SQL transaction that lets player 7 buy the cosmetic '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)
);
Show answer

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.

Exercise 3 — Add Idempotency The handler below has the double-charge bug from Section 14: if the response is lost after a successful claim, the client's automatic retry grants the reward a second time. Add an idempotency key so a retried claim returns the original result instead of granting the reward again.

// 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);
}
Show answer

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

← Back to all chapters