Neon Royale

Slot Game Server Protocol — Spin Request/Response: The Complete JSON Contract Between Client and Server

Neon AdminNeon Admin·Mar 17, 2026
Slot Game Server Protocol — Spin Request/Response: The Complete JSON Contract Between Client and Server

Introduction

Every spin begins with a JSON payload crossing the wire and ends with another JSON payload returning. Those two messages — the spin request and the spin response — are the most important API contract in the entire iGaming stack. They are the boundary where the client's responsibility ends and the server's begins. They carry everything the client needs to animate the result and everything the server needs to reconstruct any spin for an audit five years from now.

Get this contract wrong and the consequences ripple in every direction. A missing field in the response means the client cannot animate a particular bonus mechanic. An ambiguous error code means a transient network failure causes the player to see an error instead of retrying cleanly. A response that conflates line bet and total bet produces win display bugs that look like the game is cheating. A request that accepts a float for bet amount instead of a string invites floating-point rounding errors into the financial layer.

This article is the complete specification of the spin protocol — request, response, errors, edge cases, and all the mechanics that sit between the simple happy path and the dozen failure modes that production traffic will exercise. We build the contract from first principles, explain every field design decision, implement the full server-side controller and DTOs in C#, produce the TypeScript types for the client, and cover every edge case the protocol must handle: disconnections mid-spin, bonus round state synchronisation, currency precision, versioning, and the reconnect flow that saves sessions when the network drops.


Part I. Protocol Design Principles

1.1 The Contract's Job

The spin protocol contract exists to satisfy four simultaneous consumers:

The PixiJS client needs to know: which symbols landed where, what combinations won and how much, whether a bonus was triggered, what the new balance is, and precisely enough information about every animation it needs to play.

The audit system needs to know: enough to reconstruct the exact spin outcome from the response alone, without consulting any other record — the reel stops, their sizes, every win with its payline, the bet, the balance before and after.

The certification lab needs to know: that the response is deterministic given the stored reel stops, that no data has been truncated or omitted, that the pay table mapping from (symbolId, count) to payout is unambiguous.

Future developers need to know: what each field means, why it exists, what its valid range is, and how it behaves in edge cases — all of which must be captured in the schema, not just in tribal knowledge.

A field that serves only one of these consumers is usually fine. A field that serves none of them is dead weight. A field whose interpretation depends on undocumented context is a liability.

1.2 Design Decisions Made Upfront

Before writing a single JSON key, settle the following decisions. These cannot be changed later without a breaking change to the protocol.

Monetary amounts: string, not number

// ❌ WRONG — floating-point representation of money
{ "betAmount": 1.1 }
// In IEEE 754 double: 1.1 is actually 1.100000000000000088817...
// Arithmetic on this in JavaScript produces visible rounding errors.

// ✓ CORRECT — decimal string
{ "betAmount": "1.10" }
// Parsed server-side to C# decimal, which is base-10 and exact.
// JavaScript client receives it as a string and formats for display.
// No floating-point arithmetic performed on monetary values anywhere.

Identifiers: string UUID, not integer

// ❌ WRONG — integer IDs
{ "spinId": 4281042 }
// JavaScript integers lose precision above 2^53.
// Sequential IDs reveal business volume to competitors.

// ✓ CORRECT — UUID string
{ "spinId": "01942a8f-6ec7-7b3e-a04b-d7c5f3b1e8a2" }
// UUID v7 (time-ordered): sortable, globally unique, no precision loss.

Timestamps: ISO 8601 UTC string

{ "timestamp": "2026-03-16T14:23:07.441Z" }
// Always UTC. Always millisecond precision. Always Z suffix.
// Never Unix timestamps (ambiguous in seconds vs milliseconds).

Symbol IDs: integer, 1-indexed

{ "symbolId": 3 }
// Integer because it's an index into the game's symbol table.
// 1-indexed so that 0 can safely mean "empty" without ambiguity.
// Never embed string symbol names — localisation happens client-side.

Reel stops and grid: flat integer arrays

{
  "reelStops": [14, 7, 22, 3, 18],
  "visibleGrid": [[9,10,11],[3,4,5],[7,8,9],[10,11,9],[4,5,6]]
}
// visibleGrid[col][row] — column-major indexing.
// Flat arrays are smaller than nested objects and faster to parse.

Paylines: referenced by index, not embedded

// ❌ WRONG — embed payline definition in every win
{ "wins": [{ "payline": [1,1,1,1,1], "symbolId": 3 }] }
// Bloats response, payline definitions belong in game config.

// ✓ CORRECT — reference by index
{ "wins": [{ "paylineIndex": 0, "symbolId": 3 }] }
// Client looks up payline[0] from game config to know which rows.

1.3 Versioning Strategy

The API must be versioned from day one. Operators keep games live for years, and aggregators may have multiple versions in flight simultaneously.

URL versioning (preferred for iGaming):
  POST /api/v1/game/spin    ← current version
  POST /api/v2/game/spin    ← next major version (breaking changes)

Header versioning (for minor/additive changes):
  X-Api-Version: 1.3        ← patch level within major version

Additive changes (new optional response fields) are non-breaking and do not require a URL version bump. Removing fields, renaming fields, or changing field semantics always requires a URL version bump.


Part II. The Game Configuration Endpoint

2.1 Why Config Comes First

Before the first spin, the client must fetch the game configuration. This endpoint returns the complete mathematical and UI configuration needed to: render the game correctly, validate bets locally, display pay tables, map symbol IDs to assets, and interpret every subsequent spin response.

GET /api/v1/game/config?token={sessionToken}

2.2 The Config Response Schema

{
  "gameId": "crystal-forge-v1",
  "gameVersion": "1.4.2",
  "gameName": "Crystal Forge",

  "grid": {
    "columns":     5,
    "rows":        3,
    "reelSizes":   [32, 32, 32, 32, 32]
  },

  "paylines": [
    [1, 1, 1, 1, 1],
    [0, 0, 0, 0, 0],
    [2, 2, 2, 2, 2],
    [0, 1, 2, 1, 0],
    [2, 1, 0, 1, 2]
  ],

  "symbols": [
    { "id": 1,  "key": "wild",    "type": "wild",    "name": "Wild Crystal" },
    { "id": 2,  "key": "scatter", "type": "scatter", "name": "Scatter Gem" },
    { "id": 3,  "key": "diamond", "type": "premium", "name": "Diamond" },
    { "id": 4,  "key": "ruby",    "type": "premium", "name": "Ruby" },
    { "id": 5,  "key": "emerald", "type": "premium", "name": "Emerald" },
    { "id": 6,  "key": "gold",    "type": "mid",     "name": "Gold" },
    { "id": 7,  "key": "silver",  "type": "mid",     "name": "Silver" },
    { "id": 8,  "key": "ace",     "type": "low",     "name": "Ace" },
    { "id": 9,  "key": "king",    "type": "low",     "name": "King" },
    { "id": 10, "key": "queen",   "type": "low",     "name": "Queen" },
    { "id": 11, "key": "jack",    "type": "low",     "name": "Jack" }
  ],

  "payTable": [
    { "symbolId": 1,  "payouts": { "5": "1000.00" } },
    { "symbolId": 3,  "payouts": { "3": "50.00", "4": "200.00", "5": "1000.00" } },
    { "symbolId": 4,  "payouts": { "3": "30.00", "4": "100.00", "5": "500.00"  } },
    { "symbolId": 5,  "payouts": { "3": "20.00", "4": "75.00",  "5": "300.00"  } },
    { "symbolId": 6,  "payouts": { "3": "10.00", "4": "40.00",  "5": "150.00"  } },
    { "symbolId": 7,  "payouts": { "3": "8.00",  "4": "25.00",  "5": "100.00"  } },
    { "symbolId": 8,  "payouts": { "3": "5.00",  "4": "15.00",  "5": "75.00"   } },
    { "symbolId": 9,  "payouts": { "3": "5.00",  "4": "15.00",  "5": "75.00"   } },
    { "symbolId": 10, "payouts": { "3": "4.00",  "4": "10.00",  "5": "50.00"   } },
    { "symbolId": 11, "payouts": { "3": "4.00",  "4": "10.00",  "5": "50.00"   } }
  ],

  "betConfig": {
    "currency":          "EUR",
    "minBet":            "0.20",
    "maxBet":            "100.00",
    "defaultBet":        "1.00",
    "coinValues":        ["0.01", "0.02", "0.05", "0.10", "0.20", "0.50", "1.00", "2.00", "5.00"],
    "linesCount":        20,
    "linesFixed":        true
  },

  "bonusConfig": {
    "scatterSymbolId":   2,
    "triggerCount":      3,
    "freeSpinsByCount": {
      "3": 10,
      "4": 15,
      "5": 20
    },
    "freeSpinsMultiplier": "2.50",
    "retriggerEnabled":  true,
    "retriggerSpins":    10
  },

  "session": {
    "playerId":          "player-a7f3b2",
    "balance":           "125.40",
    "currency":          "EUR",
    "resumeState":       null
  },

  "rtp": "96.00",
  "volatility": "medium-high",
  "maxWin": "50000.00"
}

