SYSTEM_DESIGN

System Design: In-Game Purchase System

Design a reliable in-game purchase and virtual economy system supporting real-money transactions, virtual currency, item inventories, and fraud detection at scale for a live-service game.

16 min readUpdated Jan 15, 2025
system-designgamingpaymentsvirtual-economyfraud-detection

Requirements

Functional Requirements:

  • Players purchase virtual currency (gems, coins) with real money via credit card, PayPal, App Store, and Google Play
  • Players spend virtual currency to buy in-game items: cosmetics, battle passes, loot boxes, and consumables
  • Item inventory is persistent and viewable in real time across devices
  • Gifting: players can send purchased items to friends
  • Transaction history: complete purchase history with receipt IDs for customer support and chargebacks
  • Regional pricing: prices in local currencies with regional adjustments

Non-Functional Requirements:

  • Zero double-spend: virtual currency deducted exactly once per transaction
  • Transaction durability: no purchase is lost even during server crashes
  • Support 100,000 concurrent purchasing players during sale events
  • Payment processing latency: 95th percentile under 3 seconds end-to-end
  • Fraud detection: flag suspicious transactions within 1 second of submission

Scale Estimation

During a major sale event (Black Friday), peak purchase rate of 100k concurrent players attempting purchases simultaneously creates a burst of ~5,000 transactions/second (assuming 5% conversion rate per minute). Each transaction involves: payment gateway call (~1s latency), fraud check (~200ms), ledger write (atomic debit/credit), inventory update, and receipt generation. Inventory reads are much higher — 5M DAU each opening their inventory 3× per session = 15M reads/day = 174 reads/second average (easily cached). Virtual currency ledger: 100M players × 1 currency balance record = 100M rows in the currency table.

High-Level Architecture

The purchase system separates into three subsystems: the real-money payment subsystem, the virtual economy subsystem, and the inventory subsystem. All three must be ACID-compliant and integrated via a saga pattern to handle partial failures.

The real-money payment subsystem handles integration with payment gateways (Stripe, Apple IAP, Google Play Billing). Each platform has different callback mechanisms: web purchases go through Stripe webhooks, mobile purchases through App Store Server Notifications and Google Real-Time Developer Notifications. The payment service acts as a webhook receiver and idempotency manager — it deduplicates duplicate webhook deliveries (using the transaction ID as an idempotency key in Redis) and triggers the virtual currency credit only once per confirmed payment.

The virtual economy subsystem manages the virtual currency ledger. It uses a double-entry bookkeeping model: every currency change is recorded as a ledger entry with a debit (from one account) and credit (to another). Player accounts, the game operator's "bank" account, and promotional accounts all participate in the ledger. The ledger table in PostgreSQL uses SERIALIZABLE isolation for all balance-change transactions — preventing double-spend races at the database level. Currency debits (spending) use a SELECT FOR UPDATE on the player's balance row to prevent concurrent over-spend.

Core Components

Payment Gateway Integration Service

This service abstracts all payment provider differences behind a unified interface. For Stripe web payments: the client obtains a Stripe Payment Intent client secret from this service, completes the payment in the browser via Stripe Elements, and Stripe sends a payment_intent.succeeded webhook to the service. For Apple IAP: after the client completes an in-app purchase, it sends the receipt blob to this service, which validates the receipt against Apple's receipt verification API, extracts the product ID and transaction ID, and triggers the grant flow. All providers are handled via a strategy pattern, and all webhook endpoints are protected with provider-specific signature verification. An idempotency table (transaction_id → status) prevents re-processing duplicate webhooks.

Virtual Currency Ledger

The ledger is implemented as an append-only transaction log in PostgreSQL. Schema: ledger_entries (entry_id, player_id, amount, direction[credit/debit], reason, reference_id, created_at). Current balance is the sum of all entries for a player — never stored as a mutable field to prevent update races. For query efficiency, a currency_balances materialized table caches the current balance per player, updated via a trigger on ledger_entries inserts. Spending a currency amount uses a PostgreSQL transaction: SELECT balance (from materialized table) FOR UPDATE, validate balance >= amount, INSERT debit ledger entry, UPDATE materialized balance — all within one transaction with SERIALIZABLE isolation.

