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 versionAdditive 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 inspectionWin 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 idleMetadata 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 spin8.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 exceptionPart 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: bearerSummary
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.
