14.4 Live-Ops, Gacha & In-Game Economy

Phase 14 · Networking & Live-Service · Study time: 30–50 h

Running a game as a service — events, content updates, the gacha and reward systems, and the economy tech behind monetization.

Earlier sections of this chapter were about the game you ship on day one. This section is about what happens after that: a live game keeps changing after launch, week after week, for years, without the player ever reinstalling anything. HoYoverse's Genshin Impact and Honkai: Star Rail run on exactly this model — new story chapters, new playable characters, and new limited-time events on a fixed schedule, funded mostly by a gacha system that sells randomized character and weapon pulls. This section covers how that ongoing service is actually built: content that ships through server config instead of new code, a gacha roll that must run on the server and never on the player's device, the pity system that puts a hard cap on bad luck, the currency economy that keeps prices stable for years, and the regulatory rules studios have to follow while doing all of it.

1. What "Live-Ops" Means

Live-ops (short for live operations) means running a game as an ongoing service instead of a one-time product. An old boxed game shipped on a disc was finished the day it went on sale — maybe a patch or two later, then nothing. A live-ops game like Genshin Impact or Honkai: Star Rail is never "finished" in that sense. The version installed on a player's phone today is expected to look different in six months: new characters, new story chapters, new limited-time events, new balance changes — arriving on a regular schedule, for years, without the player ever buying a new copy of the game.

This changes who works on the game and for how long. A boxed game's team mostly disbands after shipping. A live-ops game keeps an entire team employed indefinitely: live-ops producers plan what ships and when, designers build new content, server engineers keep the backend running, and data analysts watch how players react to every change. The game itself is the product, but the ongoing schedule of updates is what keeps players coming back — and what keeps the game earning revenue — long after launch.

Tip A useful way to tell a live-ops game from a boxed one: ask what happens if the servers shut down. A boxed single-player game like a classic RPG still runs fine offline. A live-ops game like Genshin Impact stops working the moment its servers go dark, because the events, the gacha, and often even story progress depend on a live connection to a backend that keeps changing.

2. The Content Cadence: Patches, Events, Seasons, Banners

A live-ops team does not release content whenever it happens to be ready — it ships on a fixed, predictable cadence (a regular, repeating release schedule), because players plan their play time around it and the game's revenue depends on that rhythm never breaking. Four words describe the pieces of that schedule:

These four pieces overlap and repeat on a cycle. A typical six-week block might look like this:

week: 1 2 3 4 5 6 patch: [-- new playable character + story chapter shipped --] banner: [--- Character A banner ---][--- Character B banner ---] event: [-- side event --] [-- side event --] season: [------------------- Season pass, resets week 6 -----------------]

Nothing here is random — every box in that diagram was scheduled weeks or months in advance by the live-ops team, often planned a full season or two ahead so art, writing, translation, and QA all have time to finish before the date on the calendar. The player experience of "there's always something new to do" is the direct result of that planning, not a coincidence.

3. Data-Driven Content: Shipping Events Without New Code

Shipping a new client patch is slow: the build has to compile, QA has to test it, and on mobile it has to pass Apple's or Google's app store review — which can take anywhere from a few hours to several days, and can also simply get rejected. If every two-week banner rotation and every one-week event needed a full new app store submission, a live-ops team could never keep up with the cadence from Section 2.

The fix is data-driven content: designing the game so that a designer can turn a new event on, adjust a banner's featured characters, or change a drop rate by editing data (plain configuration — numbers, text, JSON) that the client downloads at runtime, instead of writing new code (compiled logic that has to ship inside the app itself and go through review). The client ships once with general-purpose systems — "a banner system," "an event system," "a battle pass system" — and those systems read whatever config the server currently hands them.


// A general-purpose event system, shipped once inside the client.
// It never mentions a specific event by name -- it just reads
// whatever EventConfig the server currently sends it.
[Serializable]
public class EventConfig
{
    public string EventId;
    public string DisplayName;
    public long StartUnixTime;
    public long EndUnixTime;
    public string BannerImageUrl;
    public List<string> FeaturedItemIds;
}

public class EventRunner
{
    private List<EventConfig> activeEvents = new List<EventConfig>();

    public void LoadEvents(List<EventConfig> configsFromServer, long nowUnixTime)
    {
        activeEvents.Clear();
        foreach (EventConfig cfg in configsFromServer)
        {
            if (nowUnixTime >= cfg.StartUnixTime && nowUnixTime < cfg.EndUnixTime)
            {
                activeEvents.Add(cfg);
            }
        }
    }

    public void PrintActiveEvents()
    {
        foreach (EventConfig e in activeEvents)
        {
            Console.WriteLine($"Active: {e.DisplayName} (id={e.EventId})");
        }
    }
}

Worked trace: suppose the server sends two events, a "Lantern Festival" running from time 1000 to 2000, and a "Winter Trials" running from 2500 to 3500. The client calls LoadEvents with the current time set to 1500:


var configs = new List<EventConfig> {
    new EventConfig { EventId="lantern", DisplayName="Lantern Festival",
                       StartUnixTime=1000, EndUnixTime=2000, FeaturedItemIds=new List<string>() },
    new EventConfig { EventId="winter", DisplayName="Winter Trials",
                       StartUnixTime=2500, EndUnixTime=3500, FeaturedItemIds=new List<string>() },
};

var runner = new EventRunner();
runner.LoadEvents(configs, nowUnixTime: 1500);
runner.PrintActiveEvents();

Expected output:


Active: Lantern Festival (id=lantern)

Only the Lantern Festival is active at time 1500, because 1500 falls between its start and end but falls before Winter Trials even starts. Nobody wrote an if (currentEvent == "Lantern Festival") anywhere in EventRunner — the exact same class runs every future event the design team dreams up, forever, as long as they can describe it as an EventConfig. Turning the Lantern Festival off next week and turning on a completely different event is a change to server data, live within minutes, with zero new client code and zero app store review.

Common mistake Hardcoding a new event as a special case in the client — a block of code that only exists to handle this one specific banner or event. It works for the first event, but the second event needs its own special-case block, and the tenth event needs a client so full of one-off code that nobody can safely change any of it. The fix is always the same: find the general shape ("a thing with a start time, an end time, and featured items") and build one system for that shape, then feed it different data forever.

4. Remote Config and Feature Flags

Remote config is the general name for any settings the client fetches from a server at runtime instead of baking into the build — the EventConfig list from Section 3 is one example, but the same idea covers gacha drop rates, shop prices, and difficulty tuning. A feature flag (a named on/off switch, controlled from the server, that decides whether a piece of already-shipped code is active) is the other half of the same idea: the code for a feature can ship inside the client weeks before it is meant to be visible, sitting dormant until the server flips its flag on.


public class FeatureFlags
{
    private Dictionary<string, bool> flags;

    public FeatureFlags(Dictionary<string, bool> flagsFromServer)
    {
        flags = flagsFromServer;
    }

    public bool IsEnabled(string flagName)
    {
        return flags.TryGetValue(flagName, out bool value) && value;
    }
}

// Later, anywhere in the client:
var flags = new FeatureFlags(new Dictionary<string, bool> {
    { "winter_event_2026", false },
    { "new_banner_ui", true },
});

if (flags.IsEnabled("winter_event_2026"))
{
    Console.WriteLine("Showing Winter Event button");
}
else
{
    Console.WriteLine("Winter Event button hidden");
}

Expected output:


Winter Event button hidden

The winter event's code and assets can already be sitting inside the installed app right now — the flag is simply off. On launch day, the live-ops team flips winter_event_2026 to true on the server, and every connected client starts showing it within minutes, with no new download and no app store review, because nothing about the client itself changed. Feature flags also let a studio do a staged rollout — turning a flag on for 5% of players first, watching for crashes or complaints, then raising it to 100% only once it looks safe — and act as a kill switch if something does go wrong: flip the same flag back to false and the broken feature disappears immediately, without an emergency patch.

Putting Sections 3 and 4 together gives the full live-ops content pipeline — the path a new event or banner takes from a designer's plan to a player's screen:

[Designers / live-ops team] | | author event data, banner data, drop rates, flags v [Live-ops CMS / backend tool] | | publish v [Config service] --------------------> [CDN] | | | (also stores flag state) | client downloads config v v [Game server] [Game client on player device] | | | server enforces rules | reads EventConfig list, | (Section 5 onward: gacha rolls, | checks feature flags, | currency, purchases all happen | shows/hides UI accordingly | here, never on the client) | v v [Player receives results, sees active events/banners] | v [Telemetry / analytics] ------ feeds back to ------> [Designers / live-ops team]

Two loops matter here. The top loop is how content gets out: designers write data, not code, and it reaches the client through a config service and a CDN (a content delivery network — servers spread around the world that serve files quickly to nearby players). The bottom loop is how the team learns whether the content worked: every player action gets logged as telemetry, which flows back to the same designers who planned the event, closing the loop. Section 11 covers that feedback loop in detail.

5. Gacha Systems and Why the Roll Must Run on the Server

A gacha system (named after Japanese gachapon capsule-toy machines) is a mechanic where a player spends currency to receive one random item from a defined pool, with a published chance per rarity tier (a category like 3-star, 4-star, 5-star that groups items by how rare and powerful they are). It is the core monetization mechanic behind games like Genshin Impact and Honkai: Star Rail — players pull for a chance at specific characters or weapons rather than buying them directly.

The single most important engineering rule for a gacha system: the roll must happen on the server, never on the player's device. Think through what a client-side roll would mean:

CLIENT-SIDE ROLL (wrong): player taps "Pull" | v client's own code picks the random result | v client tells the server "I got a 5-star, please give it to me" | v a modified client can just always send "I got a 5-star" -- the server has no way to tell a real roll from a fabricated message
SERVER-SIDE ROLL (correct): player taps "Pull" | v client sends only a request: "spend N currency, roll on banner X" | v SERVER checks the player has enough currency, SERVER picks the random result, SERVER updates the pity counter, SERVER deducts currency and grants the item -- all in one operation | v server sends the already-decided result back to the client, which only has to display it

The client in a game like this typically runs on a device the studio does not fully control — a player's own phone or PC, which the player can inspect with a debugger, a memory editor, or a modified network client. Any decision made purely on the client (the random roll, whether an item was granted, how much currency was spent) can be tampered with by exactly that kind of tool. This is the same lesson earlier networking chapters called server-authoritative design (the server holds the one true copy of game state, and the client is only allowed to ask for changes, never make them directly) — a gacha system is one of the highest-stakes places to apply it, because real money is involved.

Common mistake "The client rolls, and the server just double-checks the result looks reasonable afterward." This is still broken — a modified client can send a result that looks perfectly reasonable (a common 3-star item, say, right up until it decides to send a 5-star once every few pulls at a rate the server's plausibility check does not catch). The only version of this that is actually safe is the server generating the random number and deciding the outcome itself, with the client never being asked to report what happened, because it never decided what happened in the first place.

6. Weighted Random Draws and Published Drop Rates

A gacha pool is not a simple coin flip between equally likely items — each item, or more commonly each rarity tier, has its own weight (a number proportional to how likely that item is to be picked; weights do not need to add up to 100, only their ratios matter) and studios are increasingly required to publish the resulting drop rate (the actual probability, usually shown as a percentage, of receiving an item from a given tier on a single pull) directly in the game. Section 12 covers why that disclosure exists as more than a courtesy.

The standard way to turn a list of weights into a single random pick is the cumulative weight method: lay every item's weight end to end along a number line, pick one random point on that line, and see which item's slice it lands in.


public class GachaItem
{
    public string Id;
    public string Rarity;   // "3-star", "4-star", "5-star"
    public int Weight;      // relative weight, not a percentage
}

public class GachaPool
{
    public List<GachaItem> Items = new List<GachaItem>();

    public int TotalWeight()
    {
        int sum = 0;
        foreach (GachaItem item in Items) sum += item.Weight;
        return sum;
    }

    // roll is an integer already chosen in [0, TotalWeight()) by the caller --
    // kept separate from the random number generator itself so this method
    // can be tested with an exact, known input (see the worked trace below).
    public GachaItem PickByRoll(int roll)
    {
        int cursor = 0;
        foreach (GachaItem item in Items)
        {
            cursor += item.Weight;
            if (roll < cursor) return item;
        }
        throw new InvalidOperationException("roll was outside total weight");
    }
}

Worked trace: a tiny pool with three items and weights 940, 51, 9 (total weight 1000), fed four specific roll values instead of real random numbers, so every result can be checked by hand:


var pool = new GachaPool();
pool.Items.Add(new GachaItem { Id="common_sword",  Rarity="3-star", Weight=940 });
pool.Items.Add(new GachaItem { Id="rare_bow",      Rarity="4-star", Weight=51 });
pool.Items.Add(new GachaItem { Id="legendary_gem", Rarity="5-star", Weight=9 });

int[] scriptedRolls = { 100, 950, 991, 945 };
foreach (int roll in scriptedRolls)
{
    GachaItem result = pool.PickByRoll(roll);
    Console.WriteLine($"roll={roll} -> {result.Id} ({result.Rarity})");
}

Expected output:


roll=100 -> common_sword (3-star)
roll=950 -> rare_bow (4-star)
roll=991 -> legendary_gem (5-star)
roll=945 -> rare_bow (4-star)

Trace it by hand to see why: the three items claim the ranges [0, 940) for the 3-star, [940, 991) for the 4-star, and [991, 1000) for the 5-star, because each item's slice starts where the previous one's cursor stopped. A roll of 100 lands well inside the first range. A roll of 950 is past 940 but before 991, so it lands in the 4-star's slice. A roll of 991 lands exactly on the boundary, which the check roll < cursor puts inside the 5-star's slice (991 is not less than 991, the 4-star's cursor, but it is less than 1000, the 5-star's). The real server picks that integer with a proper random number generator seeded from a secure source, uniformly across [0, TotalWeight()) — but the picking logic itself is exactly this cumulative-weight walk, every time.

Turning those weights into the drop rate a studio actually publishes is one division: 9 out of 1000 total weight is a 0.9% base rate for the 5-star tier on a single pull. That single number — 0.9% — is what regulations in Section 12 require some studios to print directly on the pull screen.

7. The Pity Counter: Guaranteeing a Rare by N Pulls

A pure weighted roll at a 0.9% rate has an ugly property on its own: it is mathematically possible, if unlikely, to pull two hundred times in a row without ever hitting the 5-star tier. A pity counter (a running count of pulls since a player's last rare item, used to push the odds up, or eventually guarantee a result, the longer that streak runs) exists specifically to put a hard ceiling on that bad luck. Two thresholds usually work together:

pull requested | v pity_counter = pity_counter + 1 | v pity_counter >= HARD_PITY (90)? ---yes---> rate = 100% (guaranteed) | no v pity_counter >= SOFT_PITY (74)? ---yes---> rate = BASE_RATE + ramp(pity_counter) | no v rate = BASE_RATE (0.9%) | v roll a random number in [0.0, 1.0) | v roll < rate? ---yes---> grant 5-star, RESET pity_counter to 0 | no v grant a lower-rarity item, KEEP pity_counter as it is

As C# code, the same decision tree looks like this:


public class PityState
{
    public int PullsSinceLastFiveStar = 0;
}

public class PityConfig
{
    public double BaseFiveStarRate = 0.009; // 0.9%, matches Section 6's pool
    public int SoftPityStart = 74;          // rate starts ramping on this pull
    public int HardPityAt = 90;             // this pull is always a 5-star
    public double SoftPityStep = 0.06;      // rate added per pull inside soft pity
}

public class PityGacha
{
    // nextRoll returns a value in [0.0, 1.0); kept as a function parameter
    // so the worked trace below can feed it exact, known numbers.
    public static string RollOnePull(PityState pity, PityConfig cfg, Func<double> nextRoll)
    {
        int thisPullNumber = pity.PullsSinceLastFiveStar + 1;
        double rate = cfg.BaseFiveStarRate;

        if (thisPullNumber >= cfg.HardPityAt)
        {
            rate = 1.0;
        }
        else if (thisPullNumber >= cfg.SoftPityStart)
        {
            int stepsIntoSoftPity = thisPullNumber - cfg.SoftPityStart;
            rate = cfg.BaseFiveStarRate + stepsIntoSoftPity * cfg.SoftPityStep;
            if (rate > 1.0) rate = 1.0;
        }

        double roll = nextRoll();
        bool gotFiveStar = roll < rate;

        if (gotFiveStar)
        {
            pity.PullsSinceLastFiveStar = 0;
            return $"5-star! (pull #{thisPullNumber}, rate was {rate:P1})";
        }
        else
        {
            pity.PullsSinceLastFiveStar = thisPullNumber;
            return $"no 5-star (pull #{thisPullNumber}, rate was {rate:P1})";
        }
    }
}

Worked trace: a player is already 88 pulls into their pity counter (bad luck so far), and we feed three scripted rolls to see what the next three pulls do:


var pity = new PityState { PullsSinceLastFiveStar = 88 };
var cfg = new PityConfig();

double[] scriptedRolls = { 0.95, 0.95, 0.5 };
int i = 0;
Func<double> nextRoll = () => scriptedRolls[i++];

for (int n = 0; n < 3; n++)
{
    Console.WriteLine(PityGacha.RollOnePull(pity, cfg, nextRoll));
}

Expected output:


no 5-star (pull #89, rate was 90.9%)
5-star! (pull #90, rate was 100.0%)
no 5-star (pull #1, rate was 0.9%)

Pull 89 falls inside soft pity (15 steps past pull 74, so the rate has already climbed to 0.9% + 15 * 6% = 90.9%) but a roll of 0.95 still isn't below even that high a rate, so it misses — proof that soft pity raises the odds without guaranteeing anything by itself. Pull 90 hits hard pity, where the code forces rate = 1.0, so the same kind of high roll (0.95) now counts as a win instead — 0.95 < 1.0 is true no matter what the roll is. The counter resets to 0, and the third pull starts over at the ordinary 0.9% base rate, where a roll of 0.5 easily misses again.

Tip Store PullsSinceLastFiveStar on the server, attached to the player's account, the same as currency (Section 9) — never inside a value the client sends up with each request. If the client could report its own pity count, a modified client could simply always claim to be on pull 90.

Putting the Weighted Draw and Pity Together

Sections 6 and 7 answer two separate questions: Section 6 decides which specific item comes out of a pool once you know the rarity tier, and Section 7 decides whether this pull hits the rare tier at all. A real server-side gacha roll composes both, one right after the other, in a single operation:


public class GachaService
{
    // Separate weighted pools per tier -- each one uses the exact same
    // cumulative-weight PickByRoll from Section 6.
    public GachaPool FiveStarItems;
    public GachaPool StandardItems; // everything below 5-star

    public GachaItem RollPull(PityState pity, PityConfig cfg, Random rng)
    {
        int thisPullNumber = pity.PullsSinceLastFiveStar + 1;
        double rate = cfg.BaseFiveStarRate;

        if (thisPullNumber >= cfg.HardPityAt)
        {
            rate = 1.0;
        }
        else if (thisPullNumber >= cfg.SoftPityStart)
        {
            int stepsIntoSoftPity = thisPullNumber - cfg.SoftPityStart;
            rate = cfg.BaseFiveStarRate + stepsIntoSoftPity * cfg.SoftPityStep;
            if (rate > 1.0) rate = 1.0;
        }

        bool gotFiveStar = rng.NextDouble() < rate;

        if (gotFiveStar)
        {
            pity.PullsSinceLastFiveStar = 0;
            int roll = rng.Next(0, FiveStarItems.TotalWeight());
            return FiveStarItems.PickByRoll(roll);
        }
        else
        {
            pity.PullsSinceLastFiveStar = thisPullNumber;
            int roll = rng.Next(0, StandardItems.TotalWeight());
            return StandardItems.PickByRoll(roll);
        }
    }
}

This version uses a real System.Random (in production, a cryptographically secure random source is the safer choice, so nobody can predict upcoming rolls from watching past ones) instead of the scripted Func<double> from the worked trace above. That is on purpose: the scripted version exists only so this chapter's examples produce output you can check by hand, while GachaService.RollPull is the shape that actually runs on a production server, called once per pull, entirely inside the server process, with the client only ever receiving the finished GachaItem result.

8. A Worked Probability Example

It helps to work through the actual arithmetic behind "I have bad luck" once, with a simplified version of the numbers, rather than trust the feeling. Take a flat (no pity yet) base rate of 2% per pull, and ask: what is the probability of getting at least one 5-star within the first 10 pulls?

The easy trap is adding 2% ten times and expecting 20%. That is wrong, because it ignores that pulls are independent events — instead, work with the opposite question first: what is the probability of missing on every single one of the 10 pulls?

P(miss one pull) = 1 - 0.02 = 0.98 P(miss all 10 pulls) = 0.98 ^ 10 = 0.98 * 0.98 * 0.98 * ... (10 times) ~ 0.817 P(at least one hit in 10 pulls) = 1 - P(miss all 10) = 1 - 0.817 = 0.183 -> about 18.3%

So even at a 2% per-pull rate, ten pulls only give an 18.3% chance of success, not 20% — a difference that only grows as the pull count grows, because "missing every single time" gets less and less likely, but never in a straight line. This is exactly why hard pity (Section 7) exists as a separate mechanic rather than trusting the base rate alone: without it, a small but real fraction of players — roughly 1 in 5 in a simplified example like this, worse at the real 0.9% rate — would still be empty-handed after a stretch of pulls most players consider "should have hit by now," with no guarantee they ever will. Hard pity turns "probably, eventually" into "absolutely, by pull 90," which is a very different promise to make to a paying player.

Common mistake Treating a 0.9% base rate as "1-in-111, so I'm due after 100 pulls." Each pull is independent — the random number generator has no memory of previous pulls and does not owe anyone a win. The only thing that actually changes the odds as pulls accumulate is the pity system itself deliberately raising the rate on purpose, which is a designed rule, not a property of randomness catching up with you.

9. Virtual Economy Design

9.1 Soft Currency vs Premium (Hard) Currency

Almost every live-ops game runs at least two separate currencies:

Keeping these separate lets a studio tune each one independently. Soft currency can be handed out generously — it drives everyday engagement and never directly costs the studio anything to give away. Premium currency has to stay scarce and its price in real money has to stay meaningful, because it is the actual product being sold. Mixing the two into a single currency would force an uncomfortable choice: either make gameplay rewards stingy (to protect the value of paid currency) or make paid currency too easy to earn for free (undercutting the store).


public class PlayerWallet
{
    public long SoftCurrency;    // e.g. "Coins" -- earned by playing
    public long PremiumCurrency; // e.g. "Crystals" -- bought or rarely gifted

    // Two separate methods on purpose -- never one generic
    // AddCurrency(type, amount) that treats both the same way.
    public void EarnSoftCurrency(long amount)
    {
        SoftCurrency += amount;
    }

    public void GrantPremiumCurrency(long amount, string reason)
    {
        PremiumCurrency += amount;
        Console.WriteLine($"Granted {amount} premium currency: {reason}");
    }
}

var wallet = new PlayerWallet();
wallet.EarnSoftCurrency(500);
wallet.GrantPremiumCurrency(100, "first-time login bonus");
Console.WriteLine($"Coins: {wallet.SoftCurrency}, Crystals: {wallet.PremiumCurrency}");

Expected output:


Granted 100 premium currency: first-time login bonus
Coins: 500, Crystals: 100

9.2 Sinks and Faucets: Keeping the Economy in Balance

Every source that adds currency into the game is a faucet (daily login rewards, quest completions, event rewards). Every place that removes currency from the game is a sink (gacha pulls, gear upgrades, cosmetic purchases). This is the same problem a country's central bank worries about: if faucets consistently outpace sinks, players accumulate more currency than the game gives them a reason to spend, prices feel meaningless, and the premium currency in particular loses its "special" feel — the same failure mode as real-world inflation. If sinks outpace faucets, players run dry, feel punished for playing normally, and quit.

FAUCETS SINKS (currency enters economy) (currency leaves economy) daily login reward -----> quest completion -----> [ Player's ] -----> gacha pulls event rewards -----> [ Wallet ] -----> gear upgrades achievement bonus -----> [ ] -----> cosmetic shop total faucet output per week should stay roughly matched to total sink capacity per week -- tracked continuously, not guessed once

A live-ops team tracks this with real numbers, not guesswork: sum every faucet's expected output per week across the whole player base, sum every sink's expected drain, and watch the difference over time.


public class EconomyReport
{
    public static void CheckBalance(long totalFaucetOutput, long totalSinkCapacity)
    {
        long difference = totalFaucetOutput - totalSinkCapacity;
        double percentOff = (double)difference / totalSinkCapacity * 100.0;

        if (Math.Abs(percentOff) < 5.0)
        {
            Console.WriteLine($"Balanced: faucets vs sinks off by {percentOff:F1}%");
        }
        else if (percentOff > 0)
        {
            Console.WriteLine($"Warning: faucets exceed sinks by {percentOff:F1}% -- inflation risk");
        }
        else
        {
            Console.WriteLine($"Warning: sinks exceed faucets by {Math.Abs(percentOff):F1}% -- currency drought risk");
        }
    }
}

EconomyReport.CheckBalance(totalFaucetOutput: 1_040_000, totalSinkCapacity: 1_000_000);

Expected output:


Balanced: faucets vs sinks off by 4.0%

Real studios run this kind of check continuously against live telemetry (Section 11), not just once — a new event that hands out an unusually generous soft-currency reward, or a new gear tier that suddenly costs much less to upgrade, can throw the balance off within days, and a live-ops team wants to catch that in a dashboard, not from players complaining on social media weeks later.

10. Server-Authoritative Transactions and Receipts

The same server-authoritative rule from Section 5 applies to every currency change, not just gacha rolls: the client never directly sets its own currency total. It sends a request; the server decides whether to approve it, applies the change, and tells the client the new state.

Real-money purchases add one more required step: receipt validation. When a player buys premium currency, the app store (Apple's App Store, Google Play) is the one actually charging the player's card, and it hands the app a receipt (a signed proof-of-purchase token) — but that receipt first has to travel back through the game's own server and get verified with the app store's servers before any currency is granted, specifically so a modified client cannot simply fabricate a fake receipt and grant itself currency for free.

player taps "Buy 1000 Crystals for $9.99" | v device's OS purchase dialog charges the player's payment method | v app store returns a signed RECEIPT to the client | v client sends the receipt to the GAME SERVER (never trusts it locally) | v game server sends the receipt to APPLE/GOOGLE's servers to verify: - is this receipt genuine? - has this exact receipt already been redeemed before? | v valid AND not already redeemed? | | yes no | | v v grant 1000 Crystals, reject -- log the attempt, record the receipt ID grant nothing as "already redeemed"

Recording every receipt ID once it has been redeemed is what makes this operation idempotent (running it more than once on the same input gives the same result as running it once). A tiny transaction ledger (a record of every redeemed receipt) makes this concrete:


public class ReceiptLedger
{
    private HashSet<string> redeemedReceiptIds = new HashSet<string>();

    public bool TryRedeem(string receiptId, long crystalAmount, PlayerWallet wallet)
    {
        if (redeemedReceiptIds.Contains(receiptId))
        {
            Console.WriteLine($"Rejected: receipt {receiptId} already redeemed");
            return false;
        }

        wallet.GrantPremiumCurrency(crystalAmount, "purchase " + receiptId);
        redeemedReceiptIds.Add(receiptId);
        return true;
    }
}

var ledger = new ReceiptLedger();
var wallet2 = new PlayerWallet();
ledger.TryRedeem("receipt_A1", 1000, wallet2);
ledger.TryRedeem("receipt_A1", 1000, wallet2); // the same receipt sent again

Expected output:


Granted 1000 premium currency: purchase receipt_A1
Rejected: receipt receipt_A1 already redeemed

A network hiccup that makes the client resend the same receipt twice, or a malicious client that intentionally replays an old receipt, both get rejected on the second attempt, because the server already has that exact receipt ID marked as spent. This is the same idea an earlier persistence chapter used for a save system's write-ahead log — a permanent, append-only record that lets a support team answer "where did this player's currency go" months later by simply reading the record instead of guessing.

Common mistake Granting currency the moment the client reports "purchase successful," before the server has verified the receipt with the app store. This is the single most common real-world gacha exploit: a modified client, or a network tool that intercepts and rewrites the app's traffic, sends a fake "purchase successful, here is a receipt" message that was never actually charged by the app store at all. Verification against the app store's own servers, not just trusting whatever the client claims, is not optional.

11. A/B Testing, Analytics, and Telemetry

Telemetry (a stream of events the client and server log automatically as players do things — opened the shop, started a pull, finished a level) is how a live-ops team finds out whether an event actually worked, instead of guessing. A typical event gets logged as a small structured record:


public class TelemetryEvent
{
    public string EventName;      // "gacha_pull_started", "shop_opened", ...
    public string PlayerId;
    public long TimestampUnix;
    public Dictionary<string, object> Params;
}

var evt = new TelemetryEvent {
    EventName = "gacha_pull_started",
    PlayerId = "player_4471",
    TimestampUnix = 1_737_020_000,
    Params = new Dictionary<string, object> {
        { "banner_id", "characterA_banner" },
        { "pull_count", 10 },
        { "currency_spent", 1600 },
    }
};
Console.WriteLine($"{evt.EventName} by {evt.PlayerId}: banner={evt.Params["banner_id"]}, pulls={evt.Params["pull_count"]}");

Expected output:


gacha_pull_started by player_4471: banner=characterA_banner, pulls=10

Multiplied across millions of players, these events answer questions like: how many players open the shop but never buy anything (a funnel, the drop-off between one step and the next)? What fraction of players are still playing one day, seven days, thirty days after installing (D1/D7/D30 retention)? How much does the average paying player spend (ARPPU, average revenue per paying user)?

A/B testing uses the same feature-flag mechanism from Section 4 to answer a sharper question: does changing one specific thing actually help? Instead of one flag being simply on or off for everyone, players are split into groups by hashing their player ID into a consistent bucket, so the same player always lands in the same group across sessions. This has to use a hash that stays the same every time the game runs — not string.GetHashCode(), which .NET intentionally randomizes differently every time the process starts, so it must never be used for anything that has to stay consistent across sessions:


public static int StableHash(string input)
{
    int hash = 0;
    foreach (char c in input)
    {
        hash = (hash * 31 + c) % 1000000;
    }
    return Math.Abs(hash);
}

public static string AssignVariant(string playerId, int percentInVariantB)
{
    int bucket = StableHash(playerId) % 100; // 0..99, stable across runs
    return bucket < percentInVariantB ? "B" : "A";
}

string[] players = { "alice", "bob", "carol", "dave" };
foreach (string p in players)
{
    Console.WriteLine($"{p} -> variant {AssignVariant(p, percentInVariantB: 50)}");
}

Expected output:


alice -> variant B
bob -> variant B
carol -> variant B
dave -> variant A

Working the hash by hand confirms it: alice lands in bucket 40, bob in bucket 17, carol in bucket 9, all below the 50 cutoff and so all "B," while dave lands in bucket 76 and gets "A." With only four sample players the split does not look anywhere near 50/50 — that only happens once you bucket thousands of players, the exact same reason Section 8's coin-flip math needed many pulls before the true rate reliably showed up. Variant A might see a banner's original artwork; Variant B sees a redesigned banner screen. Both groups play normally, telemetry logs how much each group actually pulls and spends, and after enough players have passed through both variants, the live-ops team compares the two groups' numbers to decide which version ships to everyone.

Tip Bucketing by a stable hash of the player ID (rather than a fresh random choice every session) is what keeps a player in the same variant every time they log in. Without that consistency, a player could see a different UI every session, which breaks the experiment (their behavior is now a reaction to an inconsistent experience) as much as it breaks trust.

12. Ethics and Regulation: Disclosed Odds, Spending Limits, Loot-Box Laws

A gacha system is, mechanically, the same thing as a loot box or a slot machine: pay something of value for a randomized reward. Regulators in a growing number of countries treat it that way, and the rules below are not optional extras — they are legal requirements in many of the markets a game like Genshin Impact actually ships in.

None of this is about a mechanic being inherently dishonest — a published drop rate and a hard pity ceiling are, mechanically, a fair deal exactly because they are disclosed and bounded: a player who chooses to pull can look up in advance the worst case they might face and the exact odds they are accepting. The ethical failure mode live-ops teams are expected to avoid is the opposite: hiding real odds, engineering "near-miss" visuals designed to make a loss feel like it was almost a win, or using countdown timers and fabricated scarcity to pressure a purchase decision faster than a player would otherwise make it. A studio that publishes its rates plainly, keeps pity mechanics genuinely bounded, and respects the spending and age protections local law requires is not doing anything different, mechanically, from Sections 5 through 9 of this chapter — it is simply doing it transparently instead of hiding it.

Common mistake Treating "it's technically legal here" as the only bar to clear. Regulation is usually the floor, not the target — a studio that only ever does the legal minimum, in the jurisdiction with the loosest rules, is optimizing for a player base's trust the same way a studio that skips input validation is optimizing for a hacker never showing up. Both eventually get tested.

13. Glossary

14. Exercises

Exercise 1 — Extend the Weighted Pool The pool from Section 6 has three items with weights 940, 51, 9 (total 1000). Add a fourth item, "exclusive_skin", rarity "5-star-exclusive", with weight 1, without changing any of the other three weights. Write out the new cumulative ranges for all four items, then trace PickByRoll by hand for rolls 995 and 1000, showing which item each one returns.
Show answer

Adding the fourth item raises the total weight to 1001. Each item's range starts where the previous cursor stopped: common_sword claims [0, 940), rare_bow claims [940, 991), legendary_gem claims [991, 1000), and the new exclusive_skin claims [1000, 1001) — the smallest possible slice, matching its weight of 1.


pool.Items.Add(new GachaItem { Id="exclusive_skin", Rarity="5-star-exclusive", Weight=1 });

int[] scriptedRolls = { 995, 1000 };
foreach (int roll in scriptedRolls)
{
    GachaItem result = pool.PickByRoll(roll);
    Console.WriteLine($"roll={roll} -> {result.Id} ({result.Rarity})");
}

roll=995 -> legendary_gem (5-star)
roll=1000 -> exclusive_skin (5-star-exclusive)

995 is past 991 but before 1000, landing inside legendary_gem's range. 1000 fails legendary_gem's check (1000 < 1000 is false) but passes exclusive_skin's check (1000 < 1001 is true), landing in the tiny new slice — exactly as its weight of 1 out of 1001 total suggests it should almost never happen.

Exercise 2 — Trace the Pity Counter A player starts a session with PullsSinceLastFiveStar = 72 (using the same PityConfig defaults from Section 7: base rate 0.9%, soft pity starts at pull 74, soft pity step 6% per pull, hard pity at pull 90). They pull four times in a row, with scripted nextRoll values 0.95, 0.95, 0.03, 0.95. Using PityGacha.RollOnePull, work out by hand what each of the four pulls returns and what the pity counter is after each one.
Show answer

var pity = new PityState { PullsSinceLastFiveStar = 72 };
var cfg = new PityConfig();

double[] scriptedRolls = { 0.95, 0.95, 0.03, 0.95 };
int i = 0;
Func<double> nextRoll = () => scriptedRolls[i++];

for (int n = 0; n < 4; n++)
{
    Console.WriteLine(PityGacha.RollOnePull(pity, cfg, nextRoll));
}

no 5-star (pull #73, rate was 0.9%)
no 5-star (pull #74, rate was 0.9%)
5-star! (pull #75, rate was 6.9%)
no 5-star (pull #1, rate was 0.9%)

Pull 1 is pull number 73 (72 + 1), still below the soft pity threshold of 74, so the rate stays at the flat 0.9% and 0.95 misses easily. Pull 2 is pull number 74 — right at the soft pity threshold, but stepsIntoSoftPity is 0 there, so the rate is still exactly the base 0.9%, and it misses again. Pull 3 is pull number 75, one step into soft pity, raising the rate to 0.9% + 1 * 6% = 6.9% — this time the low roll of 0.03 is below that rate, so it hits, and the counter resets to 0. Pull 4 starts completely over at pull number 1, back at the base 0.9% rate, where 0.95 misses once more.

Exercise 3 — Find the Missing Check A junior server engineer wrote this purchase-handling endpoint (pseudocode) for granting premium currency after an in-app purchase. It works fine in normal testing, but a QA report says a tester was able to get the same 1000 Crystals reward by resending the exact same request three times. Read the code, explain what check is missing and why that lets the exploit happen, then write the corrected version.

function OnPurchaseReceiptReceived(playerId, receipt):
    isValid = VerifyReceiptWithAppStore(receipt)
    if not isValid:
        return Error("invalid receipt")

    GrantPremiumCurrency(playerId, receipt.crystalAmount)
    return Success("granted " + receipt.crystalAmount + " crystals")
Show answer

The function is missing the "already redeemed" check from Section 10's ReceiptLedger. VerifyReceiptWithAppStore only confirms that the receipt is a genuine, signed token the app store actually issued — it does not, and cannot, know whether the game server has already paid out for that same receipt once before. A perfectly genuine receipt stays genuine no matter how many times it gets checked, so resending the same valid receipt three times passes isValid three times, and GrantPremiumCurrency runs three times.


function OnPurchaseReceiptReceived(playerId, receipt):
    isValid = VerifyReceiptWithAppStore(receipt)
    if not isValid:
        return Error("invalid receipt")

    if ReceiptAlreadyRedeemed(receipt.id):
        return Error("receipt already redeemed")

    GrantPremiumCurrency(playerId, receipt.crystalAmount)
    MarkReceiptAsRedeemed(receipt.id)
    return Success("granted " + receipt.crystalAmount + " crystals")

The fix adds exactly the check ReceiptLedger.TryRedeem already does in Section 10: look up the receipt's unique ID in a permanent record of already-redeemed receipts before granting anything, and write the ID into that record the moment the grant happens. The second and third copies of the same request now fail the new check and grant nothing, while the very first one still succeeds normally — turning the endpoint idempotent, exactly as Section 10 defined the term.

← Back to all chapters