Introduction
A slot machine is one of the simplest games in existence from a player's perspective: press a button, watch the reels spin, collect a win. Behind that simplicity sits one of the most carefully engineered distributed systems in commercial software — a chain of four distinct participants, each with clearly separated responsibilities, communicating across trust boundaries enforced by cryptographic sessions, real-money wallet operations processed with bank-grade atomicity, and every spin audited to the level required by gaming regulators across dozens of jurisdictions simultaneously.
Understanding this architecture is not optional for a slot developer. The decisions made at the architecture level — which participant owns what data, where game logic executes, how the wallet API handles failures, what the latency budget for a spin round-trip looks like — directly determine the game's certifiability, its operator compatibility, its resilience under load, and its vulnerability to fraud.
This article builds the complete architectural picture from the ground up. We start with the four-participant model that every iGaming slot operates within, dissect the responsibilities of each participant, trace the data flow of a complete spin lifecycle from the player's click to the win display, examine the interfaces between participants in precise technical detail, and identify the architectural decisions where errors are most costly.
By the end, you will have a production-quality reference architecture that you can use as the foundation for any slot development project — from a simple 5×3 line-based slot to a complex Megaways title with multiple bonus rounds.
Part I. The Four-Participant Model
1.1 The Participants
Every iGaming slot operates within a system involving exactly four types of participant. Understanding their roles and, critically, their trust relationships is the foundation of everything that follows.
THE FOUR-PARTICIPANT MODEL:
PLAYER | OPERATOR | AGGREGATOR | GAME PROVIDER |
PixiJS app running in browser or mobile | Casino brand | Integration middleware | C# game engine |
The Player (Client) The end user interacting with the game through a browser or mobile app. The client runs the game's PixiJS frontend, renders symbols and animations, handles user input, and communicates with the Game Server. Critically: the client is untrusted. Any data sent by the client can be forged. The client is a display terminal, not a logic executor.
The Game Provider (Game Server) The company that built the slot — this is you. The Game Server houses all game logic: the RNG, the win calculation engine, the state machine, the audit logger. It exposes a REST API that accepts spin requests and returns spin results. It is certified by GLI or BMM. It never holds player balances directly — that is the Operator's responsibility.
The Aggregator A B2B middleware platform that sits between Game Providers and Operators. Examples include SoftSwiss, Relax Gaming, EveryMatrix, Slotegrator, Hub88, and Pariplay. The Aggregator maintains integrations with hundreds of game providers on one side and thousands of operators on the other, acting as a multiplexer. For the Game Provider, the Aggregator is the Wallet API endpoint. For the Operator, the Aggregator is the game content library.
The Operator The casino brand — the entity that holds the gambling licence, manages player accounts, is responsible for KYC/AML compliance, and ultimately owns the player's balance. The Operator's systems maintain the wallet of every player. When a player bets, the Operator deducts from their balance. When they win, the Operator credits it. The Game Server never touches these balances directly — it calls the Aggregator's Wallet API, which calls the Operator.
1.2 Trust Relationships
The trust model is asymmetric and must be understood precisely:
Trust direction: → means "trusts the responses of"
Client → Game Server:
The client trusts spin results from the Game Server.
The client does NOT trust the client itself (all logic server-side).
Game Server → Aggregator Wallet API:
The Game Server trusts wallet operation responses — but verifies them
through idempotency keys and transaction IDs.
Aggregator → Operator Wallet:
The Aggregator trusts the Operator's balance operations.
Game Server ←→ Aggregator:
Mutual authentication via API keys + HMAC signatures or mTLS.
Operator ← Aggregator:
The Operator trusts the Aggregator for game result reporting.
The Operator does NOT trust the Game Server directly.
NOBODY trusts the Client:
The client sends spin requests but cannot set outcomes, balances,
or any game state. All validation happens server-side.1.3 Why This Separation Exists
The four-participant model is not an accident of history. Each separation serves a specific regulatory, financial, or technical purpose:
Game logic on the server, not the client: Regulatory requirement. The game outcome must be determined by a certified RNG running in a controlled environment. If the client could influence the outcome, it would be exploitable and uncertifiable.
Balance on the Operator, not the Game Server: Financial and regulatory separation. The Game Server provider's business model is licensing fees and revenue share, not holding player funds. Holding player funds requires additional licensing and regulatory oversight. The separation also protects players — if the Game Server goes out of business, their balance (at the Operator) is unaffected.
Aggregator as middleware: Economic efficiency. Without aggregators, every Game Provider would need to maintain integrations with every Operator (potentially thousands). Every Operator would need to integrate with every Game Provider (potentially hundreds). The aggregator reduces this from an O(N×M) integration problem to O(N+M).
Part II. The Client (PixiJS Frontend)
2.1 What the Client Is Responsible For
The client has a single primary responsibility: present the game state that the server provides, and forward user actions to the server. Nothing more.
Client responsibilities:
✓ Render symbols, reels, and backgrounds using PixiJS
✓ Animate reel spins, symbol transitions, win celebrations
✓ Display win amounts, balance, and bet information
✓ Accept user input (spin button, bet adjustment, auto-play)
✓ Send spin requests to the Game Server
✓ Receive and deserialize spin responses
✓ Drive animation sequences from server-provided data
✓ Handle connectivity loss and reconnection gracefully
✓ Display responsible gambling information
✓ Apply locale (language, currency formatting)
Client is NOT responsible for:
✗ Determining spin outcomes
✗ Calculating win amounts
✗ Managing player balance
✗ Validating game rules
✗ Running the RNG
✗ Storing game state between sessions
✗ Any business logic whatsoeverA useful mental model: the client is a cinema projector. It faithfully projects whatever the server sends it. The film (game state) is made entirely on the server.