2.3 C# Config Response DTO

public record GameConfigResponse(
    string              GameId,
    string              GameVersion,
    string              GameName,
    GridConfig          Grid,
    int[][]             Paylines,
    SymbolConfig[]      Symbols,
    PayTableEntry[]     PayTable,
    BetConfig           BetConfig,
    BonusConfig         BonusConfig,
    SessionInfo         Session,
    string              Rtp,
    string              Volatility,
    string              MaxWin
);

public record GridConfig(int Columns, int Rows, int[] ReelSizes);

public record SymbolConfig(int Id, string Key, string Type, string Name);

public record PayTableEntry(
    int                       SymbolId,
    Dictionary<string, string> Payouts    // key = count as string, value = multiplier
);

public record BetConfig(
    string   Currency,
    string   MinBet,
    string   MaxBet,
    string   DefaultBet,
    string[] CoinValues,
    int      LinesCount,
    bool     LinesFixed
);

public record BonusConfig(
    int                       ScatterSymbolId,
    int                       TriggerCount,
    Dictionary<string, int>   FreeSpinsByCount,      // key = scatter count
    string                    FreeSpinsMultiplier,
    bool                      RetriggerEnabled,
    int                       RetriggerSpins
);

public record SessionInfo(
    string   PlayerId,
    string   Balance,
    string   Currency,
    object?  ResumeState                             // non-null if mid-bonus on reconnect
);

Part III. The Spin Request

3.1 Request Schema

POST /api/v1/game/spin
Authorization: Bearer {sessionToken}
Content-Type: application/json
X-Request-Id: {client-generated UUID}      ← Client idempotency key
X-Client-Version: 1.4.2                   ← Game client version

{
  "betAmount": "1.00",
  "currency":  "EUR"
}

That is the complete spin request body. Intentionally minimal.

Notice what is not in the request:

No reel stops (the server generates those — never trust the client)

No outcome prediction (obviously)

No line selection (lines are fixed in this game)

No timestamp (server generates authoritative timestamps)