Fraud Detection Service

The fraud service evaluates each purchase attempt against a set of rules and an ML model before the payment gateway is called. Rule-based checks (fast, <10ms): velocity checks (more than 3 purchases in 1 minute from same IP), geolocation mismatch (player's registered country vs. payment card country), and device fingerprint blacklist. ML model (logistic regression on 50 features, ~150ms inference): features include purchase amount vs. player's historical average, account age, previous chargeback history, and session behavior before purchase. Risk score above 0.8 triggers a 3D Secure challenge; above 0.95 blocks the transaction and queues for manual review. The fraud model is retrained weekly on labeled chargeback data.

Database Design

PostgreSQL (ACID, single region for consistency): payments (payment_id, player_id, provider, provider_transaction_id, amount_usd_cents, currency_granted, status, created_at), ledger_entries (entry_id, player_id, currency_type, amount, direction, reason, reference_id, created_at), currency_balances (player_id, currency_type, balance, updated_at) — materialized cache, inventory_items (item_id, player_id, item_template_id, acquired_at, source, is_gifted_from_player_id), gift_transactions (gift_id, sender_id, recipient_id, item_id, status, sent_at). Redis: idempotency:{provider}:{transaction_id} (TTL 24h, prevents duplicate webhook processing), fraud:velocity:{player_id} (counter with 1-minute TTL), inventory_cache:{player_id} (serialized inventory, TTL 5 minutes).

API Design

  • POST /purchases/intent — body: {player_id, product_id, platform}, runs fraud pre-check, creates Stripe Payment Intent or returns App Store product ID, returns {client_secret, fraud_score}
  • POST /purchases/validate — body: {provider, receipt_or_transaction_id}, called after mobile IAP completion; validates receipt, idempotency check, triggers grant saga
  • POST /currency/spend — body: {player_id, amount, reason, reference_id}, atomically debits currency and returns new balance; idempotent on reference_id
  • GET /players/{player_id}/inventory — returns full item inventory; served from Redis cache, falls back to PostgreSQL
  • POST /gifts/send — body: {sender_id, recipient_id, item_id}, transfers item ownership atomically using a DB transaction

Scaling & Bottlenecks

The PostgreSQL ledger with SERIALIZABLE isolation is the throughput bottleneck at 5,000 transactions/second during peak. Each transaction holds a row lock on the player's currency_balances row for ~10ms (payment gateway validation is done before the DB transaction begins). At 5k TPS with 10ms hold time, the average lock wait is low assuming purchases are distributed across different players — which they are. The only high-contention scenario is when a global sale triggers simultaneous purchases from the same shared account (family sharing) — mitigate with a per-player advisory lock timeout (fail fast rather than queue indefinitely).

Inventory reads at 174/second are trivially served by Redis cache. The cache invalidation trigger (on inventory_items insert) must be reliable — use PostgreSQL NOTIFY and a cache invalidation subscriber process rather than relying on application-layer cache busting, which has race conditions when multiple servers serve the same player.

Key Trade-offs

  • Double-entry ledger vs. mutable balance field: Double-entry with an append-only ledger provides a full audit trail and prevents silent data corruption but adds query complexity (SUM aggregation for balance); a mutable balance field is faster to read but loses auditability and is vulnerable to update races even with row locking.
  • Synchronous fraud check vs. async: Synchronous fraud checks add 150ms to purchase latency but block fraudulent transactions before payment; async checks allow the purchase to proceed and reverse it if flagged, which is operationally complex (refunding already-granted currency).
  • Regional PostgreSQL vs. global: Using a single regional PostgreSQL cluster ensures ACID consistency for the ledger but adds latency for players in other regions; geo-distributed ledgers (CockroachDB, Spanner) add complexity and cost but reduce latency for global games.
  • Loot box regulation compliance: Many jurisdictions require disclosure of loot box odds; designing the purchase system to record the RNG seed and outcome for every loot box opening provides an auditable trail for regulatory compliance.

GO DEEPER

Master this topic in our 12-week cohort

Our Advanced System Design cohort covers this and 11 other deep-dive topics with live sessions, assignments, and expert feedback.