2.3 Launch Modes
The client must support multiple launch modes, each with different configuration:
// Launch URL parameters determine game mode
const launchConfig = {
mode: 'real', // 'real' | 'demo' | 'history' | 'free_rounds'
token: '...', // Session token (real/free_rounds) or undefined (demo)
currency: 'EUR', // ISO 4217
locale: 'en-GB', // BCP 47
lobbyUrl: '...', // Where to go when player exits
gameId: 'awesome-slot-v1',
operatorId: '...',
};Mode | Auth Required | Real Money | Audit Logged | Description |
|---|---|---|---|---|
Real | Yes (session token) | Yes | Yes | Standard money play |
Demo / Fun | No | No | Optional | Free-play with virtual balance |
History | Yes (back-office token) | N/A | N/A | Replay historical spins |
Free Rounds | Yes | Operator-funded | Yes | Promotional free spins |
Demo mode must use the same game logic, same RNG, same pay table as real mode. The only difference is that no real money changes hands. Any game that plays differently in demo vs real mode is a regulatory violation.
Demo mode must use the same game logic, same RNG, same pay table as real mode. The only difference is that no real money changes hands. Any game that plays differently in demo vs real mode is a regulatory violation.
2.4 The Spin Lifecycle from the Client's Perspective
Player clicks SPIN
↓
UIManager: Disable spin button, start balance animation
↓
SpinManager: Build SpinRequest {sessionToken, betAmount, lines}
↓
NetworkLayer: POST /api/v1/game/spin
↓
[Network round-trip: 50–200ms]
↓
NetworkLayer: Receive SpinResponse
↓
SpinManager: Parse response, extract {stops, wins, newBalance, bonusData}
↓
ReelController: Begin reel spin animation
↓
AnimationQueue: [Enqueue all animation steps from response]
Step 1: Reel deceleration → stop at server-specified positions
Step 2: If wins: highlight winning symbols
Step 3: If wins: display win amount, play win audio
Step 4: If big win: play big win celebration animation
Step 5: If bonus triggered: transition to bonus screen
Step 6: Update balance display to newBalance
↓
UIManager: Re-enable spin buttonEverything after receiving the SpinResponse is deterministic animation driven entirely by the data in that response. The client does not decide which symbols to highlight, which combination paid, or what the new balance is — all of that comes from the server.
Part III. The Game Server (C# Backend)
3.1 What the Game Server Is Responsible For
The Game Server is the certified mathematical core of the system. It has five primary responsibilities:
GAME LOGIC EXECUTION
Accept spin requests
Execute the RNG to determine reel stops
Evaluate paylines against the stop positions
Determine wins, scatter counts, bonus triggers
Manage bonus round state (free spins remaining, multipliers)
Return complete spin results
WALLET INTEGRATION
Call the Aggregator's Wallet API to debit bets
Call the Aggregator's Wallet API to credit wins
Handle wallet failures with idempotency and rollback
Never hold player balances
SESSION MANAGEMENT
Validate session tokens
Track game state per session (bet level, bonus state, etc.)
Handle reconnection and state recovery
AUDIT LOGGING
Log every spin with full reconstruction data
Maintain tamper-evident log chain
Support regulatory query interfaces
CONFIGURATION MANAGEMENT
Apply operator-specific configurations (RTP variant, bet limits)
Serve game configuration to clients on launch
Manage game version lifecycle
3.2 Clean Architecture Layout
GameServer/
├── API/ ← HTTP endpoints (controllers)
│ ├── GameController.cs ← POST /spin, GET /gamestate
│ ├── ConfigController.cs ← GET /config (client launch config)
│ └── HealthController.cs ← GET /health (load balancer probe)
│
├── Application/ ← Use cases (orchestration)
│ ├── SpinUseCase.cs ← Full spin lifecycle
│ ├── BonusSpinUseCase.cs ← Free spin lifecycle
│ └── GameStateUseCase.cs ← State recovery after disconnect
│
├── Domain/ ← Pure game logic (no I/O)
│ ├── Engine/
│ │ ├── SlotEngine.cs ← RNG → stops → grid → wins
│ │ ├── WinCalculator.cs ← Payline evaluation
│ │ ├── BonusEngine.cs ← Free spins, retriggers
│ │ └── ScatterDetector.cs ← Scatter counting
│ ├── Math/
│ │ ├── ReelStrips.cs ← Reel configuration data
│ │ ├── PayTable.cs ← Win amounts by combination
│ │ └── GameConfig.cs ← All game parameters
│ └── State/
│ ├── GameSession.cs ← Per-session state
│ └── BonusState.cs ← Bonus round state
│
├── Infrastructure/ ← External I/O adapters
│ ├── Wallet/
│ │ ├── IWalletService.cs ← Interface (domain)
│ │ ├── AggregatorWallet.cs ← Aggregator API implementation
│ │ └── WalletDtos.cs ← Request/response DTOs
│ ├── Persistence/
│ │ ├── ISpinRepository.cs ← Interface (domain)
│ │ ├── PostgresSpinRepo.cs ← PostgreSQL implementation
│ │ └── AuditLogger.cs ← Tamper-evident logging
│ ├── Rng/
│ │ └── SlotRng.cs ← CSPRNG (from Article 3)
│ └── Cache/
│ └── SessionCache.cs ← Redis session state
│
└── Tests/
├── Domain/ ← Unit tests (no I/O)
├── Application/ ← Integration tests (mocked I/O)
└── Simulation/ ← 10M-spin RTP verification3.3 The Domain Layer: Pure Game Logic
The Domain layer contains zero I/O. No database calls, no HTTP calls, no filesystem access. Every class is a pure function from inputs to outputs. This is what makes the game engine testable, auditable, and certifiable.
/// <summary>
/// The core slot engine: pure function from (config, rng) to SpinOutcome.
/// No I/O. No side effects. Deterministic given the same RNG output.
/// This class is what the certification lab tests.
/// </summary>
public sealed class SlotEngine
{
private readonly GameConfig _config;
private readonly ISlotRng _rng;
public SlotEngine(GameConfig config, ISlotRng rng)
{
_config = config;
_rng = rng;
}
public SpinOutcome Execute(SpinRequest request)
{
// 1. Generate reel stops via certified CSPRNG
int[] stops = _rng.NextReelStops(_config.ReelSizes);
// 2. Build visible symbol grid from stops
int[,] grid = BuildGrid(stops);
// 3. Evaluate all paylines for wins
var wins = EvaluateAllLines(grid, request.BetPerLine);
// 4. Count scatters for bonus trigger
int scatterCount = CountScatters(grid);
// 5. Determine bonus trigger
bool bonusTriggered = scatterCount >= _config.BonusTriggerScatterCount;
int freeSpinsAwarded = bonusTriggered
? _config.GetFreeSpinsForScatterCount(scatterCount)
: 0;
return new SpinOutcome(
ReelStops: stops,
VisibleGrid: grid,
Wins: wins,
TotalWin: wins.Sum(w => w.Amount),
ScatterCount: scatterCount,
BonusTriggered: bonusTriggered,
FreeSpinsAwarded: freeSpinsAwarded
);
}
private int[,] BuildGrid(int[] stops)
{
var grid = new int[_config.Columns, _config.VisibleRows];
for (int col = 0; col < _config.Columns; col++)
{
var strip = _config.ReelStrips[col];
for (int row = 0; row < _config.VisibleRows; row++)
{
int idx = (stops[col] + row - 1 + strip.Length) % strip.Length;
grid[col, row] = strip[idx];
}
}
return grid;
}
private List<WinResult> EvaluateAllLines(int[,] grid, decimal betPerLine)
{
var wins = new List<WinResult>();
foreach (var payline in _config.Paylines)
{
var (symbolId, count) = EvaluatePayline(grid, payline);
if (count >= 3 && _config.PayTable.TryGetValue(
(symbolId, count), out decimal multiplier))
{
wins.Add(new WinResult(
PaylineIndex: Array.IndexOf(_config.Paylines, payline),
SymbolId: symbolId,
Count: count,
Multiplier: multiplier,
Amount: multiplier * betPerLine
));
}
}
return wins;
}
private (int symbolId, int count) EvaluatePayline(int[,] grid, int[] payline)
{
int? baseSymbol = null;
int count = 0;
for (int col = 0; col < payline.Length; col++)
{
int sym = grid[col, payline[col]];
if (sym == _config.ScatterSymbolId) break;
if (baseSymbol is null)
{
if (sym == _config.WildSymbolId) { count++; continue; }
baseSymbol = sym;
count++;
}
else if (sym == baseSymbol || sym == _config.WildSymbolId)
count++;
else
break;
}
if (baseSymbol is null && count > 0)
baseSymbol = _config.WildSymbolId;
return (baseSymbol ?? 0, count);
}
private int CountScatters(int[,] grid)
{
int n = 0;
for (int col = 0; col < _config.Columns; col++)
for (int row = 0; row < _config.VisibleRows; row++)
if (grid[col, row] == _config.ScatterSymbolId) n++;
return n;
}
}3.4 The Application Layer: Spin Use Case
The Application layer orchestrates Domain logic and Infrastructure. It is where the full spin lifecycle — debit, spin, credit, log — is assembled.
/// <summary>
/// Orchestrates a complete real-money spin:
/// debit → generate → evaluate → credit → log.
/// Handles wallet failures, rollback, and audit in every code path.
/// </summary>
public sealed class SpinUseCase
{
private readonly SlotEngine _engine;
private readonly IWalletService _wallet;
private readonly ISpinRepository _repository;
private readonly ISessionCache _sessions;
private readonly ILogger<SpinUseCase> _logger;
public async Task<SpinResponse> ExecuteAsync(
SpinRequest request,
CancellationToken ct = default)
{
// ── 1. VALIDATE SESSION ──────────────────────────────────────
var session = await _sessions.GetAsync(request.SessionToken, ct)
?? throw new InvalidSessionException(request.SessionToken);
if (session.IsInBonus)
throw new InvalidOperationException(
"Cannot start base spin while bonus round is active. " +
"Call /bonus/spin instead.");
// ── 2. VALIDATE BET ──────────────────────────────────────────
ValidateBet(request.BetAmount, session);
// ── 3. DEBIT BET ─────────────────────────────────────────────
// Idempotency key: ensures debit is exactly-once even if retried
var debitKey = $"debit:{session.SessionId}:{session.SpinSequence + 1}";
var debitResult = await _wallet.DebitAsync(new DebitRequest(
SessionToken: request.SessionToken,
Amount: request.BetAmount,
Currency: session.Currency,
TransactionRef: debitKey
), ct);
if (!debitResult.Success)
{
_logger.LogWarning("Debit failed: {Reason} for session {SessionId}",
debitResult.FailureReason, session.SessionId);
throw new InsufficientFundsException(debitResult.FailureReason);
}
// ── 4. GENERATE SPIN OUTCOME ─────────────────────────────────
// RNG executes AFTER successful debit — outcome tied to committed bet
SpinOutcome outcome;
try
{
outcome = _engine.Execute(request);
}
catch (Exception ex)
{
// Engine failure after successful debit: must rollback
_logger.LogError(ex, "Engine failure after debit. Rolling back.");
await RollbackDebitAsync(debitKey, request.BetAmount, session, ct);
throw;
}
// ── 5. CREDIT WINS ───────────────────────────────────────────
decimal totalWin = outcome.TotalWin;
string? creditTxId = null;
if (totalWin > 0)
{
var creditKey = $"credit:{session.SessionId}:{session.SpinSequence + 1}";
var creditResult = await _wallet.CreditAsync(new CreditRequest(
SessionToken: request.SessionToken,
Amount: totalWin,
Currency: session.Currency,
TransactionRef: creditKey
), ct);
if (!creditResult.Success)
{
// Credit failure: this is a critical incident
// Log, alert, and trigger manual reconciliation
await HandleCreditFailureAsync(
session, outcome, totalWin, creditKey, ct);
// Do not throw — the player won legitimately.
// The win will be reconciled manually.
}
else
{
creditTxId = creditResult.TransactionId;
}
}
// ── 6. UPDATE SESSION STATE ──────────────────────────────────
session.SpinSequence++;
if (outcome.BonusTriggered)
{
session.IsInBonus = true;
session.BonusState = new BonusState(
FreeSpinsRemaining: outcome.FreeSpinsAwarded,
FreeSpinsTotal: outcome.FreeSpinsAwarded,
Multiplier: 1.0m,
TriggeringSpinId: session.SpinSequence.ToString()
);
}
await _sessions.UpdateAsync(session, ct);
// ── 7. AUDIT LOG ─────────────────────────────────────────────
var spinRecord = new SpinAuditRecord(
SpinId: Guid.NewGuid().ToString("N"),
SessionId: session.SessionId,
PlayerId: session.PlayerId,
TimestampUtc: DateTime.UtcNow,
GameId: _engine.Config.GameId,
GameVersion: _engine.Config.Version,
BetAmount: request.BetAmount,
Currency: session.Currency,
ReelStops: outcome.ReelStops,
ReelSizes: _engine.Config.ReelSizes,
VisibleGrid: GridToJagged(outcome.VisibleGrid),
WinAmount: totalWin,
WinDetails: SerializeWins(outcome.Wins),
ScatterCount: outcome.ScatterCount,
BonusTriggered: outcome.BonusTriggered,
FreeSpinsAwarded: outcome.FreeSpinsAwarded,
BalanceBefore: debitResult.BalanceBefore,
BalanceAfter: debitResult.BalanceBefore - request.BetAmount + totalWin,
DebitTxId: debitResult.TransactionId,
CreditTxId: creditTxId,
RngVersion: SlotRng.AlgorithmVersion
);
await _repository.SaveSpinAsync(spinRecord, ct);
// ── 8. BUILD RESPONSE ────────────────────────────────────────
return new SpinResponse(
SpinId: spinRecord.SpinId,
ReelStops: outcome.ReelStops,
VisibleGrid: GridToJagged(outcome.VisibleGrid),
Wins: outcome.Wins.Select(MapWinResult).ToList(),
TotalWin: totalWin,
NewBalance: debitResult.BalanceBefore - request.BetAmount + totalWin,
Currency: session.Currency,
BonusTriggered: outcome.BonusTriggered,
FreeSpinsData: outcome.BonusTriggered
? new FreeSpinsData(outcome.FreeSpinsAwarded, session.BonusState!.Multiplier)
: null
);
}
private async Task RollbackDebitAsync(
string debitKey, decimal amount,
GameSession session, CancellationToken ct)
{
try
{
await _wallet.RollbackAsync(new RollbackRequest(
SessionToken: session.SessionId,
OriginalTransactionRef: debitKey,
Amount: amount,
Currency: session.Currency
), ct);
}
catch (Exception rollbackEx)
{
// Rollback failure is a critical incident requiring manual intervention
_logger.LogCritical(rollbackEx,
"CRITICAL: Rollback failed for debit key {DebitKey}. " +
"Manual reconciliation required.", debitKey);
// Alert on-call team via PagerDuty/OpsGenie
}
}
private async Task HandleCreditFailureAsync(
GameSession session, SpinOutcome outcome,
decimal amount, string creditKey, CancellationToken ct)
{
_logger.LogCritical(
"CRITICAL: Credit failure for session {SessionId}. " +
"Player won {Amount} but credit failed. Key: {CreditKey}",
session.SessionId, amount, creditKey);
// Store in pending-credits table for reconciliation
await _repository.SavePendingCreditAsync(new PendingCredit(
CreditKey: creditKey,
SessionId: session.SessionId,
Amount: amount,
Currency: session.Currency,
CreatedAt: DateTime.UtcNow
), ct);
}
}Part IV. The Wallet API — The Most Critical Interface
4.1 Why the Wallet Interface Is Where Most Bugs Hide
The Wallet API is the only interface in the system where failure has immediate, real-money consequences. A bug in the win calculator means the game pays the wrong amount. A bug in the wallet integration means money appears or disappears from player accounts without corresponding game events.
The three operations that make up the wallet interface are conceptually simple:
GetBalance → How much money does this player have?
Debit → Take bet_amount from the player's balance
Credit → Give win_amount to the player's balance
Rollback → Undo a previous Debit that cannot be completedBut the implementation must handle: network failures, timeout responses that don't indicate success or failure, duplicate requests from retries, race conditions between concurrent requests for the same session, and the case where a debit succeeds but the credit doesn't.
4.2 The Wallet Interface in C#
public interface IWalletService
{
/// <summary>
/// Returns current balance. Called on game launch and after each spin.
/// </summary>
Task<BalanceResponse> GetBalanceAsync(
string sessionToken, CancellationToken ct = default);
/// <summary>
/// Deducts bet amount from player balance.
/// MUST be idempotent: same transactionRef always produces same result.
/// Returns balance BEFORE deduction to enable spin record auditing.
/// </summary>
Task<DebitResponse> DebitAsync(
DebitRequest request, CancellationToken ct = default);
/// <summary>
/// Adds win amount to player balance.
/// MUST be idempotent: same transactionRef always produces same result.
/// </summary>
Task<CreditResponse> CreditAsync(
CreditRequest request, CancellationToken ct = default);
/// <summary>
/// Reverses a prior debit. Called when engine failure occurs after debit
/// but before spin outcome is determined.
/// MUST be idempotent.
/// </summary>
Task<RollbackResponse> RollbackAsync(
RollbackRequest request, CancellationToken ct = default);
/// <summary>
/// Validates that a session token is still active.
/// Operator may invalidate sessions (timeout, logout, self-exclusion).
/// </summary>
Task<SessionValidationResponse> ValidateSessionAsync(
string sessionToken, CancellationToken ct = default);
}
// ── Request / Response DTOs ──────────────────────────────────────
public record DebitRequest(
string SessionToken,
decimal Amount,
string Currency,
string TransactionRef, // Idempotency key: unique per spin attempt
string? GameRoundId = null // Optional: links debit to a round
);
public record DebitResponse(
bool Success,
string? TransactionId, // Aggregator's transaction ID (for audit)
decimal BalanceBefore, // Balance before debit (for audit log)
decimal BalanceAfter, // Balance after debit
string? FailureReason // Populated when Success = false
);
public record CreditRequest(
string SessionToken,
decimal Amount,
string Currency,
string TransactionRef, // Idempotency key
string? GameRoundId = null,
string? WinType = null // "base_win" | "bonus_win" | "jackpot"
);
public record CreditResponse(
bool Success,
string? TransactionId,
decimal BalanceAfter,
string? FailureReason
);
public record RollbackRequest(
string SessionToken,
string OriginalTransactionRef, // The transactionRef of the debit to reverse
decimal Amount,
string Currency
);
public record RollbackResponse(
bool Success,
string? TransactionId,
string? FailureReason
);4.3 Idempotency: The Non-Negotiable Requirement
Every wallet operation must be idempotent — calling it multiple times with the same parameters must produce the same result as calling it once.
Why this matters: network failures are common. When the Game Server sends a debit request and the network times out, it does not know whether the debit succeeded or not. Without idempotency, retrying the debit might deduct the player's balance twice.
The standard mechanism is the transaction reference (also called idempotency key): a unique, deterministic identifier for each wallet operation that the Aggregator uses to detect and reject duplicate requests.
/// <summary>
/// Generates deterministic, unique transaction references.
/// The same inputs always produce the same output — enabling safe retries.
/// </summary>
public static class TransactionRefBuilder
{
/// <summary>
/// Debit reference: unique per session per spin sequence number.
/// Format: debit:{sessionId}:{spinSequence}
/// Even if the debit is retried 10 times, the same ref is sent each time.
/// The aggregator deduplicates — only one debit ever occurs.
/// </summary>
public static string Debit(string sessionId, long spinSequence)
=> $"debit:{sessionId}:{spinSequence}";
/// <summary>
/// Credit reference: unique per session per spin.
/// Separate from debit ref to allow partial retries.
/// </summary>
public static string Credit(string sessionId, long spinSequence)
=> $"credit:{sessionId}:{spinSequence}";
/// <summary>
/// Free spin credit: includes free spin number to allow per-spin retry.
/// </summary>
public static string FreeSpinCredit(
string sessionId, string fsBlockId, int fsSpinNumber)
=> $"fs-credit:{sessionId}:{fsBlockId}:{fsSpinNumber}";
/// <summary>
/// Rollback reference: always based on the original debit ref.
/// </summary>
public static string Rollback(string originalDebitRef)
=> $"rollback:{originalDebitRef}";
}4.4 The Split Transaction Problem
The most dangerous scenario in wallet integration: the debit succeeds but the credit fails.
Timeline of a split transaction:
─────────────────────────────────────────────────────────────
T+0ms: Game Server sends DebitRequest for €1.00
T+50ms: Aggregator debits player: balance goes from €100 to €99
T+50ms: Aggregator responds: DebitResponse { Success=true }
T+100ms: Engine generates spin outcome: player wins €25.00
T+150ms: Game Server sends CreditRequest for €25.00
T+160ms: NETWORK FAILURE — CreditRequest never reaches Aggregator
T+200ms: Game Server receives timeout
T+???: Player's balance is €99 — they lost €1 and won nothing
─────────────────────────────────────────────────────────────This must never happen from the player's perspective. The resolution strategies:
Strategy 1: Retry with exponential backoff Retry the credit up to N times with increasing delays. For most transient failures, this resolves in 1–3 retries.
public async Task<CreditResponse> CreditWithRetryAsync(
CreditRequest request,
int maxRetries = 3,
CancellationToken ct = default)
{
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
try
{
var response = await _wallet.CreditAsync(request, ct);
if (response.Success) return response;
// Non-transient failure: don't retry
if (IsNonRetryableFailure(response.FailureReason))
throw new WalletException(response.FailureReason);
}
catch (HttpRequestException) when (attempt < maxRetries)
{
// Transient network failure: retry
}
catch (TaskCanceledException) when (attempt < maxRetries)
{
// Timeout: retry — idempotency ensures no double-credit
}
// Exponential backoff: 100ms, 200ms, 400ms
await Task.Delay(100 * (int)Math.Pow(2, attempt), ct);
}
// All retries failed: store for async reconciliation
await _pendingCredits.SaveAsync(request);
_alerts.SendCritical($"Credit failed after {maxRetries} retries: {request.TransactionRef}");
// Return a synthetic success response so the player sees their win
// The backend will reconcile asynchronously
return new CreditResponse(
Success: true, // Show win to player — will be reconciled
TransactionId: null,
BalanceAfter: 0, // Unknown — client should re-fetch balance
FailureReason: null
);
}Strategy 2: Async reconciliation queue If all retries fail, store the pending credit in a durable queue. A background worker retries until successful or until manual intervention is required.
public class PendingCreditWorker : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var pending = await _repository.GetOldestPendingCreditsAsync(
maxAge: TimeSpan.FromMinutes(5), limit: 100);
foreach (var credit in pending)
{
try
{
var result = await _wallet.CreditAsync(
new CreditRequest(
credit.SessionToken, credit.Amount,
credit.Currency, credit.TransactionRef),
stoppingToken);
if (result.Success)
await _repository.MarkCreditResolvedAsync(credit.Id);
else if (IsNonRetryable(result.FailureReason))
await _repository.EscalateToManualAsync(credit.Id,
result.FailureReason);
}
catch (Exception ex)
{
_logger.LogError(ex, "Pending credit retry failed: {Ref}",
credit.TransactionRef);
}
}
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
}Part V. The Aggregator
5.1 What the Aggregator Does
The Aggregator's role is often misunderstood by developers who are new to iGaming. It is not just a proxy — it provides significant value on both sides of the relationship:
For Game Providers:
Single integration point to reach hundreds of operators
Standardised Wallet API so the game server doesn't need to adapt to each operator's native API
Traffic management and load balancing
Reporting and analytics aggregation
Sometimes: content hosting (the aggregator serves the game client)
For Operators:
Single integration point to access hundreds of game providers
Unified game lobby management
Centralised reporting across all game providers
Session management that spans the operator's entire game catalogue
Sometimes: jurisdiction-level content filtering
5.2 Major Aggregators and Their Technical Profiles
Aggregator | Headquarters | Game Providers | Notable Technical Aspect |
|---|---|---|---|
SoftSwiss | Minsk/Malta | 200+ | Popular in crypto casinos; supports fiat and crypto wallets |
Relax Gaming | Malta | 100+ | "Silver Bullet" distribution model; premium/boutique focus |
EveryMatrix | Malta | 600+ | CasinoEngine; strong API documentation |
Slotegrator | Estonia | 200+ | Aggregator + turnkey casino; popular in CIS markets |
Hub88 | Malta/Hong Kong | 200+ | Strong Asia-Pacific presence |
Pariplay | Gibraltar | 100+ | Focus on B2B; owns Fusion aggregation platform |
Pragmatic Play | Malta | Internal | Also operates as both provider and aggregator via BGTB |
5.3 The Aggregator API Contract
From the Game Server's perspective, the Aggregator exposes a Wallet API. The exact shape of this API varies by aggregator, but all follow the same logical structure. Below is a representative implementation:
/// <summary>
/// Concrete implementation of IWalletService for the SoftSwiss/EveryMatrix
/// style aggregator API. Each aggregator has its own endpoint and
/// authentication scheme but identical logical operations.
/// </summary>
public sealed class AggregatorWalletService : IWalletService
{
private readonly HttpClient _http;
private readonly string _apiKey;
private readonly string _apiSecret;
private readonly string _baseUrl;
private readonly ILogger<AggregatorWalletService> _logger;
public async Task<DebitResponse> DebitAsync(
DebitRequest request, CancellationToken ct = default)
{
var body = new
{
session_token = request.SessionToken,
amount = request.Amount.ToString("F2"),
currency = request.Currency,
transaction_ref = request.TransactionRef,
game_round_id = request.GameRoundId,
transaction_type = "bet"
};
var httpResponse = await SendSignedRequestAsync(
HttpMethod.Post, "/wallet/debit", body, ct);
if (httpResponse.IsSuccessStatusCode)
{
var json = await httpResponse.Content
.ReadFromJsonAsync<AggregatorDebitResponse>(ct);
return new DebitResponse(
Success: json!.Status == "ok",
TransactionId: json.TransactionId,
BalanceBefore: json.BalanceBefore,
BalanceAfter: json.BalanceAfter,
FailureReason: json.Status != "ok" ? json.ErrorCode : null
);
}
// Non-2xx response: network or server error
_logger.LogError("Aggregator debit returned {Status} for {Ref}",
httpResponse.StatusCode, request.TransactionRef);
throw new WalletCommunicationException(
$"Aggregator returned {httpResponse.StatusCode}");
}
private async Task<HttpResponseMessage> SendSignedRequestAsync(
HttpMethod method, string path, object body, CancellationToken ct)
{
string jsonBody = JsonSerializer.Serialize(body);
string timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
string signature = ComputeHmacSignature(jsonBody, timestamp);
using var request = new HttpRequestMessage(method, _baseUrl + path)
{
Content = new StringContent(jsonBody,
System.Text.Encoding.UTF8, "application/json")
};
request.Headers.Add("X-Api-Key", _apiKey);
request.Headers.Add("X-Timestamp", timestamp);
request.Headers.Add("X-Signature", signature);
return await _http.SendAsync(request, ct);
}
/// <summary>
/// HMAC-SHA256 signature: prevents request tampering.
/// The aggregator verifies this on every request.
/// </summary>
private string ComputeHmacSignature(string body, string timestamp)
{
string message = $"{timestamp}:{body}";
byte[] keyBytes = System.Text.Encoding.UTF8.GetBytes(_apiSecret);
byte[] messageBytes = System.Text.Encoding.UTF8.GetBytes(message);
byte[] hash = HMACSHA256.HashData(keyBytes, messageBytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}Part VI. The Operator
6.1 What the Operator Owns
The Operator is the entity that holds the gambling licence and is responsible for the player relationship. From an architecture perspective, the Operator owns:
Player accounts (identity, KYC status, contact details)
Player balances (the actual money)
Session lifecycle (login, session tokens, logout, timeout)
Responsible gambling controls (deposit limits, session limits, self-exclusion)
Bonus and promotion management (free rounds, welcome bonuses)
KYC/AML compliance (document verification, transaction monitoring)
Regulatory reporting (monthly reports to licensing authority)
The Operator never interacts with the Game Server directly. All interaction flows through the Aggregator.
6.2 Session Token Lifecycle
The session token is the primary authentication mechanism between the Client, the Game Server, and the Wallet API. Understanding its lifecycle is essential:
Player logs in to Operator website
Operator authenticates player (username/password, 2FA)
Player clicks on a game in the casino lobby
Operator generates a session token for this game session:
Token is scoped to: {player_id, game_id, operator_id}
Token has a TTL (typically 24 hours or until logout)
Token is opaque to the Game Server — only the Aggregator can validate it
Operator redirects player to game URL with token as parameter: https://{example-domain}/awesome-slot?token=abc123...
Game client loads, sends token to Game Server
Game Server sends token to Aggregator for validation
Aggregator validates token with Operator (or from its own cache)
Game proceeds using the validated token for all wallet operations
When player exits: token is invalidated by Operator Any subsequent wallet operations with this token will fail
/// <summary>
/// Session validation: the first thing the Game Server does when
/// the client launches the game. If this fails, the game cannot start.
/// </summary>
public async Task<GameSession> ValidateAndCreateSessionAsync(
string token, string gameId, CancellationToken ct = default)
{
// Validate token with Aggregator → Operator
var validation = await _wallet.ValidateSessionAsync(token, ct);
if (!validation.IsValid)
throw new InvalidSessionException(
$"Token validation failed: {validation.FailureReason}");
// Build internal session object from validated data
var session = new GameSession(
SessionToken: token,
SessionId: Guid.NewGuid().ToString("N"),
PlayerId: validation.PlayerId,
OperatorId: validation.OperatorId,
Currency: validation.Currency,
GameId: gameId,
CreatedAt: DateTime.UtcNow,
LastActivity: DateTime.UtcNow,
SpinSequence: 0,
IsInBonus: false,
BonusState: null,
RtpConfig: validation.RtpConfiguration ?? "default",
BetLimits: new BetLimits(
Min: validation.MinBet ?? _config.DefaultMinBet,
Max: validation.MaxBet ?? _config.DefaultMaxBet
)
);
await _sessions.SaveAsync(session, ct);
return session;
}Part VII. The Complete Spin Data Flow
7.1 The 23-Step Spin Lifecycle
A complete spin involves 23 distinct steps across all four participants. Understanding every step is essential for debugging production issues:
Step 1 [CLIENT] Player clicks SPIN button
Step 2 [CLIENT] UIManager disables spin button, records local timestamp
Step 3 [CLIENT] SpinManager builds SpinRequest JSON
Step 4 [CLIENT] NetworkLayer sends POST /api/v1/game/spin
Headers: Authorization: Bearer {sessionToken}
Body: {betAmount, lines, currency}
Step 5 [GAME SERVER] SpinController receives request
Step 6 [GAME SERVER] Middleware validates JWT structure (format only)
Step 7 [GAME SERVER] SpinUseCase.ExecuteAsync() begins
Step 8 [GAME SERVER] Session loaded from Redis — validates token exists
Step 9 [GAME SERVER] Bet amount validated against session limits
Step 10 [GAME SERVER] POST /wallet/debit → AGGREGATOR
Body: {sessionToken, amount, transactionRef}
Headers: HMAC signature
Step 11 [AGGREGATOR] Validates session token with Operator
Step 12 [AGGREGATOR] Deduplication check: has transactionRef been seen?
Step 13 [AGGREGATOR] POST /wallet/debit → OPERATOR
Step 14 [OPERATOR] Deducts bet from player balance
Step 15 [OPERATOR] Returns: {success, newBalance, transactionId}
Step 16 [AGGREGATOR] Returns: {success, balanceBefore, balanceAfter, txId}
Step 17 [GAME SERVER] Debit confirmed — RNG executes, determines reel stops
Step 18 [GAME SERVER] Win calculated from stops + pay table
Step 19 [GAME SERVER] If win > 0: POST /wallet/credit → AGGREGATOR
(Same flow as steps 10–16, but direction reversed)
Step 20 [GAME SERVER] Session state updated (spinSequence++, bonus state)
Step 21 [GAME SERVER] Audit record written to PostgreSQL
Step 22 [GAME SERVER] SpinResponse JSON serialized and returned
Step 23 [CLIENT] Receives SpinResponse, begins animation sequence7.2 Latency Budget
For a satisfying player experience, the full round-trip (step 4 to step 23) should complete within 800ms at the 95th percentile. Breaking down the budget:
Total budget: 800ms
├── Network (client → game server): 30–80ms (depends on geography)
├── Session validation + bet validation: 2–5ms (Redis lookup)
├── Wallet debit (steps 10–16): 50–150ms (aggregator + operator)
│ Network to aggregator: 10–30ms
│ Aggregator processing: 10–40ms
│ Aggregator → Operator: 10–30ms
│ Operator processing: 10–40ms
├── RNG + Engine (pure computation): < 1ms (in-memory, trivial)
├── Wallet credit (when win > 0): 50–150ms (same as debit)
├── Session update (Redis write): 2–5ms
├── Audit log write (async, non-blocking): 0ms (fire-and-forget)
├── Response serialisation: < 1ms
└── Network (game server → client): 30–80ms
P50 total: ~200ms
P95 total: ~600ms
P99 total: ~1200ms (acceptable — player won't notice occasionally)The audit log write is explicitly fire-and-forget — the response is sent to the client before the audit log is confirmed written. This is acceptable because the audit data is captured in memory before the response is sent; the write is async but guaranteed through a resilient write-ahead log.
7.3 Error Handling at Each Step
/// <summary>
/// Error codes returned to the client in SpinResponse.
/// The client maps these to user-facing messages.
/// </summary>
public static class SpinErrorCodes
{
// Session errors
public const string SessionExpired = "SESSION_EXPIRED";
public const string SessionNotFound = "SESSION_NOT_FOUND";
public const string SessionInBonus = "SESSION_IN_BONUS";
// Bet errors
public const string BetBelowMinimum = "BET_BELOW_MINIMUM";
public const string BetAboveMaximum = "BET_ABOVE_MAXIMUM";
public const string InsufficientFunds = "INSUFFICIENT_FUNDS";
// Wallet errors
public const string WalletUnavailable = "WALLET_UNAVAILABLE";
public const string WalletTimeout = "WALLET_TIMEOUT";
// Game errors
public const string GameUnderMaintenance = "GAME_UNDER_MAINTENANCE";
public const string RoundInProgress = "ROUND_IN_PROGRESS";
// Server errors
public const string InternalError = "INTERNAL_ERROR";
}Part VIII. Environment Architecture
8.1 The Four Environments
Every iGaming production system runs across four environments, each serving a distinct purpose:
ENVIRONMENT HIERARCHY:
DEVELOPMENT | Developer's local machine or dev server |
STAGING | Full infrastructure, production-like |
UAT | The environment submitted to certification labs |
PRODUCTION | Real money, real players |
8.2 Production Infrastructure

8.3 Twelve-Factor Compliance
The Game Server should follow the Twelve-Factor App methodology. For iGaming specifically, these factors are most critical:
Factor III — Config: All configuration (aggregator URLs, API keys, RTP configurations, bet limits) comes from environment variables or a secrets manager. No configuration in code or version-controlled files.
// appsettings.json contains only defaults and structure.
// Real values come from environment:
// WALLET_API_URL, WALLET_API_KEY, WALLET_API_SECRET
// REDIS_CONNECTION, POSTGRES_CONNECTION
// GAME_RTP_CONFIGURATION, MAX_BET_AMOUNT
builder.Configuration
.AddEnvironmentVariables() // Overrides appsettings
.AddSecretsManager(region: "eu-west-1"); // AWS Secrets ManagerFactor VI — Processes: Each Game Server instance is stateless. Session state lives in Redis, not in the instance's memory. This allows horizontal scaling and zero-downtime restarts.
Factor IX — Disposability: The Game Server can start and stop quickly. On receiving SIGTERM, it completes in-progress spins (giving them a 5-second grace period) and then shuts down cleanly.
app.Lifetime.ApplicationStopping.Register(() =>
{
_logger.LogInformation("Shutdown signal received. " +
"Waiting for in-progress spins to complete...");
// SpinUseCase uses a semaphore that blocks shutdown
// until all active spins complete or timeout
_spinSemaphore.Wait(TimeSpan.FromSeconds(5));
});Part IX. Common Architectural Mistakes
Mistake 1: Game Logic in the Client
// ❌ NEVER — client-side win calculation
function evaluateSpin(stops) {
const grid = buildGrid(stops);
const wins = evaluatePaylines(grid); // client evaluates wins
return wins; // and sends result to server
}If the client evaluates wins and sends them to the server for crediting, any player with browser devtools can modify the wins before they reach the server. This is a catastrophic security flaw and an immediate certification failure.
The correct pattern: the server sends the stops and wins in the response. The client displays them. The client never calculates them.
Mistake 2: Balance Stored on the Game Server
// ❌ WRONG — game server holds balance
public class GameSession
{
public decimal Balance { get; set; } // ← never do this
}The Game Server must never be the source of truth for player balance. It may cache the last known balance for display purposes, but the authoritative balance lives at the Operator. If the Game Server crashes and restarts, the cached balance is lost. If two instances of the Game Server serve the same player, they would have conflicting balances.
Mistake 3: Synchronous Audit Logging in the Spin Response Path
// ❌ SLOW — audit write blocks response
await _repository.SaveSpinAsync(spinRecord, ct); // blocks for 5–20ms
return spinResponse; // sent after audit write completes
// ✓ FAST — audit write is async
_ = _repository.SaveSpinAsync(spinRecord, CancellationToken.None)
.ContinueWith(t => {
if (t.IsFaulted) _logger.LogError(t.Exception, "Audit write failed");
});
return spinResponse; // sent immediatelyThe audit log write should not be in the critical path of the spin response. Write the response first, write the audit asynchronously. The data is captured in memory before the response goes out — the risk of loss on failure is acceptable for audit writes (which should be backed by a write-ahead log anyway).
Mistake 4: No Idempotency on Retry
// ❌ DANGEROUS — retry without idempotency key
for (int i = 0; i < 3; i++)
{
var response = await _wallet.DebitAsync(new DebitRequest(
SessionToken: token,
Amount: betAmount,
Currency: "EUR"
// No TransactionRef — aggregator can't detect duplicates
));
if (response.Success) break;
}
// Risk: player is debited 3 times if the first two responses are lostEvery wallet request must carry a stable, unique TransactionRef that enables the aggregator to detect and reject duplicates.
Mistake 5: Direct Operator Integration
// ❌ ARCHITECTURAL MISTAKE — direct integration with operator
// bypasses aggregator, breaks the four-participant model
public class DirectOperatorWallet : IWalletService
{
private readonly string _operatorApiUrl; // Direct to operator
// ...
}When you integrate directly with an operator, you assume all the complexity that aggregators handle: supporting every operator's unique API format, managing N integration points instead of one, dealing with different authentication schemes per operator. This becomes unmanageable at scale and is the exact problem aggregators were created to solve.
Summary
The four-participant model — Player, Game Provider, Aggregator, Operator — is not arbitrary complexity. It is a carefully evolved structure that separates concerns according to regulatory requirements, financial responsibility, and operational scalability.
The key architectural principles to carry forward:
The client is a display terminal. It presents server-provided state and forwards user actions. Every line of business logic in the client is a security flaw and a certification risk.
The Game Server owns the mathematics and nothing else. It runs the RNG, evaluates wins, manages bonus state, and logs everything. It never holds player money and never stores session state in its own memory.
The wallet interface is the highest-risk integration point. Idempotency keys, retry with exponential backoff, split-transaction recovery, and async reconciliation are not optional features — they are the difference between a game that can run in production and one that occasionally loses player money.
The Aggregator makes the market work. Building direct integrations with operators is an anti-pattern. The aggregator model is how the iGaming market achieves its scale.
The audit log enables everything else. Without a complete, tamper-evident audit log, certification is impossible, disputes cannot be resolved, and regulatory investigations cannot be answered. Every spin must be fully reconstructable from stored data.