No session token in the body (it's in the Authorization header)

No player ID (derived from the session token server-side)

Every field that is absent from the request is a field that cannot be forged by the client.

3.2 Why BetAmount Is in the Request Body

A common question: if the session holds the player's current bet level, why send betAmount in the request at all?

Two reasons. First, bet changes happen continuously — the player adjusts their bet between spins. Storing the current bet in the session would require a separate API call to update it before every spin, doubling the round-trip count. It is simpler and more correct to treat every spin as a self-contained declaration: "I am betting this amount, right now."

Second, having betAmount explicitly in the request makes every spin record self-describing. When reconstructing a spin from the audit log, you know the exact bet without needing to query session history.

3.3 Request Validation

Server-side validation is strict and covers every possible client error:

public sealed class SpinRequestValidator : AbstractValidator<SpinRequest>
{
    private readonly GameConfig _config;

    public SpinRequestValidator(GameConfig config)
    {
        _config = config;

        RuleFor(x => x.BetAmount)
            .NotEmpty()
            .WithMessage("betAmount is required.")
            .Must(BeValidDecimalString)
            .WithMessage("betAmount must be a valid decimal string (e.g. '1.00').")
            .Must(BePositive)
            .WithMessage("betAmount must be greater than zero.")
            .Must(BeWithinLimits)
            .WithMessage($"betAmount must be between {config.MinBet} and {config.MaxBet}.")
            .Must(BeAValidCoinValue)
            .WithMessage("betAmount must correspond to a valid coin value × line count.");

        RuleFor(x => x.Currency)
            .NotEmpty()
            .WithMessage("currency is required.")
            .Length(3)
            .WithMessage("currency must be a 3-character ISO 4217 code.")
            .Must(MatchSessionCurrency)
            .WithMessage("currency must match the session currency.");
    }

    private bool BeValidDecimalString(string value)
        => decimal.TryParse(value, NumberStyles.Number,
               CultureInfo.InvariantCulture, out _);

    private bool BePositive(string value)
        => decimal.TryParse(value, NumberStyles.Number,
               CultureInfo.InvariantCulture, out decimal d) && d > 0;

    private bool BeWithinLimits(string value)
    {
        if (!decimal.TryParse(value, NumberStyles.Number,
                CultureInfo.InvariantCulture, out decimal d)) return false;
        return d >= _config.MinBet && d <= _config.MaxBet;
    }

    private bool BeAValidCoinValue(string value)
    {
        if (!decimal.TryParse(value, NumberStyles.Number,
                CultureInfo.InvariantCulture, out decimal d)) return false;
        // Total bet must equal coinValue × linesCount
        // where coinValue is in the allowed list
        decimal coinValue = d / _config.LinesCount;
        return _config.CoinValues.Contains(coinValue);
    }

    private bool MatchSessionCurrency(SpinRequest request, string currency,
        ValidationContext<SpinRequest> context)
    {
        // Session currency is injected via context from the controller
        var session = context.RootContextData["session"] as GameSession;
        return session?.Currency == currency;
    }
}

3.4 The X-Request-Id Header: Client-Level Idempotency

The X-Request-Id header is a client-generated UUID that enables the client to safely retry a request if the response is lost in transit. The server stores this ID per session and returns the cached response if the same ID is received again within a time window.

/// <summary>
/// Middleware that implements client-level idempotency using X-Request-Id.
/// If the same X-Request-Id is received within 30 seconds,
/// returns the cached response without re-executing the spin.
/// </summary>
public class RequestIdempotencyMiddleware
{
    private readonly RequestDelegate   _next;
    private readonly IResponseCache    _cache;
    private readonly TimeSpan          _window = TimeSpan.FromSeconds(30);

    public async Task InvokeAsync(HttpContext context)
    {
        if (!context.Request.Headers.TryGetValue(
                "X-Request-Id", out var requestId)
            || context.Request.Method != "POST"
            || !context.Request.Path.StartsWithSegments("/api/v1/game/spin"))
        {
            await _next(context);
            return;
        }

        // Extract session ID from auth token for cache namespacing
        string sessionId = ExtractSessionId(context);
        string cacheKey  = $"req:{sessionId}:{requestId}";

        // Check for cached response
        var cached = await _cache.GetAsync<string>(cacheKey);
        if (cached is not null)
        {
            context.Response.ContentType = "application/json";
            context.Response.Headers.Add("X-Idempotency-Replayed", "true");
            await context.Response.WriteAsync(cached);
            return;
        }

        // Capture response body for caching
        var originalBody   = context.Response.Body;
        using var buffer   = new MemoryStream();
        context.Response.Body = buffer;

        await _next(context);

        buffer.Seek(0, SeekOrigin.Begin);
        string responseBody = await new StreamReader(buffer).ReadToEndAsync();

        // Cache only successful responses (2xx)
        if (context.Response.StatusCode is >= 200 and < 300)
            await _cache.SetAsync(cacheKey, responseBody, _window);

        buffer.Seek(0, SeekOrigin.Begin);
        await buffer.CopyToAsync(originalBody);
        context.Response.Body = originalBody;
    }
}

Part IV. The Spin Response — Base Game

4.1 Complete Base Game Response Schema

{
  "spinId":      "01942a8f-6ec7-7b3e-a04b-d7c5f3b1e8a2",
  "timestamp":   "2026-03-16T14:23:07.441Z",
  "roundId":     "01942a8f-6ec7-7b3e-a04b-d7c5f3b1e8a2",

  "reelStops":   [14, 7, 22, 3, 18],

  "visibleGrid": [
    [9, 10, 11],
    [3,  7,  8],
    [1,  4,  5],
    [6,  9,  3],
    [10, 11, 9]
  ],

  "wins": [
    {
      "paylineIndex": 0,
      "symbolId":     3,
      "count":        3,
      "multiplier":   "50.00",
      "winAmount":    "2.50",
      "positions": [
        { "col": 0, "row": 1 },
        { "col": 1, "row": 1 },
        { "col": 2, "row": 1 }
      ]
    }
  ],

  "totalWin":    "2.50",
  "betAmount":   "1.00",
  "currency":    "EUR",

  "balance": {
    "before":    "125.40",
    "after":     "126.90"
  },

  "scatterCount": 0,
  "bonusTriggered": false,
  "bonusData":   null,

  "state": "idle",

  "metadata": {
    "rngVersion":    "CSPRNG-OS-v1.2",
    "gameVersion":   "1.4.2",
    "processingMs":  47
  }
}

4.2 Field-by-Field Specification

Identification Fields

spinId      UUID v7 string
            Globally unique identifier for this spin.
            Used in: audit log, player disputes, regulatory queries.
            Never reused. Monotonically increasing (v7 is time-ordered).

timestamp   ISO 8601 UTC string to millisecond precision
            Server-authoritative time. Never derived from client clock.
            Used in: audit ordering, regulatory reports.

roundId     UUID v7 string
            Links together all operations in one "game round".
            For base game spins: roundId == spinId.
            For Free Spins: roundId is shared across all spins in the block.
            Used in: dispute resolution, bonus round tracking.

Grid Fields

reelStops   int[] of length = number of reels
            Reel stop positions (0-indexed within each reel strip).
            Combined with reelSizes from config, fully determines the grid.
            Stored in audit log for spin reconstruction.

visibleGrid int[][] — column-major: visibleGrid[col][row]
            row 0 = top row, row 1 = middle, row 2 = bottom.
            Every entry is a symbolId (1-indexed) from the symbol table.
            Redundant with reelStops + reelStrips, but included for:
            - Client rendering (immediate, no client-side strip logic needed)
            - Audit reconstruction convenience
            - Certification lab inspection

Win Fields

wins        array of WinResult objects (empty array if no wins)

  paylineIndex  int
                Index into the paylines array from game config.
                Client uses config.paylines[paylineIndex] to determine
                which cells to highlight.

  symbolId      int
                The symbol that formed this combination.
                For Wild-only combinations: use Wild's symbolId (1).
                For Wild-substituted combinations: use the base symbol's ID.

  count         int (3, 4, or 5)
                How many symbols formed the combination.
                Always starts from reel 1 (leftmost).

  multiplier    decimal string
                The pay table multiplier for this (symbolId, count) pair.
                Expressed as multiple of line bet.
                Example: "50.00" means 50 × lineBet.

  winAmount     decimal string
                Actual win in currency units: multiplier × lineBet.
                = multiplier × (betAmount / linesCount)
                This is what gets credited to the player from this line.

  positions     array of {col, row} objects
                Exact grid coordinates of each symbol in the combination.
                Used by client to highlight individual cells.
                Always col-indexed from 0, row-indexed from 0 (top).

totalWin    decimal string
            Sum of all winAmount values across all wins.
            Zero if no wins. Never negative.
            This is the exact amount credited to player this spin.

Balance Fields

balance.before  decimal string
                Player's balance before this spin's bet was deducted.
                Used in: audit log, balance display on disconnect recovery.

balance.after   decimal string
                Player's balance after bet deduction and win credit.
                = balance.before - betAmount + totalWin
                Client updates its displayed balance to this value.
                Must be exact: no rounding, no floating-point error.

State Fields

scatterCount    int (0–5)
                Number of scatter symbols visible anywhere on the grid.
                Included even when bonus was not triggered.
                Used by client to animate scatter symbols appropriately.

bonusTriggered  bool
                True when scatterCount >= bonusTriggerCount from config.
                When true: bonusData is populated, state transitions to bonus.

bonusData       null | BonusData object
                Populated only when bonusTriggered = true.
                Contains: type, freeSpinsAwarded, initialMultiplier.

state           string enum
                "idle"          — spin completed, ready for next spin
                "bonus"         — bonus round is active, use /bonus/spin
                "bonus_complete"— bonus just ended, state returning to idle

Metadata Fields

metadata.rngVersion     string
                        Algorithm version tag stored in audit log.
                        Enables identification of which RNG produced this spin.

metadata.gameVersion    string
                        Exact game version that produced this spin.
                        Matches the certified binary version.

metadata.processingMs   int
                        Server-side processing time in milliseconds.
                        Useful for performance monitoring, not game logic.
                        Client may log this; never uses it for decisions.

4.3 C# Response DTOs

public record SpinResponse(
    string              SpinId,
    string              Timestamp,
    string              RoundId,
    int[]               ReelStops,
    int[][]             VisibleGrid,
    WinResult[]         Wins,
    string              TotalWin,
    string              BetAmount,
    string              Currency,
    BalanceSnapshot     Balance,
    int                 ScatterCount,
    bool                BonusTriggered,
    BonusData?          BonusData,
    string              State,
    SpinMetadata        Metadata
);

public record WinResult(
    int              PaylineIndex,
    int              SymbolId,
    int              Count,
    string           Multiplier,
    string           WinAmount,
    GridPosition[]   Positions
);

public record GridPosition(int Col, int Row);

public record BalanceSnapshot(string Before, string After);

public record BonusData(
    string   Type,              // "free_spins" | "pick_bonus" | "hold_and_win"
    int      FreeSpinsAwarded,
    string   InitialMultiplier,
    string   BlockId            // Unique ID for this Free Spins block
);

public record SpinMetadata(
    string   RngVersion,
    string   GameVersion,
    int      ProcessingMs
);

4.4 JSON Serialisation Configuration

// In Program.cs / Startup.cs
builder.Services.AddControllers()
    .AddJsonOptions(options =>
    {
        var json = options.JsonSerializerOptions;

        // snake_case for API consistency with other iGaming systems
        json.PropertyNamingPolicy        = JsonNamingPolicy.SnakeCaseLower;
        json.DefaultIgnoreCondition      = JsonIgnoreCondition.WhenWritingNull;
        json.NumberHandling              = JsonNumberHandling.Strict;
        // ^ Disallows numbers where strings are expected — enforces our contract

        json.Converters.Add(new JsonStringEnumConverter());
        // Enum values serialised as strings, not integers
    });

// Custom converter to ensure decimal → string (never number) in JSON
public class DecimalStringConverter : JsonConverter<decimal>
{
    public override decimal Read(ref Utf8JsonReader reader,
        Type type, JsonSerializerOptions options)
    {
        if (reader.TokenType == JsonTokenType.String)
        {
            if (decimal.TryParse(reader.GetString(),
                    NumberStyles.Number, CultureInfo.InvariantCulture, out var d))
                return d;
        }
        throw new JsonException("Expected decimal string.");
    }

    public override void Write(Utf8JsonWriter writer,
        decimal value, JsonSerializerOptions options)
    {
        // Always serialise with exactly 2 decimal places
        writer.WriteStringValue(value.ToString("F2", CultureInfo.InvariantCulture));
    }
}

Part V. The Spin Response — Free Spins

5.1 Free Spins Spin Request

During a Free Spins block, the client calls a separate endpoint. The bet amount is fixed (inherited from the triggering spin) and cannot be changed. The request is minimal:

POST /api/v1/game/bonus/spin
Authorization: Bearer {sessionToken}
Content-Type: application/json
X-Request-Id: {UUID}

json

{
  "blockId": "01942b1f-7abc-7e4f-b05c-e8d6f4c2a9b3"
}

The blockId is provided by the server in the bonusData of the triggering spin and in every subsequent Free Spin response. It serves as the idempotency anchor for the entire Free Spins block.

5.2 Free Spins Response Schema

{
  "spinId":    "01942b2a-8def-7f5g-c06d-f9e7g5d3b0c4",
  "timestamp": "2026-03-16T14:23:15.882Z",
  "roundId":   "01942b1f-7abc-7e4f-b05c-e8d6f4c2a9b3",

  "reelStops":   [5, 29, 11, 17, 24],
  "visibleGrid": [
    [3, 1, 4],
    [5, 3, 8],
    [1, 6, 2],
    [3, 4, 9],
    [7, 3, 5]
  ],

  "wins": [
    {
      "paylineIndex": 0,
      "symbolId":     3,
      "count":        4,
      "multiplier":   "200.00",
      "winAmount":    "25.00",
      "positions": [
        { "col": 0, "row": 1 },
        { "col": 1, "row": 1 },
        { "col": 2, "row": 0 },
        { "col": 3, "row": 1 }
      ]
    }
  ],

  "totalWin":   "25.00",
  "betAmount":  "1.00",
  "currency":   "EUR",

  "balance": {
    "before":   "124.40",
    "after":    "149.40"
  },

  "scatterCount": 1,
  "bonusTriggered": false,
  "bonusData":   null,

  "freeSpins": {
    "blockId":          "01942b1f-7abc-7e4f-b05c-e8d6f4c2a9b3",
    "spinNumber":       3,
    "totalSpins":       10,
    "spinsRemaining":   7,
    "multiplier":       "2.50",
    "multiplierApplied": "2.50",
    "blockTotalWin":    "28.50",
    "retriggerOccurred": false,
    "retriggerSpinsAdded": 0
  },

  "state": "bonus",

  "metadata": {
    "rngVersion":   "CSPRNG-OS-v1.2",
    "gameVersion":  "1.4.2",
    "processingMs": 52
  }
}

5.3 The freeSpins Object — Field Specification

freeSpins.blockId           string UUID
                            The ID of the current Free Spins block.
                            Constant across all spins in this block.
                            Used by client to identify the correct block state.

freeSpins.spinNumber        int (1-based)
                            Which spin within this block this is.
                            Monotonically increasing, never skipped.

freeSpins.totalSpins        int
                            Total spins committed to this block
                            (including any retrigger additions).
                            Updated when a retrigger occurs.

freeSpins.spinsRemaining    int
                            How many spins remain after this one.
                            = totalSpins - spinNumber
                            Client uses this to display the counter.
                            When spinsRemaining = 0: this is the final spin.

freeSpins.multiplier        decimal string
                            The multiplier currently active for THIS spin's wins.
                            Fixed for constant-multiplier games.
                            Progressive for accumulating-multiplier games.

freeSpins.multiplierApplied decimal string
                            The multiplier actually applied to totalWin.
                            Usually equals multiplier, but included explicitly
                            to prevent any ambiguity in audit reconstruction.

freeSpins.blockTotalWin     decimal string
                            Running total of wins in this block so far,
                            INCLUDING the win from this spin.
                            Allows client to show cumulative block win.

freeSpins.retriggerOccurred bool
                            True if this spin triggered a retrigger.
                            When true: retriggerSpinsAdded > 0 and
                            totalSpins increased by that amount.

freeSpins.retriggerSpinsAdded int
                            Number of additional spins added by retrigger.
                            Zero if retriggerOccurred = false.

5.4 Free Spins Block Completion Response

When spinsRemaining = 0, the response signals end of the bonus block:

{
  "spinId":    "01942c3b-9ef0-7g6h-d07e-g0f8h6e4c1d5",
  "timestamp": "2026-03-16T14:23:58.103Z",
  "roundId":   "01942b1f-7abc-7e4f-b05c-e8d6f4c2a9b3",

  "reelStops":   [12, 4, 19, 28, 7],
  "visibleGrid": [[8,9,10],[11,3,4],[6,7,8],[9,10,5],[3,4,11]],

  "wins": [],
  "totalWin": "0.00",
  "betAmount": "1.00",
  "currency":  "EUR",

  "balance": {
    "before": "192.50",
    "after":  "192.50"
  },

  "scatterCount": 0,
  "bonusTriggered": false,
  "bonusData": null,

  "freeSpins": {
    "blockId":            "01942b1f-7abc-7e4f-b05c-e8d6f4c2a9b3",
    "spinNumber":         10,
    "totalSpins":         10,
    "spinsRemaining":     0,
    "multiplier":         "2.50",
    "multiplierApplied":  "2.50",
    "blockTotalWin":      "68.00",
    "retriggerOccurred":  false,
    "retriggerSpinsAdded": 0
  },

  "blockSummary": {
    "blockId":        "01942b1f-7abc-7e4f-b05c-e8d6f4c2a9b3",
    "totalSpinsPlayed": 10,
    "totalWin":       "68.00",
    "triggeringBet":  "1.00",
    "winMultiple":    "68.00"
  },

  "state": "bonus_complete",

  "metadata": {
    "rngVersion":   "CSPRNG-OS-v1.2",
    "gameVersion":  "1.4.2",
    "processingMs": 44
  }
}

The blockSummary object gives the client everything it needs to display the "You won X× your bet!" celebration screen after the Free Spins block completes.

The state: "bonus_complete" tells the client that the next call should return to POST /api/v1/game/spin (base game), not POST /api/v1/game/bonus/spin.


Part VI. Error Responses

6.1 Error Response Schema

All errors follow a single, consistent structure:

{
  "error": {
    "code":      "INSUFFICIENT_FUNDS",
    "message":   "Player balance is insufficient for the requested bet.",
    "requestId": "req-01942a8f-6ec7-7b3e",
    "timestamp": "2026-03-16T14:23:07.441Z",
    "retryable": false,
    "retryAfterMs": null
  }
}

error.code          string (SCREAMING_SNAKE_CASE)
                    Machine-readable error identifier.
                    Client maps this to a localised error message.
                    Stable across versions — never changed, only deprecated.

error.message       string
                    Human-readable description in English.
                    For developer use only, never shown to players.
                    May change across versions without breaking compatibility.

error.requestId     string
                    The X-Request-Id from the request (if provided).
                    Allows correlation of the error with the failed request.

error.timestamp     ISO 8601 UTC string
                    When the error occurred server-side.

error.retryable     bool
                    Whether the client should automatically retry this request.
                    true: transient error — retry with same X-Request-Id.
                    false: permanent error — do not retry, show user message.

error.retryAfterMs  int | null
                    If retryable = true: minimum delay before retry in ms.
                    null if retryable = false or no specific delay needed.

6.2 Complete Error Code Catalogue

/// <summary>
/// All error codes returned by the spin API.
/// Codes are stable: once defined, never removed (only deprecated).
/// </summary>
public static class SpinErrorCodes
{
    // ── HTTP 400 — Bad Request ───────────────────────────────────
    /// Bet amount string cannot be parsed as a decimal.
    public const string InvalidBetFormat    = "INVALID_BET_FORMAT";

    /// Bet amount is below the minimum allowed.
    public const string BetBelowMinimum     = "BET_BELOW_MINIMUM";

    /// Bet amount exceeds the maximum allowed.
    public const string BetAboveMaximum     = "BET_ABOVE_MAXIMUM";

    /// Bet amount does not correspond to a valid coin value.
    public const string InvalidCoinValue    = "INVALID_COIN_VALUE";

    /// Currency code does not match the session currency.
    public const string CurrencyMismatch    = "CURRENCY_MISMATCH";

    /// Request is missing required field.
    public const string MissingField        = "MISSING_FIELD";

    // ── HTTP 401 — Unauthorised ──────────────────────────────────
    /// Session token is missing or malformed.
    public const string MissingToken        = "MISSING_TOKEN";

    /// Session token has expired (player session timeout).
    public const string SessionExpired      = "SESSION_EXPIRED";

    /// Session token is invalid or not recognised by the aggregator.
    public const string InvalidToken        = "INVALID_TOKEN";

    // ── HTTP 403 — Forbidden ─────────────────────────────────────
    /// Player has been self-excluded (responsible gambling).
    public const string PlayerSelfExcluded  = "PLAYER_SELF_EXCLUDED";

    /// Player is in a cooling-off period.
    public const string PlayerCoolingOff    = "PLAYER_COOLING_OFF";

    /// Game is not available in the player's jurisdiction.
    public const string JurisdictionBlocked = "JURISDICTION_BLOCKED";

    // ── HTTP 409 — Conflict ──────────────────────────────────────
    /// A spin is already in progress for this session.
    /// Client should wait and retry after a delay.
    public const string SpinInProgress      = "SPIN_IN_PROGRESS";

    /// Base game spin attempted while bonus round is active.
    /// Client should use /bonus/spin instead.
    public const string BonusRoundActive    = "BONUS_ROUND_ACTIVE";

    /// Bonus spin attempted when no bonus round is active.
    /// Client should use /spin instead.
    public const string NoBonusRoundActive  = "NO_BONUS_ROUND_ACTIVE";

    /// Provided blockId does not match the active bonus block.
    public const string BlockIdMismatch     = "BLOCK_ID_MISMATCH";

    // ── HTTP 402 — Payment Required ──────────────────────────────
    /// Player balance is insufficient for the requested bet.
    public const string InsufficientFunds   = "INSUFFICIENT_FUNDS";

    // ── HTTP 503 — Service Unavailable ───────────────────────────
    /// Wallet service is temporarily unavailable. Retryable.
    public const string WalletUnavailable   = "WALLET_UNAVAILABLE";

    /// Game is under maintenance. Non-retryable.
    public const string GameMaintenance     = "GAME_UNDER_MAINTENANCE";

    // ── HTTP 500 — Internal Server Error ─────────────────────────
    /// Unexpected server error. Retryable after delay.
    public const string InternalError       = "INTERNAL_ERROR";

    // ── Retryability Map ─────────────────────────────────────────
    public static readonly IReadOnlyDictionary<string, (bool Retryable, int? RetryAfterMs)>
        RetryPolicy = new Dictionary<string, (bool, int?)>
    {
        [InvalidBetFormat]    = (false, null),
        [BetBelowMinimum]     = (false, null),
        [BetAboveMaximum]     = (false, null),
        [InvalidCoinValue]    = (false, null),
        [CurrencyMismatch]    = (false, null),
        [MissingField]        = (false, null),
        [MissingToken]        = (false, null),
        [SessionExpired]      = (false, null),
        [InvalidToken]        = (false, null),
        [PlayerSelfExcluded]  = (false, null),
        [PlayerCoolingOff]    = (false, null),
        [JurisdictionBlocked] = (false, null),
        [SpinInProgress]      = (true,  2000),  // retry after 2s
        [BonusRoundActive]    = (false, null),
        [NoBonusRoundActive]  = (false, null),
        [BlockIdMismatch]     = (false, null),
        [InsufficientFunds]   = (false, null),
        [WalletUnavailable]   = (true,  5000),  // retry after 5s
        [GameMaintenance]     = (false, null),
        [InternalError]       = (true,  3000),  // retry after 3s
    };
}

6.3 Error Response Builder

public static class ErrorResponseFactory
{
    public static IActionResult Create(
        string errorCode, HttpContext httpContext, int httpStatus = 0)
    {
        var (retryable, retryAfterMs) =
            SpinErrorCodes.RetryPolicy.TryGetValue(errorCode, out var policy)
            ? policy
            : (false, (int?)null);

        int statusCode = httpStatus > 0
            ? httpStatus
            : DeriveHttpStatus(errorCode);

        var response = new
        {
            error = new
            {
                code         = errorCode,
                message      = GetMessage(errorCode),
                requestId    = httpContext.Request.Headers["X-Request-Id"]
                                   .FirstOrDefault() ?? "unknown",
                timestamp    = DateTime.UtcNow.ToString("O"),
                retryable    = retryable,
                retryAfterMs = retryAfterMs
            }
        };

        return new ObjectResult(response) { StatusCode = statusCode };
    }

    private static int DeriveHttpStatus(string code) => code switch
    {
        SpinErrorCodes.MissingToken or
        SpinErrorCodes.SessionExpired or
        SpinErrorCodes.InvalidToken          => 401,
        SpinErrorCodes.PlayerSelfExcluded or
        SpinErrorCodes.PlayerCoolingOff or
        SpinErrorCodes.JurisdictionBlocked   => 403,
        SpinErrorCodes.InsufficientFunds     => 402,
        SpinErrorCodes.SpinInProgress or
        SpinErrorCodes.BonusRoundActive or
        SpinErrorCodes.NoBonusRoundActive or
        SpinErrorCodes.BlockIdMismatch       => 409,
        SpinErrorCodes.WalletUnavailable or
        SpinErrorCodes.GameMaintenance       => 503,
        SpinErrorCodes.InternalError         => 500,
        _                                    => 400
    };

    private static string GetMessage(string code) => code switch
    {
        SpinErrorCodes.InsufficientFunds =>
            "Player balance is insufficient for the requested bet.",
        SpinErrorCodes.BonusRoundActive =>
            "A bonus round is active. Use POST /api/v1/game/bonus/spin.",
        SpinErrorCodes.SpinInProgress =>
            "A spin is already in progress for this session. Retry after delay.",
        SpinErrorCodes.WalletUnavailable =>
            "The wallet service is temporarily unavailable. Retry after delay.",
        SpinErrorCodes.SessionExpired =>
            "The session has expired. Player must re-authenticate.",
        _ =>
            $"An error occurred: {code}"
    };
}

Part VII. The Full Controller Implementation

7.1 SpinController

[ApiController]
[Route("api/v1/game")]
[Produces("application/json")]
public sealed class SpinController : ControllerBase
{
    private readonly SpinUseCase       _spinUseCase;
    private readonly BonusSpinUseCase  _bonusSpinUseCase;
    private readonly GameStateUseCase  _gameStateUseCase;
    private readonly ISessionCache     _sessions;
    private readonly ILogger<SpinController> _logger;

    // ── GET /config ───────────────────────────────────────────────

    [HttpGet("config")]
    [ProducesResponseType(typeof(GameConfigResponse), 200)]
    [ProducesResponseType(401)]
    public async Task<IActionResult> GetConfig(
        [FromQuery] string token,
        CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(token))
            return ErrorResponseFactory.Create(SpinErrorCodes.MissingToken, HttpContext);

        var session = await _sessions.GetAsync(token, ct);
        if (session is null)
            return ErrorResponseFactory.Create(SpinErrorCodes.InvalidToken, HttpContext);

        var config = await _gameStateUseCase.GetConfigAsync(session, ct);
        return Ok(config);
    }

    // ── POST /spin ────────────────────────────────────────────────

    [HttpPost("spin")]
    [ProducesResponseType(typeof(SpinResponse), 200)]
    [ProducesResponseType(typeof(ErrorEnvelope), 400)]
    [ProducesResponseType(typeof(ErrorEnvelope), 401)]
    [ProducesResponseType(typeof(ErrorEnvelope), 402)]
    [ProducesResponseType(typeof(ErrorEnvelope), 409)]
    [ProducesResponseType(typeof(ErrorEnvelope), 503)]
    public async Task<IActionResult> Spin(
        [FromBody] SpinRequest request,
        CancellationToken ct)
    {
        // Extract session from Authorization: Bearer {token}
        string? token = ExtractBearerToken();
        if (token is null)
            return ErrorResponseFactory.Create(SpinErrorCodes.MissingToken, HttpContext);

        var session = await _sessions.GetAsync(token, ct);
        if (session is null)
            return ErrorResponseFactory.Create(SpinErrorCodes.SessionExpired, HttpContext);

        // Validate request in session context
        var validationResult = await ValidateSpinRequest(request, session);
        if (!validationResult.IsValid)
            return ErrorResponseFactory.Create(
                validationResult.FirstErrorCode, HttpContext);

        // Check state conflicts
        if (session.IsInBonus)
            return ErrorResponseFactory.Create(SpinErrorCodes.BonusRoundActive, HttpContext);

        if (session.SpinInProgress)
            return ErrorResponseFactory.Create(SpinErrorCodes.SpinInProgress, HttpContext);

        try
        {
            var response = await _spinUseCase.ExecuteAsync(
                new SpinRequest(request.BetAmount, request.Currency, token), ct);

            return Ok(response);
        }
        catch (InsufficientFundsException)
        {
            return ErrorResponseFactory.Create(SpinErrorCodes.InsufficientFunds, HttpContext);
        }
        catch (WalletUnavailableException ex)
        {
            _logger.LogWarning(ex, "Wallet unavailable during spin");
            return ErrorResponseFactory.Create(SpinErrorCodes.WalletUnavailable, HttpContext);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unexpected error during spin for session {SessionId}",
                session.SessionId);
            return ErrorResponseFactory.Create(SpinErrorCodes.InternalError, HttpContext);
        }
    }

    // ── POST /bonus/spin ──────────────────────────────────────────

    [HttpPost("bonus/spin")]
    [ProducesResponseType(typeof(SpinResponse), 200)]
    [ProducesResponseType(typeof(ErrorEnvelope), 401)]
    [ProducesResponseType(typeof(ErrorEnvelope), 409)]
    public async Task<IActionResult> BonusSpin(
        [FromBody] BonusSpinRequest request,
        CancellationToken ct)
    {
        string? token = ExtractBearerToken();
        if (token is null)
            return ErrorResponseFactory.Create(SpinErrorCodes.MissingToken, HttpContext);

        var session = await _sessions.GetAsync(token, ct);
        if (session is null)
            return ErrorResponseFactory.Create(SpinErrorCodes.SessionExpired, HttpContext);

        if (!session.IsInBonus)
            return ErrorResponseFactory.Create(SpinErrorCodes.NoBonusRoundActive, HttpContext);

        if (session.BonusState?.BlockId != request.BlockId)
            return ErrorResponseFactory.Create(SpinErrorCodes.BlockIdMismatch, HttpContext);

        if (session.SpinInProgress)
            return ErrorResponseFactory.Create(SpinErrorCodes.SpinInProgress, HttpContext);

        try
        {
            var response = await _bonusSpinUseCase.ExecuteAsync(session, ct);
            return Ok(response);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error during bonus spin, blockId={BlockId}",
                request.BlockId);
            return ErrorResponseFactory.Create(SpinErrorCodes.InternalError, HttpContext);
        }
    }

    // ── GET /state ────────────────────────────────────────────────

    [HttpGet("state")]
    [ProducesResponseType(typeof(GameStateResponse), 200)]
    public async Task<IActionResult> GetState(CancellationToken ct)
    {
        string? token = ExtractBearerToken();
        if (token is null)
            return ErrorResponseFactory.Create(SpinErrorCodes.MissingToken, HttpContext);

        var session = await _sessions.GetAsync(token, ct);
        if (session is null)
            return ErrorResponseFactory.Create(SpinErrorCodes.SessionExpired, HttpContext);

        var state = await _gameStateUseCase.GetStateAsync(session, ct);
        return Ok(state);
    }

    private string? ExtractBearerToken()
    {
        var authHeader = Request.Headers.Authorization.FirstOrDefault();
        if (authHeader?.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) == true)
            return authHeader["Bearer ".Length..].Trim();
        return null;
    }

    private async Task<ValidationResult> ValidateSpinRequest(
        SpinRequest request, GameSession session)
    {
        var validator = new SpinRequestValidator(_config);
        var context   = new ValidationContext<SpinRequest>(request);
        context.RootContextData["session"] = session;
        return await validator.ValidateAsync(context);
    }
}

