Neon Royale

iGaming Architecture Overview — Client, Server, Operator, Aggregator

Neon AdminNeon Admin·Mar 16, 2026
iGaming Architecture Overview — Client, Server, Operator, Aggregator

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
(Client)

OPERATOR

AGGREGATOR

GAME PROVIDER
(Game Server)

PixiJS app running in browser or mobile

Casino brand
Website/app
Player auth
Balance mgmt
KYC/AML

Integration middleware
Wallet proxy
Game routing
Reporting

C# game engine
REST API
RNG + Math
Audit logging
State management

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 whatsoever

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

cli.jpg

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 button

Everything 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 verification

3.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 completed

But 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 sequence

7.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
Mock wallet service (returns configurable responses)
Local PostgreSQL, local Redis
Debug logging, no TLS required

STAGING

Full infrastructure, production-like
Connected to Aggregator's UAT/sandbox environment
Aggregator uses test balances (not real money)
Used for: integration testing, QA, demo to operators

UAT
(Cert)

The environment submitted to certification labs
Identical to production in every technical aspect
Connected to Aggregator's certification environment
Must be stable and version-locked during testing
Lab access credentials for this environment only

PRODUCTION

Real money, real players
Horizontally scaled, multi-region
Zero-downtime deployments
Full monitoring, alerting, on-call rotation

8.2 Production Infrastructure

queue.jpg

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 Manager

Factor 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 immediately

The 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 lost

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

Neon Royale use cookies for technical purposes, functionality and measurement.

Policy