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.
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 sagaPOST /currency/spend— body:{player_id, amount, reason, reference_id}, atomically debits currency and returns new balance; idempotent on reference_idGET /players/{player_id}/inventory— returns full item inventory; served from Redis cache, falls back to PostgreSQLPOST /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.