Part VIII. The Reconnect Protocol

8.1 What Happens When the Connection Drops

Players disconnect. Networks fail. Browser tabs crash. Laptops lose WiFi. The protocol must handle all of these gracefully — the player must never lose a spin result or be stuck in an unrecoverable state.

The reconnect flow:

1. Player's connection drops mid-spin
   (after spin sent, before response received)
           ↓
2. Player reopens the game (same or different device)
           ↓
3. Client calls GET /api/v1/game/config?token={token}
           ↓
4. Server detects: session.SpinInProgress = true
   OR session.IsInBonus = true (bonus was mid-block)
           ↓
5. Config response includes resumeState with full context
           ↓
6. Client displays "Resuming your session..." animation
           ↓
7a. If SpinInProgress:
    Client calls GET /api/v1/game/state
    Server returns the completed spin result (it was computed server-side)
    Client animates the result normally
           ↓
7b. If IsInBonus with no SpinInProgress:
    Config includes full bonus state
    Client transitions directly to bonus screen
    Player clicks "Continue" to play next bonus spin

8.2 The State Endpoint Response

{
  "sessionId":      "sess-01942a8f",
  "state":          "bonus",
  "balance":        "149.40",
  "currency":       "EUR",
  "pendingSpin":    null,
  "bonusState": {
    "type":             "free_spins",
    "blockId":          "01942b1f-7abc-7e4f-b05c-e8d6f4c2a9b3",
    "spinNumber":       3,
    "totalSpins":       10,
    "spinsRemaining":   7,
    "multiplier":       "2.50",
    "blockTotalWin":    "28.50",
    "triggeringBet":    "1.00"
  }
}

If pendingSpin is non-null, the server has a completed spin result waiting for the client. The client should call GET /api/v1/game/state/pending-spin to retrieve it:

{
  "pendingSpin": {
    "spinId":    "01942a8f-6ec7-7b3e-a04b-d7c5f3b1e8a2",
    "reelStops": [14, 7, 22, 3, 18],
    "visibleGrid": [[9,10,11],[3,7,8],[1,4,5],[6,9,3],[10,11,9]],
    "wins": [...],
    "totalWin": "2.50",
    "bonusTriggered": false,
    "bonusData": null,
    "state": "idle"
  }
}

The client processes this exactly as if it had received the spin response in real-time. The player sees the result, even though they disconnected before it arrived originally.

8.3 Concurrent Spin Prevention

The SpinInProgress flag in the session prevents duplicate spins from concurrent connections:

public sealed class SpinGuard : IAsyncDisposable
{
    private readonly ISessionCache _cache;
    private readonly string        _sessionId;
    private bool                   _released;

    private SpinGuard(ISessionCache cache, string sessionId)
    {
        _cache     = cache;
        _sessionId = sessionId;
    }

    public static async Task<SpinGuard?> TryAcquireAsync(
        ISessionCache cache, string sessionId, CancellationToken ct)
    {
        // Atomic compare-and-set: only succeeds if SpinInProgress is false
        bool acquired = await cache.SetSpinInProgressAsync(
            sessionId, value: true, onlyIfFalse: true, ct);

        return acquired ? new SpinGuard(cache, sessionId) : null;
    }

    public async ValueTask DisposeAsync()
    {
        if (_released) return;
        _released = true;
        await _cache.SetSpinInProgressAsync(_sessionId, value: false, ct: default);
    }
}

// Usage in SpinUseCase:
await using var guard = await SpinGuard.TryAcquireAsync(_sessions, session.SessionId, ct);
if (guard is null)
    throw new SpinInProgressException();

// ... rest of spin logic ...
// Guard is released in DisposeAsync() even on exception

Part IX. TypeScript Client Types

9.1 Complete TypeScript Interface Definitions

The client needs precise TypeScript types that mirror the server's C# DTOs. These should be generated from the OpenAPI schema, but here is the manual definition for reference:

// ── Shared Types ──────────────────────────────────────────────────

export type DecimalString = string;  // Always exactly 2 decimal places
export type IsoTimestamp  = string;  // ISO 8601 UTC: "2026-03-16T14:23:07.441Z"
export type UuidString    = string;  // UUID v7: "01942a8f-6ec7-7b3e-a04b-..."

export type GameState =
  | 'idle'           // Ready for next base game spin
  | 'bonus'          // Free Spins block is active
  | 'bonus_complete' // Free Spins just ended, about to return to idle
  | 'suspended';     // Game suspended (maintenance, session issue)

// ── Config Types ──────────────────────────────────────────────────

export interface GameConfig {
  gameId:       string;
  gameVersion:  string;
  gameName:     string;
  grid:         GridConfig;
  paylines:     number[][];    // paylines[lineIndex][col] = row
  symbols:      SymbolConfig[];
  payTable:     PayTableEntry[];
  betConfig:    BetConfig;
  bonusConfig:  BonusConfig;
  session:      SessionInfo;
  rtp:          DecimalString;
  volatility:   string;
  maxWin:       DecimalString;
}

export interface GridConfig {
  columns:   number;
  rows:      number;
  reelSizes: number[];
}

export interface SymbolConfig {
  id:   number;
  key:  string;
  type: 'wild' | 'scatter' | 'premium' | 'mid' | 'low';
  name: string;
}

export interface PayTableEntry {
  symbolId: number;
  payouts:  Record<string, DecimalString>;  // { "3": "50.00", "4": "200.00", ... }
}

export interface BetConfig {
  currency:     string;
  minBet:       DecimalString;
  maxBet:       DecimalString;
  defaultBet:   DecimalString;
  coinValues:   DecimalString[];
  linesCount:   number;
  linesFixed:   boolean;
}

export interface BonusConfig {
  scatterSymbolId:     number;
  triggerCount:        number;
  freeSpinsByCount:    Record<string, number>;
  freeSpinsMultiplier: DecimalString;
  retriggerEnabled:    boolean;
  retriggerSpins:      number;
}

export interface SessionInfo {
  playerId:    string;
  balance:     DecimalString;
  currency:    string;
  resumeState: ResumeState | null;
}

export interface ResumeState {
  state:      GameState;
  bonusState: FreeSpisState | null;
}

// ── Spin Request ──────────────────────────────────────────────────

export interface SpinRequest {
  betAmount: DecimalString;
  currency:  string;
}

export interface BonusSpinRequest {
  blockId: UuidString;
}

// ── Spin Response ─────────────────────────────────────────────────

export interface SpinResponse {
  spinId:          UuidString;
  timestamp:       IsoTimestamp;
  roundId:         UuidString;
  reelStops:       number[];
  visibleGrid:     number[][];  // [col][row], 0-indexed
  wins:            WinResult[];
  totalWin:        DecimalString;
  betAmount:       DecimalString;
  currency:        string;
  balance:         BalanceSnapshot;
  scatterCount:    number;
  bonusTriggered:  boolean;
  bonusData:       BonusData | null;
  freeSpins:       FreeSpisInfo | null;   // non-null during Free Spins
  blockSummary:    BlockSummary | null;   // non-null on final Free Spin
  state:           GameState;
  metadata:        SpinMetadata;
}

export interface WinResult {
  paylineIndex: number;
  symbolId:     number;
  count:        number;
  multiplier:   DecimalString;
  winAmount:    DecimalString;
  positions:    GridPosition[];
}

export interface GridPosition {
  col: number;
  row: number;
}

export interface BalanceSnapshot {
  before: DecimalString;
  after:  DecimalString;
}

export interface BonusData {
  type:             string;
  freeSpinsAwarded: number;
  initialMultiplier: DecimalString;
  blockId:          UuidString;
}

export interface FreeSpisInfo {
  blockId:             UuidString;
  spinNumber:          number;
  totalSpins:          number;
  spinsRemaining:      number;
  multiplier:          DecimalString;
  multiplierApplied:   DecimalString;
  blockTotalWin:       DecimalString;
  retriggerOccurred:   boolean;
  retriggerSpinsAdded: number;
}

export interface BlockSummary {
  blockId:          UuidString;
  totalSpinsPlayed: number;
  totalWin:         DecimalString;
  triggeringBet:    DecimalString;
  winMultiple:      DecimalString;
}

export interface SpinMetadata {
  rngVersion:   string;
  gameVersion:  string;
  processingMs: number;
}

// ── Error Response ────────────────────────────────────────────────

export interface ErrorResponse {
  error: {
    code:          string;
    message:       string;
    requestId:     string;
    timestamp:     IsoTimestamp;
    retryable:     boolean;
    retryAfterMs:  number | null;
  };
}

// ── State Response ────────────────────────────────────────────────

export interface GameStateResponse {
  sessionId:   string;
  state:       GameState;
  balance:     DecimalString;
  currency:    string;
  pendingSpin: SpinResponse | null;
  bonusState:  FreeSpisState | null;
}

export interface FreeSpisState {
  type:          string;
  blockId:       UuidString;
  spinNumber:    number;
  totalSpins:    number;
  spinsRemaining: number;
  multiplier:    DecimalString;
  blockTotalWin: DecimalString;
  triggeringBet: DecimalString;
}

9.2 Client Network Layer (typescript)

export class SpinNetworkLayer {
  private readonly baseUrl:      string;
  private readonly sessionToken: string;

  async spin(request: SpinRequest): Promise<SpinResponse> {
    return this.post<SpinResponse>('/api/v1/game/spin', request);
  }

  async bonusSpin(blockId: string): Promise<SpinResponse> {
    return this.post<SpinResponse>(
      '/api/v1/game/bonus/spin', { blockId });
  }

  async getState(): Promise<GameStateResponse> {
    return this.get<GameStateResponse>('/api/v1/game/state');
  }

  private async post<T>(
    path:    string,
    body:    unknown,
    attempt: number = 0
  ): Promise<T> {
    const requestId = crypto.randomUUID();

    const response = await fetch(this.baseUrl + path, {
      method:  'POST',
      headers: {
        'Content-Type':   'application/json',
        'Authorization':  `Bearer ${this.sessionToken}`,
        'X-Request-Id':   requestId,
        'X-Client-Version': GAME_VERSION,
      },
      body: JSON.stringify(body),
    });

    if (response.ok) {
      return response.json() as Promise<T>;
    }

    const error: ErrorResponse = await response.json();

    if (error.error.retryable && attempt < 3) {
      const delay = error.error.retryAfterMs ?? (1000 * Math.pow(2, attempt));
      await sleep(delay);
      return this.post<T>(path, body, attempt + 1);
      // Uses same requestId — idempotent on retry
    }

    throw new SpinApiError(error.error.code, error.error.message,
      error.error.retryable);
  }

  private async get<T>(path: string): Promise<T> {
    const response = await fetch(this.baseUrl + path, {
      headers: { 'Authorization': `Bearer ${this.sessionToken}` }
    });
    if (!response.ok) {
      const error: ErrorResponse = await response.json();
      throw new SpinApiError(error.error.code, error.error.message, false);
    }
    return response.json();
  }
}

export class SpinApiError extends Error {
  constructor(
    public readonly code:      string,
    public readonly detail:    string,
    public readonly retryable: boolean
  ) {
    super(`SpinApiError [${code}]: ${detail}`);
  }
}

const sleep = (ms: number) =>
  new Promise(resolve => setTimeout(resolve, ms));

Part X. OpenAPI Specification

10.1 Why OpenAPI Is Mandatory

An OpenAPI specification for the spin API is not documentation overhead — it is a first-class engineering deliverable. It enables:

Type generation: TypeScript client types generated from spec, not hand-written

Contract testing: Automated verification that server responses match spec

Aggregator integration: Aggregators often require the OpenAPI spec for integration review

Client mocking: Mock server for client development before the real server is ready

openapi: 3.1.0
info:
  title: Crystal Forge Slot Game API
  version: 1.0.0
  description: >
    Spin protocol for Crystal Forge slot game.
    All monetary values are decimal strings.
    All timestamps are ISO 8601 UTC.

servers:
  - url: https://games.provider.com/crystal-forge
    description: Production
  - url: https://staging.games.provider.com/crystal-forge
    description: Staging

security:
  - BearerAuth: []

paths:
  /api/v1/game/config:
    get:
      summary: Get game configuration
      parameters:
        - name: token
          in: query
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Game configuration
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GameConfigResponse' }
        '401':
          $ref: '#/components/responses/Unauthorised'

  /api/v1/game/spin:
    post:
      summary: Execute a base game spin
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SpinRequest' }
            example:
              betAmount: "1.00"
              currency: "EUR"
      responses:
        '200':
          description: Spin result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SpinResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorised' }
        '402': { $ref: '#/components/responses/InsufficientFunds' }
        '409': { $ref: '#/components/responses/Conflict' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }

  /api/v1/game/bonus/spin:
    post:
      summary: Execute a Free Spins bonus spin
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BonusSpinRequest' }
      responses:
        '200':
          description: Bonus spin result
          content:
            application/json:
              schema: { $ref: '#/components/schemas/SpinResponse' }
        '401': { $ref: '#/components/responses/Unauthorised' }
        '409': { $ref: '#/components/responses/Conflict' }

  /api/v1/game/state:
    get:
      summary: Get current game state (used for reconnect recovery)
      responses:
        '200':
          description: Current game state
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GameStateResponse' }

components:
  schemas:
    SpinRequest:
      type: object
      required: [betAmount, currency]
      properties:
        betAmount:
          type: string
          pattern: '^\d+\.\d{2}$'
          example: "1.00"
        currency:
          type: string
          minLength: 3
          maxLength: 3
          example: "EUR"

    SpinResponse:
      type: object
      required: [spinId, timestamp, roundId, reelStops, visibleGrid,
                 wins, totalWin, betAmount, currency, balance,
                 scatterCount, bonusTriggered, state, metadata]
      properties:
        spinId:
          type: string
          format: uuid
        timestamp:
          type: string
          format: date-time
        reelStops:
          type: array
          items: { type: integer }
        visibleGrid:
          type: array
          items:
            type: array
            items: { type: integer }
        wins:
          type: array
          items: { $ref: '#/components/schemas/WinResult' }
        totalWin:
          type: string
          pattern: '^\d+\.\d{2}$'
        bonusTriggered:
          type: boolean
        bonusData:
          oneOf:
            - { $ref: '#/components/schemas/BonusData' }
            - { type: 'null' }
        freeSpins:
          oneOf:
            - { $ref: '#/components/schemas/FreeSpisInfo' }
            - { type: 'null' }
        state:
          type: string
          enum: [idle, bonus, bonus_complete, suspended]
        metadata:
          $ref: '#/components/schemas/SpinMetadata'

    WinResult:
      type: object
      required: [paylineIndex, symbolId, count, multiplier, winAmount, positions]
      properties:
        paylineIndex: { type: integer, minimum: 0 }
        symbolId:     { type: integer, minimum: 1 }
        count:        { type: integer, minimum: 3, maximum: 5 }
        multiplier:   { type: string, pattern: '^\d+\.\d{2}$' }
        winAmount:    { type: string, pattern: '^\d+\.\d{2}$' }
        positions:
          type: array
          items: { $ref: '#/components/schemas/GridPosition' }

    GridPosition:
      type: object
      required: [col, row]
      properties:
        col: { type: integer, minimum: 0 }
        row: { type: integer, minimum: 0 }

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, requestId, timestamp, retryable]
          properties:
            code:         { type: string }
            message:      { type: string }
            requestId:    { type: string }
            timestamp:    { type: string, format: date-time }
            retryable:    { type: boolean }
            retryAfterMs: { type: integer, nullable: true }

  responses:
    Unauthorised:
      description: Session token invalid or expired
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    BadRequest:
      description: Invalid request parameters
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    InsufficientFunds:
      description: Player balance too low for bet
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    Conflict:
      description: State conflict (spin in progress, wrong game state)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    ServiceUnavailable:
      description: Wallet service temporarily unavailable (retryable)
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }

  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer

Summary

The spin protocol contract is the nervous system of the slot — every feature, every mechanic, and every player interaction flows through it. A well-designed contract makes the client simple and the server responsible, makes errors unambiguous and retries safe, makes audits straightforward and certification inevitable.

The design principles that hold everything together:

Monetary values are strings. Never numbers. This is not optional.

The client sends nothing it could forge. No outcomes, no balance, no session state. The request is a declaration of intent — "bet this amount in this currency" — and everything else is determined server-side.

Every response is self-describing. The visibleGrid could be derived from reelStops, but is included explicitly so the client never needs to know the reel strips. The winAmount could be derived from multiplier × betPerLine, but is included explicitly so the audit never needs to recompute it.

Errors are machine-readable and retryability is explicit. The client does not need to guess whether a 503 is retryable. The protocol tells it, and tells it how long to wait.

The reconnect protocol is a first-class feature. Players disconnect. The GET /state endpoint and the pendingSpin mechanism ensure no player ever loses a spin result to a network failure.

The OpenAPI spec is part of the deliverable. Generated types, contract tests, and aggregator reviews all depend on it. Write it before the implementation — it forces precision in the design.

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

Policy