Neon Royale

State Machine Design for Slot Games — Managing Game States: Idle → Spin → Evaluate → Bonus

Neon AdminNeon Admin·Mar 18, 2026
State Machine Design for Slot Games — Managing Game States: Idle → Spin → Evaluate → Bonus

Introduction

A slot game is, at its core, a finite state machine. At any moment in time, the game exists in exactly one well-defined state — Idle, Spinning, Evaluating, WaitingForCredit, BonusActive, BonusSpin, and so on. Transitions between states are triggered by specific events: a player clicks Spin, the RNG executes, the win is calculated, the wallet credits. Each transition must leave the game in a valid, consistent state from which it can continue operating, recover from failure, or be audited months later.

This sounds obvious. But in practice, slot games developed without an explicit state machine model grow into systems where:

A disconnection mid-spin leaves the session in an ambiguous state that requires manual database fixes to resolve

A player who clicks Spin twice rapidly before the first response arrives gets two debits but only one credit

A Free Spins block is in progress but the server thinks the base game is idle, so a new spin is accepted and executed, corrupting the bonus state

A network timeout during wallet credit leaves a player's balance short, and the system has no mechanism to detect or repair it

A crash recovery restores the session to a state from which the next valid transition is undefined

Every one of these scenarios is a state machine design failure. And every one of them will occur in production unless you design the state machine explicitly, implement it as a first-class citizen in your architecture, make every state and every transition observable, and design every component to fail safely and recover cleanly.

This article builds a production-quality state machine for a complete slot game from first principles. We start with the formal model — the states, the events that trigger transitions, the invariants that must hold in each state — then implement it in C# using the State pattern, integrate it with Redis for distributed persistence, and verify it through exhaustive property-based testing. Every design decision is explained, every edge case is addressed, and every failure mode is handled explicitly rather than left to chance.


Part I. Why an Explicit State Machine

1.1 The Cost of Implicit State

Most developers building their first slot game do not explicitly model the state machine. Instead, state is represented implicitly through a combination of flags and nullable fields:

// ❌ Implicit state — the anti-pattern
public class GameSession
{
    public bool    IsSpinning        { get; set; }
    public bool    IsInBonus         { get; set; }
    public bool    IsBonusComplete   { get; set; }
    public bool    WaitingForCredit  { get; set; }
    public int?    FreeSpinsRemaining{ get; set; }
    public decimal? PendingCredit    { get; set; }
    // ...more flags added as bugs are discovered...
}

This representation has a critical flaw: it admits invalid states. With four boolean flags, there are 2⁴ = 16 possible combinations. How many of them are valid game states? Perhaps six or seven. The remaining nine are invalid — impossible by the game's rules, but representable in this data structure.

When invalid states are representable, they will be reached. Not intentionally, but through concurrent requests, network failures, race conditions, or bugs in transitions. And when an invalid state is reached, the system's behaviour is undefined — it will do something, but what it does will be wrong in ways that are hard to detect and expensive to fix.

The explicit state machine eliminates this class of bug entirely by making invalid states unrepresentable.

1.2 State Machine as Regulatory Requirement

From an iGaming certification perspective, the state machine is not merely a design choice — it is an auditable requirement. GLI-16 requires that:

Every game state be explicitly documented

Every state transition be deterministic and traceable from the audit log

Recovery from any failure mode must restore the game to a valid, well-defined state

The game must never accept a spin request while another is in progress

An explicit state machine is the most direct path to satisfying these requirements, because it makes every state and transition a concrete, documentable artefact.

1.3 Properties a Good State Machine Must Have

Before designing the states, establish the invariants the state machine must satisfy:

SAFETY INVARIANTS (must hold in every state):
  1. The player's balance is non-negative.
  2. At most one spin is executing at any moment for a session.
  3. The sum of: (all debits) - (all credits) - (all rollbacks)
     equals the total house edge collected from this session.
  4. Every executed spin has a complete audit record.
  5. If a debit has occurred without a corresponding credit,
     the system is in a state that will produce that credit.

LIVENESS INVARIANTS (the system makes progress):
  6. A session that is not in a terminal state will eventually
     reach a terminal state (complete or abandoned).
  7. A player who won a spin will eventually receive their credit,
     even if the initial credit attempt fails.
  8. A player who disconnects mid-bonus will be able to resume
     the bonus from the correct spin upon reconnection.

These invariants are the acceptance criteria for the state machine design. Every state, every transition, and every error handler must be evaluated against them.


Part II. The Formal State Model

2.1 The Complete State Enumeration

A slot game with Free Spins bonus and no other mechanics has the following states:

state.jpg

2.2 State Definitions

Each state has a precise definition: what it means, what invariants hold while in it, which events are valid, and which events are invalid.

/// <summary>
/// The complete enumeration of game states.
/// Every state is mutually exclusive — the session is in exactly one.
/// </summary>
public enum GameState
{
    /// <summary>
    /// The game is ready for the player's next spin.
    /// No pending debits. No pending credits. No active bonus.
    /// Valid events: SpinRequested
    /// Invalid events: BonusSpinRequested, OutcomeGenerated, CreditConfirmed
    /// </summary>
    Idle,

    /// <summary>
    /// A debit request has been sent to the wallet service.
    /// The bet has NOT yet been confirmed deducted from the player's balance.
    /// Invariant: one debit request is in flight, no debit has been confirmed.
    /// Valid events: DebitConfirmed, DebitFailed
    /// Invalid events: SpinRequested, BonusSpinRequested
    /// </summary>
    DebitPending,

    /// <summary>
    /// The debit is confirmed. The RNG is generating the spin outcome.
    /// Invariant: exactly one debit has been applied to the player's balance.
    ///            No credit has been applied yet.
    /// Valid events: OutcomeGenerated, EngineFailure
    /// Invalid events: SpinRequested, DebitConfirmed
    /// </summary>
    SpinExecuting,

    /// <summary>
    /// The outcome is generated. Win amounts are being calculated.
    /// In practice, evaluation is synchronous and near-instantaneous.
    /// This state exists as a logical marker for the audit trail.
    /// Valid events: EvaluationComplete
    /// </summary>
    Evaluating,

    /// <summary>
    /// Evaluation complete with a non-zero win. Credit is being applied.
    /// Invariant: totalWin > 0, one credit request is in flight or pending retry.
    /// Valid events: CreditConfirmed, CreditFailed
    /// </summary>
    CreditPending,

    /// <summary>
    /// A bonus was triggered on the base spin. Bonus state is being initialised.
    /// Valid events: BonusInitialised
    /// </summary>
    BonusInitialising,

    /// <summary>
    /// The bonus round is active and ready for the player's next bonus spin.
    /// Invariant: bonusState.SpinsRemaining > 0
    /// Valid events: BonusSpinRequested
    /// Invalid events: SpinRequested (base game spin not allowed)
    /// </summary>
    BonusIdle,

    /// <summary>
    /// A bonus spin is executing.
    /// Valid events: BonusOutcomeGenerated, BonusEngineFailure
    /// </summary>
    BonusSpinExecuting,

    /// <summary>
    /// The bonus spin outcome is being evaluated.
    /// Valid events: BonusEvaluationComplete
    /// </summary>
    BonusEvaluating,

    /// <summary>
    /// Bonus spin produced a win. Credit is pending.
    /// Valid events: BonusCreditConfirmed, BonusCreditFailed
    /// </summary>
    BonusCreditPending,

    /// <summary>
    /// This was the final bonus spin. Bonus is wrapping up.
    /// Any final win has been credited. BonusComplete event will follow.
    /// Valid events: BonusCompleted
    /// </summary>
    BonusCompleting,

    /// <summary>
    /// The session has been permanently terminated.
    /// Player has logged out, self-excluded, or session has timed out.
    /// No valid events — this is a terminal state.
    /// </summary>
    Terminated
}

2.3 The Event Enumeration

/// <summary>
/// All events that can trigger state transitions.
/// Events are the inputs to the state machine.
/// </summary>
public enum GameEvent
{
    // Base game
    SpinRequested,
    DebitConfirmed,
    DebitFailed,
    OutcomeGenerated,
    EvaluationComplete,
    CreditConfirmed,
    CreditFailed,
    EngineFailure,

    // Bonus round
    BonusTriggered,
    BonusInitialised,
    BonusSpinRequested,
    BonusOutcomeGenerated,
    BonusEvaluationComplete,
    BonusCreditConfirmed,
    BonusCreditFailed,
    BonusEngineFailure,
    BonusCompleted,
    RetriggerOccurred,

    // Session lifecycle
    SessionExpired,
    PlayerSelfExcluded,
    ManualTermination,
}

2.4 The Transition Table

The complete transition table defines, for every (state, event) pair, whether the transition is valid and what the resulting state is:

/// <summary>
/// The authoritative transition table for the slot game state machine.
/// Any (state, event) pair not in this table is an INVALID transition.
/// Attempting an invalid transition throws InvalidTransitionException.
/// </summary>
public static class TransitionTable
{
    // Key: (fromState, event)
    // Value: (toState, isValid)
    private static readonly Dictionary<(GameState, GameEvent), GameState> _transitions
        = new()
    {
        // ── Idle transitions ────────────────────────────────────────
        { (GameState.Idle, GameEvent.SpinRequested),       GameState.DebitPending    },
        { (GameState.Idle, GameEvent.SessionExpired),      GameState.Terminated      },
        { (GameState.Idle, GameEvent.PlayerSelfExcluded),  GameState.Terminated      },
        { (GameState.Idle, GameEvent.ManualTermination),   GameState.Terminated      },

        // ── DebitPending transitions ─────────────────────────────────
        { (GameState.DebitPending, GameEvent.DebitConfirmed),  GameState.SpinExecuting  },
        { (GameState.DebitPending, GameEvent.DebitFailed),     GameState.Idle           },
        { (GameState.DebitPending, GameEvent.SessionExpired),  GameState.Terminated     },

        // ── SpinExecuting transitions ────────────────────────────────
        { (GameState.SpinExecuting, GameEvent.OutcomeGenerated),  GameState.Evaluating  },
        { (GameState.SpinExecuting, GameEvent.EngineFailure),     GameState.DebitPending },
        // Note: EngineFailure goes back to DebitPending so rollback can be attempted

        // ── Evaluating transitions ───────────────────────────────────
        // EvaluationComplete carries data (win amount, bonus triggered)
        // The handler reads this data to determine the next state
        { (GameState.Evaluating, GameEvent.EvaluationComplete),   GameState.CreditPending    },
        // When EvaluationComplete and win == 0 and no bonus:
        { (GameState.Evaluating, GameEvent.EvaluationComplete),   GameState.Idle             },
        // When EvaluationComplete and bonus triggered:
        { (GameState.Evaluating, GameEvent.BonusTriggered),       GameState.BonusInitialising },

        // ── CreditPending transitions ────────────────────────────────
        { (GameState.CreditPending, GameEvent.CreditConfirmed),   GameState.Idle            },
        { (GameState.CreditPending, GameEvent.CreditFailed),      GameState.CreditPending   },
        // CreditFailed stays in CreditPending — retry loop
        { (GameState.CreditPending, GameEvent.BonusTriggered),    GameState.BonusInitialising },
        // Credit + bonus: credit first, then initialise bonus

        // ── BonusInitialising transitions ────────────────────────────
        { (GameState.BonusInitialising, GameEvent.BonusInitialised), GameState.BonusIdle },

        // ── BonusIdle transitions ────────────────────────────────────
        { (GameState.BonusIdle, GameEvent.BonusSpinRequested),   GameState.BonusSpinExecuting },
        { (GameState.BonusIdle, GameEvent.SessionExpired),        GameState.Terminated         },

        // ── BonusSpinExecuting transitions ───────────────────────────
        { (GameState.BonusSpinExecuting, GameEvent.BonusOutcomeGenerated),
                                                                  GameState.BonusEvaluating    },
        { (GameState.BonusSpinExecuting, GameEvent.BonusEngineFailure),
                                                                  GameState.BonusIdle           },

        // ── BonusEvaluating transitions ──────────────────────────────
        { (GameState.BonusEvaluating, GameEvent.BonusEvaluationComplete),
                                                                  GameState.BonusCreditPending },
        // When no win in bonus:
        { (GameState.BonusEvaluating, GameEvent.RetriggerOccurred),
                                                                  GameState.BonusIdle          },

        // ── BonusCreditPending transitions ───────────────────────────
        { (GameState.BonusCreditPending, GameEvent.BonusCreditConfirmed),
                                                                  GameState.BonusIdle          },
        { (GameState.BonusCreditPending, GameEvent.BonusCreditConfirmed),
                                                                  GameState.BonusCompleting    },
        // ^ When spinsRemaining == 0 after this credit
        { (GameState.BonusCreditPending, GameEvent.BonusCreditFailed),
                                                                  GameState.BonusCreditPending },
        { (GameState.BonusCreditPending, GameEvent.RetriggerOccurred),
                                                                  GameState.BonusIdle          },

        // ── BonusCompleting transitions ──────────────────────────────
        { (GameState.BonusCompleting, GameEvent.BonusCompleted),  GameState.Idle              },

        // ── Universal terminal transitions ───────────────────────────
        { (GameState.BonusIdle,          GameEvent.ManualTermination), GameState.Terminated   },
        { (GameState.BonusCreditPending, GameEvent.ManualTermination), GameState.Terminated   },
    };

    public static bool TryGetTransition(
        GameState fromState,
        GameEvent gameEvent,
        out GameState toState)
        => _transitions.TryGetValue((fromState, gameEvent), out toState);

    public static bool IsValidTransition(GameState fromState, GameEvent gameEvent)
        => _transitions.ContainsKey((fromState, gameEvent));

    /// <summary>
    /// Returns all valid events from a given state.
    /// Used for: API validation, UI state management, testing.
    /// </summary>
    public static IReadOnlyList<GameEvent> ValidEventsFrom(GameState state)
        => _transitions.Keys
            .Where(k => k.Item1 == state)
            .Select(k => k.Item2)
            .ToList();
}

Part III. The State Machine Implementation

3.1 The Core State Machine Engine

/// <summary>
/// The state machine engine for a single game session.
/// Thread-safe: uses optimistic concurrency via version numbers.
/// Persistent: every state change is written to Redis before returning.
/// </summary>
public sealed class SlotStateMachine
{
    private readonly ISessionStore          _store;
    private readonly IStateMachineLogger    _auditLogger;
    private readonly ILogger<SlotStateMachine> _logger;

    public SlotStateMachine(
        ISessionStore store,
        IStateMachineLogger auditLogger,
        ILogger<SlotStateMachine> logger)
    {
        _store       = store;
        _auditLogger = auditLogger;
        _logger      = logger;
    }

    /// <summary>
    /// Applies an event to the session's current state, producing a transition.
    /// Returns the new session state after the transition.
    /// Throws InvalidTransitionException if the event is not valid in current state.
    /// </summary>
    public async Task<SessionState> TransitionAsync(
        string         sessionId,
        GameEvent      gameEvent,
        TransitionData data,
        CancellationToken ct = default)
    {
        // Load current state with optimistic lock version
        var (session, version) = await _store.LoadWithVersionAsync(sessionId, ct)
            ?? throw new SessionNotFoundException(sessionId);

        GameState fromState = session.CurrentState;

        // Validate transition
        if (!TransitionTable.TryGetTransition(fromState, gameEvent, out GameState toState))
        {
            _logger.LogWarning(
                "Invalid transition attempted: {From} + {Event} for session {Id}",
                fromState, gameEvent, sessionId);

            throw new InvalidTransitionException(fromState, gameEvent);
        }

        // For states with ambiguous next state (e.g., Evaluating → different
        // next states depending on win/bonus), the handler resolves the ambiguity
        toState = ResolveAmbiguousTransition(fromState, gameEvent, toState, data);

        // Apply transition: build new session state
        var newSession = ApplyTransition(session, fromState, toState, gameEvent, data);

        // Persist with optimistic concurrency
        bool saved = await _store.SaveWithVersionAsync(
            sessionId, newSession, expectedVersion: version, ct);

        if (!saved)
        {
            // Concurrent modification: another request modified the session
            // This is the concurrent spin guard — the second request loses
            throw new ConcurrentTransitionException(sessionId, fromState, gameEvent);
        }

        // Audit log every transition
        await _auditLogger.LogTransitionAsync(new TransitionAuditRecord(
            SessionId:   sessionId,
            FromState:   fromState,
            Event:       gameEvent,
            ToState:     toState,
            Timestamp:   DateTime.UtcNow,
            Data:        data,
            Version:     version + 1
        ), ct);

        _logger.LogDebug(
            "[{SessionId}] {From} ──{Event}──► {To}",
            sessionId[..8], fromState, gameEvent, toState);

        return newSession;
    }

    /// <summary>
    /// Resolves ambiguous transitions where the same (state, event) pair
    /// can produce different next states depending on runtime data.
    /// </summary>
    private static GameState ResolveAmbiguousTransition(
        GameState      fromState,
        GameEvent      gameEvent,
        GameState      tableResult,
        TransitionData data)
    {
        // Evaluating + EvaluationComplete can go to:
        //   CreditPending  (win > 0, no bonus)
        //   BonusInitialising (bonus triggered, may also have win)
        //   Idle           (no win, no bonus)
        if (fromState == GameState.Evaluating
            && gameEvent == GameEvent.EvaluationComplete)
        {
            if (data.BonusTriggered)   return GameState.BonusInitialising;
            if (data.TotalWin > 0)     return GameState.CreditPending;
            return GameState.Idle;
        }

        // BonusCreditPending + BonusCreditConfirmed can go to:
        //   BonusIdle      (spins remaining > 0)
        //   BonusCompleting (this was the final spin)
        if (fromState == GameState.BonusCreditPending
            && gameEvent == GameEvent.BonusCreditConfirmed)
        {
            return data.BonusSpinsRemaining > 0
                ? GameState.BonusIdle
                : GameState.BonusCompleting;
        }

        // BonusEvaluating + BonusEvaluationComplete can go to:
        //   BonusCreditPending (win > 0)
        //   BonusIdle          (no win, spins remaining)
        //   BonusCompleting    (no win, final spin)
        if (fromState == GameState.BonusEvaluating
            && gameEvent == GameEvent.BonusEvaluationComplete)
        {
            if (data.TotalWin > 0)
                return GameState.BonusCreditPending;
            if (data.BonusSpinsRemaining > 0)
                return GameState.BonusIdle;
            return GameState.BonusCompleting;
        }

        return tableResult;
    }

    /// <summary>
    /// Applies the transition to produce the new session state.
    /// Pure function: no I/O, no side effects.
    /// </summary>
    private static SessionState ApplyTransition(
        SessionState   current,
        GameState      fromState,
        GameState      toState,
        GameEvent      gameEvent,
        TransitionData data)
    {
        return current with
        {
            CurrentState      = toState,
            PreviousState     = fromState,
            LastEvent         = gameEvent,
            LastTransitionAt  = DateTime.UtcNow,
            Version           = current.Version + 1,

            // Update financial state
            PendingDebit      = gameEvent == GameEvent.DebitConfirmed
                ? data.DebitAmount : current.PendingDebit,

            PendingCredit     = gameEvent is GameEvent.EvaluationComplete
                                         or GameEvent.BonusEvaluationComplete
                ? (data.TotalWin > 0 ? data.TotalWin : current.PendingCredit)
                : gameEvent is GameEvent.CreditConfirmed or GameEvent.BonusCreditConfirmed
                    ? 0m
                    : current.PendingCredit,

            // Update bonus state
            BonusState        = UpdateBonusState(current.BonusState, toState, data),

            // Track spin sequence for idempotency keys
            SpinSequence      = gameEvent == GameEvent.SpinRequested
                ? current.SpinSequence + 1
                : gameEvent == GameEvent.BonusSpinRequested
                    ? current.SpinSequence // bonus spins use block-scoped numbering
                    : current.SpinSequence,
        };
    }

    private static BonusStateData? UpdateBonusState(
        BonusStateData? current,
        GameState       toState,
        TransitionData  data)
    {
        return toState switch
        {
            GameState.BonusInitialising => new BonusStateData(
                BlockId:        Guid.NewGuid().ToString("N"),
                Type:           "free_spins",
                TotalSpins:     data.FreeSpinsAwarded,
                SpinsPlayed:    0,
                SpinsRemaining: data.FreeSpinsAwarded,
                Multiplier:     data.FreeSpinsMultiplier,
                BlockTotalWin:  0m,
                TriggeringBet:  data.BetAmount
            ),

            GameState.BonusSpinExecuting when current is not null =>
                current with { SpinsPlayed = current.SpinsPlayed + 1 },

            GameState.BonusIdle when current is not null && data.RetriggerSpinsAdded > 0 =>
                current with
                {
                    TotalSpins     = current.TotalSpins + data.RetriggerSpinsAdded,
                    SpinsRemaining = current.SpinsRemaining - 1 + data.RetriggerSpinsAdded,
                    BlockTotalWin  = current.BlockTotalWin + data.TotalWin
                },

            GameState.BonusIdle when current is not null =>
                current with
                {
                    SpinsRemaining = current.SpinsRemaining - 1,
                    BlockTotalWin  = current.BlockTotalWin + data.TotalWin
                },

            GameState.BonusCompleting when current is not null =>
                current with { SpinsRemaining = 0, BlockTotalWin = current.BlockTotalWin + data.TotalWin },

            GameState.Idle => null,  // Bonus complete, clear bonus state

            _ => current
        };
    }
}

3.2 Session State: The Persisted Document

/// <summary>
/// The complete persisted state of a game session.
/// Stored in Redis as a JSON document, keyed by sessionId.
/// Every field is required to fully describe the session at any point.
/// </summary>
public sealed record SessionState
{
    public required string          SessionId        { get; init; }
    public required string          PlayerId         { get; init; }
    public required string          OperatorId       { get; init; }
    public required GameState       CurrentState     { get; init; }
    public          GameState?      PreviousState    { get; init; }
    public          GameEvent?      LastEvent        { get; init; }
    public required DateTime        CreatedAt        { get; init; }
    public          DateTime        LastTransitionAt { get; init; }
    public required long            Version          { get; init; }
    public required long            SpinSequence     { get; init; }
    public required string          Currency         { get; init; }
    public required string          GameId           { get; init; }
    public required string          GameVersion      { get; init; }
    public required string          RtpConfiguration { get; init; }

    // Financial tracking
    public          decimal         PendingDebit     { get; init; }
    public          decimal         PendingCredit    { get; init; }
    public          string?         PendingDebitTxRef  { get; init; }
    public          string?         PendingCreditTxRef { get; init; }

    // Bet configuration
    public required decimal         CurrentBet       { get; init; }
    public required decimal         MinBet           { get; init; }
    public required decimal         MaxBet           { get; init; }

    // Bonus state (null when not in bonus)
    public          BonusStateData? BonusState       { get; init; }

    // Computed properties
    [JsonIgnore]
    public bool IsInBonus       => CurrentState is
        GameState.BonusInitialising or
        GameState.BonusIdle or
        GameState.BonusSpinExecuting or
        GameState.BonusEvaluating or
        GameState.BonusCreditPending or
        GameState.BonusCompleting;

    [JsonIgnore]
    public bool IsSpinInProgress => CurrentState is
        GameState.DebitPending or
        GameState.SpinExecuting or
        GameState.Evaluating or
        GameState.CreditPending or
        GameState.BonusSpinExecuting or
        GameState.BonusEvaluating or
        GameState.BonusCreditPending;

    [JsonIgnore]
    public bool HasPendingFinancials =>
        PendingDebit > 0 || PendingCredit > 0;

    [JsonIgnore]
    public bool IsTerminal => CurrentState == GameState.Terminated;
}

public sealed record BonusStateData(
    string  BlockId,
    string  Type,
    int     TotalSpins,
    int     SpinsPlayed,
    int     SpinsRemaining,
    decimal Multiplier,
    decimal BlockTotalWin,
    decimal TriggeringBet
);

public sealed record TransitionData(
    decimal  BetAmount            = 0m,
    decimal  DebitAmount          = 0m,
    decimal  TotalWin             = 0m,
    bool     BonusTriggered       = false,
    int      FreeSpinsAwarded     = 0,
    decimal  FreeSpinsMultiplier  = 1m,
    int      BonusSpinsRemaining  = 0,
    int      RetriggerSpinsAdded  = 0,
    string?  DebitTxRef           = null,
    string?  CreditTxRef          = null
);

3.3 The Session Store with Optimistic Concurrency

public interface ISessionStore
{
    Task<(SessionState state, long version)?> LoadWithVersionAsync(
        string sessionId, CancellationToken ct = default);

    /// <summary>
    /// Saves the session only if the current version in the store
    /// matches expectedVersion (optimistic concurrency check).
    /// Returns true if saved, false if version mismatch (concurrent modification).
    /// </summary>
    Task<bool> SaveWithVersionAsync(
        string       sessionId,
        SessionState state,
        long         expectedVersion,
        CancellationToken ct = default);
}

public sealed class RedisSessionStore : ISessionStore
{
    private readonly IConnectionMultiplexer _redis;
    private readonly TimeSpan               _ttl = TimeSpan.FromHours(24);

    private static string StateKey(string id)   => $"game:session:{id}:state";
    private static string VersionKey(string id) => $"game:session:{id}:version";

    public async Task<(SessionState, long)?> LoadWithVersionAsync(
        string sessionId, CancellationToken ct = default)
    {
        var db    = _redis.GetDatabase();
        var stateJson = await db.StringGetAsync(StateKey(sessionId));
        if (!stateJson.HasValue) return null;

        var versionValue = await db.StringGetAsync(VersionKey(sessionId));
        long version = versionValue.HasValue ? (long)versionValue : 0;

        var state = JsonSerializer.Deserialize<SessionState>(stateJson!);
        return (state!, version);
    }

    public async Task<bool> SaveWithVersionAsync(
        string sessionId, SessionState state,
        long expectedVersion, CancellationToken ct = default)
    {
        var db        = _redis.GetDatabase();
        string json   = JsonSerializer.Serialize(state);
        long   newVer = expectedVersion + 1;

        // Lua script: atomic compare-and-swap on version
        const string luaScript = @"
            local currentVersion = tonumber(redis.call('GET', KEYS[2]))
            if currentVersion == nil then currentVersion = 0 end
            if currentVersion ~= tonumber(ARGV[3]) then
                return 0
            end
            redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
            redis.call('SET', KEYS[2], ARGV[4], 'EX', ARGV[2])
            return 1";

        var result = (int)await db.ScriptEvaluateAsync(
            luaScript,
            new RedisKey[] { StateKey(sessionId), VersionKey(sessionId) },
            new RedisValue[]
            {
                json,
                (int)_ttl.TotalSeconds,
                expectedVersion,
                newVer
            });

        return result == 1;
    }
}

Part IV. Integrating the State Machine with the Spin Use Case

4.1 The Full Spin Use Case with State Machine

/// <summary>
/// Spin use case rewritten to use the state machine explicitly.
/// Every step in the spin lifecycle is a state transition.
/// No flags. No nullable booleans. The state is the truth.
/// </summary>
public sealed class StatefulSpinUseCase
{
    private readonly SlotStateMachine   _stateMachine;
    private readonly SlotEngine         _engine;
    private readonly IWalletService     _wallet;
    private readonly ISpinRepository    _repository;

    public async Task<SpinResponse> ExecuteAsync(
        string    sessionId,
        decimal   betAmount,
        string    currency,
        CancellationToken ct = default)
    {
        // ── TRANSITION 1: Idle → DebitPending ────────────────────────
        var session = await _stateMachine.TransitionAsync(
            sessionId,
            GameEvent.SpinRequested,
            new TransitionData(BetAmount: betAmount),
            ct);

        // ── DEBIT ────────────────────────────────────────────────────
        string debitRef = TransactionRefBuilder.Debit(
            sessionId, session.SpinSequence);

        DebitResponse debit;
        try
        {
            debit = await _wallet.DebitAsync(new DebitRequest(
                SessionToken:   sessionId,
                Amount:         betAmount,
                Currency:       currency,
                TransactionRef: debitRef
            ), ct);
        }
        catch (Exception ex) when (IsTransientWalletFailure(ex))
        {
            // Debit failed (transient) — return to Idle
            await _stateMachine.TransitionAsync(sessionId,
                GameEvent.DebitFailed,
                new TransitionData(), ct);
            throw new WalletUnavailableException();
        }

        if (!debit.Success)
        {
            await _stateMachine.TransitionAsync(sessionId,
                GameEvent.DebitFailed,
                new TransitionData(), ct);
            throw new InsufficientFundsException();
        }

        // ── TRANSITION 2: DebitPending → SpinExecuting ───────────────
        session = await _stateMachine.TransitionAsync(
            sessionId,
            GameEvent.DebitConfirmed,
            new TransitionData(
                DebitAmount: betAmount,
                DebitTxRef:  debit.TransactionId),
            ct);

        // ── GENERATE OUTCOME ─────────────────────────────────────────
        SpinOutcome outcome;
        try
        {
            outcome = _engine.Execute(new SpinRequest(betAmount, currency));
        }
        catch (Exception ex)
        {
            // Engine failure: stay in SpinExecuting state (needs rollback)
            await _stateMachine.TransitionAsync(sessionId,
                GameEvent.EngineFailure,
                new TransitionData(), ct);

            // Attempt rollback — session is now in DebitPending (re-entry point)
            await AttemptRollbackAsync(sessionId, debitRef, betAmount, currency, ct);
            throw new GameEngineException("Engine failure after debit.", ex);
        }

        // ── TRANSITION 3: SpinExecuting → Evaluating ─────────────────
        session = await _stateMachine.TransitionAsync(
            sessionId,
            GameEvent.OutcomeGenerated,
            new TransitionData(), ct);

        // ── EVALUATE (synchronous, in-memory) ────────────────────────
        // Evaluation is already done by the engine; this transition is
        // a logical marker for the audit trail.
        decimal totalWin      = outcome.TotalWin;
        bool    bonusTriggered = outcome.BonusTriggered;
        int     freeSpins      = outcome.FreeSpinsAwarded;

        // ── TRANSITION 4: Evaluating → (CreditPending | BonusInitialising | Idle)
        session = await _stateMachine.TransitionAsync(
            sessionId,
            GameEvent.EvaluationComplete,
            new TransitionData(
                TotalWin:            totalWin,
                BonusTriggered:      bonusTriggered,
                FreeSpinsAwarded:    freeSpins,
                FreeSpinsMultiplier: _engine.Config.FreeSpinsMultiplier
            ), ct);

        // ── CREDIT (if win > 0) ───────────────────────────────────────
        string? creditTxId = null;
        if (totalWin > 0 && !bonusTriggered)
        {
            session = await CreditAndTransitionAsync(
                sessionId, session, totalWin, currency, ct);
            creditTxId = session.PendingCreditTxRef; // set by credit transition
        }
        else if (totalWin > 0 && bonusTriggered)
        {
            // Credit the base win before entering bonus
            session = await CreditAndTransitionAsync(
                sessionId, session, totalWin, currency, ct);
        }

        // ── TRANSITION 5 (if bonus): CreditPending → BonusInitialising
        if (bonusTriggered && session.CurrentState != GameState.BonusInitialising)
        {
            session = await _stateMachine.TransitionAsync(
                sessionId,
                GameEvent.BonusTriggered,
                new TransitionData(
                    FreeSpinsAwarded: freeSpins,
                    BetAmount:        betAmount
                ), ct);
        }

        // ── TRANSITION 6 (if bonus): BonusInitialising → BonusIdle ──
        if (session.CurrentState == GameState.BonusInitialising)
        {
            session = await _stateMachine.TransitionAsync(
                sessionId,
                GameEvent.BonusInitialised,
                new TransitionData(), ct);
        }

        // ── AUDIT LOG ─────────────────────────────────────────────────
        var spinRecord = BuildSpinRecord(session, outcome, betAmount,
            totalWin, debit, creditTxId);
        await _repository.SaveSpinAsync(spinRecord, ct);

        return BuildSpinResponse(session, outcome, totalWin, debit.BalanceBefore);
    }

    private async Task<SessionState> CreditAndTransitionAsync(
        string sessionId, SessionState session,
        decimal amount, string currency,
        CancellationToken ct)
    {
        string creditRef = TransactionRefBuilder.Credit(
            sessionId, session.SpinSequence);

        for (int attempt = 0; attempt < 4; attempt++)
        {
            try
            {
                var credit = await _wallet.CreditAsync(new CreditRequest(
                    SessionToken:   sessionId,
                    Amount:         amount,
                    Currency:       currency,
                    TransactionRef: creditRef
                ), ct);

                if (credit.Success)
                {
                    return await _stateMachine.TransitionAsync(
                        sessionId,
                        session.IsInBonus
                            ? GameEvent.BonusCreditConfirmed
                            : GameEvent.CreditConfirmed,
                        new TransitionData(
                            TotalWin:   amount,
                            CreditTxRef: credit.TransactionId,
                            BonusSpinsRemaining: session.BonusState?.SpinsRemaining ?? 0
                        ), ct);
                }
            }
            catch (Exception ex) when (IsTransientWalletFailure(ex))
            {
                if (attempt < 3)
                    await Task.Delay(TimeSpan.FromMilliseconds(200 * (1 << attempt)), ct);
            }
        }

        // All retries failed — stay in CreditPending, enqueue for async retry
        await _stateMachine.TransitionAsync(
            sessionId,
            session.IsInBonus
                ? GameEvent.BonusCreditFailed
                : GameEvent.CreditFailed,
            new TransitionData(), ct);

        await EnqueuePendingCreditAsync(sessionId, creditRef, amount, currency);

        // Return current session state (still CreditPending)
        var (reloaded, _) = await _stateMachine.LoadCurrentAsync(sessionId, ct);
        return reloaded;
    }

    private static bool IsTransientWalletFailure(Exception ex)
        => ex is HttpRequestException or TaskCanceledException or TimeoutException;
}

4.2 The Bonus Spin Use Case

public sealed class BonusSpinUseCase
{
    private readonly SlotStateMachine _stateMachine;
    private readonly SlotEngine       _engine;
    private readonly IWalletService   _wallet;
    private readonly ISpinRepository  _repository;

    public async Task<SpinResponse> ExecuteAsync(
        string sessionId,
        string blockId,
        CancellationToken ct = default)
    {
        // Validate the blockId matches the active bonus
        var (session, _) = await _stateMachine.LoadCurrentAsync(sessionId, ct)
            ?? throw new SessionNotFoundException(sessionId);

        if (session.CurrentState != GameState.BonusIdle)
            throw new InvalidOperationException(
                $"Expected BonusIdle, got {session.CurrentState}");

        if (session.BonusState?.BlockId != blockId)
            throw new BlockIdMismatchException(blockId);

        // ── TRANSITION: BonusIdle → BonusSpinExecuting ───────────────
        session = await _stateMachine.TransitionAsync(
            sessionId,
            GameEvent.BonusSpinRequested,
            new TransitionData(), ct);

        // ── GENERATE BONUS OUTCOME ────────────────────────────────────
        // Free Spins use dedicated reel strips — pass the config
        var bonusOutcome = _engine.ExecuteBonus(new BonusSpinContext(
            BonusState:    session.BonusState!,
            BetAmount:     session.BonusState!.TriggeringBet
        ));

        // ── TRANSITION: BonusSpinExecuting → BonusEvaluating ─────────
        session = await _stateMachine.TransitionAsync(
            sessionId,
            GameEvent.BonusOutcomeGenerated,
            new TransitionData(), ct);

        // ── EVALUATE ─────────────────────────────────────────────────
        decimal bonusWin    = bonusOutcome.TotalWin * session.BonusState!.Multiplier;
        bool    retrigger   = bonusOutcome.BonusTriggered;
        int     retriSpins  = retrigger ? _engine.Config.RetriggerSpins : 0;
        int     spinsAfter  = session.BonusState.SpinsRemaining - 1
                              + retriSpins;  // -1 for this spin, +N for retrigger

        // ── TRANSITION: BonusEvaluating → (BonusCreditPending | BonusIdle | BonusCompleting)
        session = await _stateMachine.TransitionAsync(
            sessionId,
            retrigger
                ? GameEvent.RetriggerOccurred
                : GameEvent.BonusEvaluationComplete,
            new TransitionData(
                TotalWin:             bonusWin,
                BonusSpinsRemaining:  spinsAfter,
                RetriggerSpinsAdded:  retriSpins
            ), ct);

        // ── CREDIT BONUS WIN ──────────────────────────────────────────
        if (bonusWin > 0 && session.CurrentState == GameState.BonusCreditPending)
        {
            session = await CreditBonusAndTransitionAsync(
                sessionId, session, bonusWin, ct);
        }

        // ── HANDLE BONUS COMPLETION ───────────────────────────────────
        if (session.CurrentState == GameState.BonusCompleting)
        {
            session = await _stateMachine.TransitionAsync(
                sessionId,
                GameEvent.BonusCompleted,
                new TransitionData(), ct);
        }

        // ── AUDIT ─────────────────────────────────────────────────────
        await _repository.SaveBonusSpinAsync(
            BuildBonusSpinRecord(session, bonusOutcome, bonusWin), ct);

        return BuildBonusSpinResponse(session, bonusOutcome, bonusWin);
    }
}

Part V. State Invariant Validation

5.1 The Session Invariant Checker

Running invariant checks on every state transition catches bugs before they affect real money:

/// <summary>
/// Validates that a session state satisfies all defined invariants.
/// Called after every state transition in non-production environments.
/// Called on session load in production (to detect persisted corrupt state).
/// </summary>
public sealed class SessionInvariantChecker
{
    private readonly IReadOnlyList<ISessionInvariant> _invariants;

    public SessionInvariantChecker()
    {
        _invariants = new List<ISessionInvariant>
        {
            new NoPendingDebitInIdleState(),
            new NoPendingCreditInIdleState(),
            new BonusStateConsistency(),
            new SpinSequenceMonotonicity(),
            new CreditOnlyWhenWinPositive(),
            new TerminalStateIsTerminal(),
            new BonusSpinsCountConsistency(),
        };
    }

    public InvariantCheckResult Check(
        SessionState state,
        SessionState? previousState = null)
    {
        var violations = new List<string>();

        foreach (var invariant in _invariants)
        {
            var result = invariant.Check(state, previousState);
            if (!result.IsValid)
                violations.Add($"[{invariant.Name}] {result.Violation}");
        }

        return violations.Count == 0
            ? InvariantCheckResult.Valid
            : InvariantCheckResult.Invalid(violations);
    }
}

public interface ISessionInvariant
{
    string Name { get; }
    (bool IsValid, string Violation) Check(
        SessionState state, SessionState? previous);
}

public sealed class NoPendingDebitInIdleState : ISessionInvariant
{
    public string Name => "NoPendingDebitInIdle";

    public (bool, string) Check(SessionState state, SessionState? _)
    {
        if (state.CurrentState == GameState.Idle && state.PendingDebit > 0)
            return (false,
                $"Session is Idle but has PendingDebit={state.PendingDebit}. " +
                "Debit was not rolled back on transition to Idle.");
        return (true, string.Empty);
    }
}

public sealed class BonusStateConsistency : ISessionInvariant
{
    public string Name => "BonusStateConsistency";

    public (bool, string) Check(SessionState state, SessionState? _)
    {
        bool isInBonus    = state.IsInBonus;
        bool hasBonusData = state.BonusState is not null;

        if (isInBonus && !hasBonusData)
            return (false,
                $"State is {state.CurrentState} (bonus) but BonusState is null.");

        if (!isInBonus && hasBonusData)
            return (false,
                $"State is {state.CurrentState} (non-bonus) but BonusState is non-null. " +
                "BonusState was not cleared on bonus completion.");

        if (isInBonus && hasBonusData)
        {
            var bonus = state.BonusState!;

            if (bonus.SpinsRemaining < 0)
                return (false,
                    $"BonusState.SpinsRemaining = {bonus.SpinsRemaining} < 0.");

            if (bonus.SpinsPlayed + bonus.SpinsRemaining != bonus.TotalSpins
                && state.CurrentState != GameState.BonusInitialising)
            {
                // After retriggers TotalSpins may be updated; this check needs care
                // but the sum should always be <= TotalSpins
                if (bonus.SpinsPlayed > bonus.TotalSpins)
                    return (false,
                        $"SpinsPlayed ({bonus.SpinsPlayed}) > TotalSpins ({bonus.TotalSpins}).");
            }
        }

        return (true, string.Empty);
    }
}

public sealed class TerminalStateIsTerminal : ISessionInvariant
{
    public string Name => "TerminalStateIsTerminal";

    public (bool, string) Check(SessionState state, SessionState? previous)
    {
        if (previous?.CurrentState == GameState.Terminated
            && state.CurrentState != GameState.Terminated)
        {
            return (false,
                "Session transitioned OUT of Terminated state. " +
                "Terminated is a terminal state — no transitions permitted.");
        }
        return (true, string.Empty);
    }
}

public sealed class BonusSpinsCountConsistency : ISessionInvariant
{
    public string Name => "BonusSpinsCountConsistency";

    public (bool, string) Check(SessionState state, SessionState? _)
    {
        if (state.BonusState is null) return (true, string.Empty);

        var b = state.BonusState;
        if (b.SpinsRemaining == 0
            && state.CurrentState is GameState.BonusIdle)
        {
            return (false,
                "BonusState.SpinsRemaining = 0 but CurrentState = BonusIdle. " +
                "The bonus should have transitioned to BonusCompleting.");
        }

        return (true, string.Empty);
    }
}

public record InvariantCheckResult(
    bool            IsValid,
    IReadOnlyList<string> Violations)
{
    public static InvariantCheckResult Valid
        => new(true, Array.Empty<string>());

    public static InvariantCheckResult Invalid(IReadOnlyList<string> violations)
        => new(false, violations);

    public void ThrowIfInvalid()
    {
        if (!IsValid)
            throw new SessionInvariantViolationException(
                string.Join("; ", Violations));
    }
}

Part VI. The Crash Recovery System

6.1 Why Crash Recovery Is Non-Negotiable

A game server can crash at any point during a spin lifecycle. The crash recovery system must restore every session to a valid, well-defined state without human intervention.

The key insight: every dangerous state has exactly one recovery action.

/// <summary>
/// Recovers sessions that are stuck in non-terminal, non-idle states.
/// Called at server startup and periodically for long-stuck sessions.
/// </summary>
public sealed class SessionRecoveryService : BackgroundService
{
    private readonly ISessionStore     _store;
    private readonly SlotStateMachine  _stateMachine;
    private readonly IWalletService    _wallet;
    private readonly ILogger<SessionRecoveryService> _logger;

    // Sessions in these states for longer than the threshold
    // are considered "stuck" and need recovery
    private static readonly Dictionary<GameState, TimeSpan> _stuckThresholds = new()
    {
        [GameState.DebitPending]        = TimeSpan.FromSeconds(30),
        [GameState.SpinExecuting]       = TimeSpan.FromSeconds(10),
        [GameState.Evaluating]          = TimeSpan.FromSeconds(10),
        [GameState.CreditPending]       = TimeSpan.FromMinutes(2),
        [GameState.BonusInitialising]   = TimeSpan.FromSeconds(30),
        [GameState.BonusCreditPending]  = TimeSpan.FromMinutes(2),
        [GameState.BonusCompleting]     = TimeSpan.FromSeconds(30),
    };

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            await RecoverStuckSessionsAsync(ct);
            await Task.Delay(TimeSpan.FromSeconds(60), ct);
        }
    }

    private async Task RecoverStuckSessionsAsync(CancellationToken ct)
    {
        var stuckSessions = await _store.FindSessionsOlderThanAsync(
            _stuckThresholds, ct);

        foreach (var session in stuckSessions)
        {
            try
            {
                await RecoverSessionAsync(session, ct);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex,
                    "Recovery failed for session {Id} in state {State}",
                    session.SessionId, session.CurrentState);
            }
        }
    }

    private async Task RecoverSessionAsync(
        SessionState session, CancellationToken ct)
    {
        _logger.LogWarning(
            "Recovering session {Id} stuck in {State} since {Since}",
            session.SessionId[..8], session.CurrentState, session.LastTransitionAt);

        switch (session.CurrentState)
        {
            case GameState.DebitPending:
                // Debit was in flight — attempt rollback
                await RollbackDebitAsync(session, ct);
                break;

            case GameState.SpinExecuting:
                // Debit confirmed, engine didn't finish
                // The engine is deterministic from the session state
                // We can re-run it if we have the stopped state OR roll back
                await RollbackAfterEngineFailureAsync(session, ct);
                break;

            case GameState.Evaluating:
                // Evaluation is in-memory/synchronous — this shouldn't happen
                // but if it does, we can reconstruct from audit log
                await RecoverEvaluatingStateAsync(session, ct);
                break;

            case GameState.CreditPending:
            case GameState.BonusCreditPending:
                // Credit was in flight — retry
                await RetryCreditAsync(session, ct);
                break;

            case GameState.BonusInitialising:
                // Bonus init failed — transition to BonusIdle with stored data
                await RecoverBonusInitAsync(session, ct);
                break;

            case GameState.BonusCompleting:
                // Final bonus logic — transition to Idle
                await CompleteBonus(session, ct);
                break;
        }
    }

    private async Task RollbackDebitAsync(
        SessionState session, CancellationToken ct)
    {
        if (session.PendingDebitTxRef is null)
        {
            // No debit TX ref stored — debit never started, transition to Idle
            await _stateMachine.TransitionAsync(
                session.SessionId, GameEvent.DebitFailed,
                new TransitionData(), ct);
            return;
        }

        // Attempt rollback with idempotency key
        string rollbackRef = TransactionRefBuilder.Rollback(session.PendingDebitTxRef);
        try
        {
            var result = await _wallet.RollbackAsync(new RollbackRequest(
                SessionToken:           session.SessionId,
                OriginalTransactionRef: session.PendingDebitTxRef,
                Amount:                 session.PendingDebit,
                Currency:               session.Currency
            ), ct);

            if (result.Success || result.FailureReason == "ALREADY_ROLLED_BACK")
            {
                await _stateMachine.TransitionAsync(
                    session.SessionId, GameEvent.DebitFailed,
                    new TransitionData(), ct);
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex,
                "Rollback failed for session {Id}, debitRef {Ref}",
                session.SessionId, session.PendingDebitTxRef);
            // Escalate to manual reconciliation queue
        }
    }

    private async Task RetryCreditAsync(
        SessionState session, CancellationToken ct)
    {
        if (session.PendingCredit <= 0 || session.PendingCreditTxRef is null)
        {
            // Nothing to credit — transition through to Idle
            await _stateMachine.TransitionAsync(
                session.SessionId,
                session.IsInBonus ? GameEvent.BonusCreditConfirmed : GameEvent.CreditConfirmed,
                new TransitionData(
                    TotalWin:            0m,
                    BonusSpinsRemaining: session.BonusState?.SpinsRemaining ?? 0
                ), ct);
            return;
        }

        var credit = await _wallet.CreditAsync(new CreditRequest(
            SessionToken:   session.SessionId,
            Amount:         session.PendingCredit,
            Currency:       session.Currency,
            TransactionRef: session.PendingCreditTxRef  // Idempotent retry
        ), ct);

        if (credit.Success)
        {
            await _stateMachine.TransitionAsync(
                session.SessionId,
                session.IsInBonus ? GameEvent.BonusCreditConfirmed : GameEvent.CreditConfirmed,
                new TransitionData(
                    TotalWin:            session.PendingCredit,
                    BonusSpinsRemaining: session.BonusState?.SpinsRemaining ?? 0
                ), ct);

            _logger.LogInformation(
                "Recovered credit for session {Id}: {Amount} {Currency}",
                session.SessionId[..8], session.PendingCredit, session.Currency);
        }
    }
}

Part VII. Testing the State Machine

7.1 State Transition Coverage Tests

[TestClass]
public class StateMachineTransitionTests
{
    private SlotStateMachine  _machine = null!;
    private ISessionStore     _store   = null!;

    [TestInitialize]
    public void Setup()
    {
        _store   = new InMemorySessionStore();
        _machine = new SlotStateMachine(
            _store,
            new NullStateMachineLogger(),
            new NullLogger<SlotStateMachine>());
    }

    // ── Test every valid transition explicitly ────────────────────

    [TestMethod]
    public async Task IdlePlusSpinRequested_TransitionsToDebitPending()
    {
        var session = await CreateSessionInState(GameState.Idle);

        var result = await _machine.TransitionAsync(
            session.SessionId,
            GameEvent.SpinRequested,
            new TransitionData(BetAmount: 1.00m));

        Assert.AreEqual(GameState.DebitPending, result.CurrentState);
    }

    [TestMethod]
    public async Task DebitPendingPlusDebitFailed_TransitionsToIdle()
    {
        var session = await CreateSessionInState(GameState.DebitPending);

        var result = await _machine.TransitionAsync(
            session.SessionId,
            GameEvent.DebitFailed,
            new TransitionData());

        Assert.AreEqual(GameState.Idle, result.CurrentState);
        Assert.AreEqual(0m, result.PendingDebit,
            "PendingDebit must be cleared on DebitFailed.");
    }

    [TestMethod]
    public async Task EvaluatingPlusEvalComplete_NoWinNoBonus_TransitionsToIdle()
    {
        var session = await CreateSessionInState(GameState.Evaluating);

        var result = await _machine.TransitionAsync(
            session.SessionId,
            GameEvent.EvaluationComplete,
            new TransitionData(TotalWin: 0m, BonusTriggered: false));

        Assert.AreEqual(GameState.Idle, result.CurrentState);
        Assert.IsNull(result.BonusState);
    }

    [TestMethod]
    public async Task EvaluatingPlusEvalComplete_WithWin_TransitionsToCreditPending()
    {
        var session = await CreateSessionInState(GameState.Evaluating);

        var result = await _machine.TransitionAsync(
            session.SessionId,
            GameEvent.EvaluationComplete,
            new TransitionData(TotalWin: 5.00m, BonusTriggered: false));

        Assert.AreEqual(GameState.CreditPending, result.CurrentState);
        Assert.AreEqual(5.00m, result.PendingCredit);
    }

    [TestMethod]
    public async Task EvaluatingPlusEvalComplete_BonusTriggered_TransitionsToBonusInitialising()
    {
        var session = await CreateSessionInState(GameState.Evaluating);

        var result = await _machine.TransitionAsync(
            session.SessionId,
            GameEvent.EvaluationComplete,
            new TransitionData(
                TotalWin: 0m, BonusTriggered: true,
                FreeSpinsAwarded: 10, FreeSpinsMultiplier: 2.5m));

        Assert.AreEqual(GameState.BonusInitialising, result.CurrentState);
        Assert.IsNotNull(result.BonusState);
        Assert.AreEqual(10, result.BonusState!.FreeSpinsAwarded);
    }

    // ── Test every INVALID transition is rejected ─────────────────

    [TestMethod]
    [ExpectedException(typeof(InvalidTransitionException))]
    public async Task IdlePlusBonusSpinRequested_ThrowsInvalidTransition()
    {
        var session = await CreateSessionInState(GameState.Idle);

        await _machine.TransitionAsync(
            session.SessionId,
            GameEvent.BonusSpinRequested,  // Invalid: not in bonus
            new TransitionData());
    }

    [TestMethod]
    [ExpectedException(typeof(InvalidTransitionException))]
    public async Task BonusIdlePlusSpinRequested_ThrowsInvalidTransition()
    {
        var session = await CreateSessionInState(GameState.BonusIdle);

        await _machine.TransitionAsync(
            session.SessionId,
            GameEvent.SpinRequested,  // Invalid: use BonusSpinRequested instead
            new TransitionData());
    }

    [TestMethod]
    [ExpectedException(typeof(InvalidTransitionException))]
    public async Task TerminatedPlusAnyEvent_ThrowsInvalidTransition()
    {
        var session = await CreateSessionInState(GameState.Terminated);

        await _machine.TransitionAsync(
            session.SessionId,
            GameEvent.SpinRequested,
            new TransitionData());
    }

    // ── Concurrency test: second spin while first is in progress ──

    [TestMethod]
    [ExpectedException(typeof(ConcurrentTransitionException))]
    public async Task ConcurrentSpinAttempt_SecondRequestFails()
    {
        var session = await CreateSessionInState(GameState.Idle);

        // First transition: Idle → DebitPending
        // Don't complete it — simulate concurrent second request

        var t1 = _machine.TransitionAsync(
            session.SessionId,
            GameEvent.SpinRequested,
            new TransitionData(BetAmount: 1.00m));

        // Second concurrent request with same session
        var t2 = _machine.TransitionAsync(
            session.SessionId,
            GameEvent.SpinRequested,
            new TransitionData(BetAmount: 1.00m));

        // One must succeed, one must throw ConcurrentTransitionException
        try { await Task.WhenAll(t1, t2); }
        catch (AggregateException ae) when (
            ae.InnerExceptions.Any(e => e is ConcurrentTransitionException))
        {
            throw new ConcurrentTransitionException(
                session.SessionId, GameState.Idle, GameEvent.SpinRequested);
        }
    }

    // ── Full happy path: Idle → ... → Idle ───────────────────────

    [TestMethod]
    public async Task FullSpinCycle_HappyPath_ReturnedToIdle()
    {
        var session = await CreateSessionInState(GameState.Idle);
        string id   = session.SessionId;

        await _machine.TransitionAsync(id, GameEvent.SpinRequested,
            new TransitionData(BetAmount: 1.00m));
        await _machine.TransitionAsync(id, GameEvent.DebitConfirmed,
            new TransitionData(DebitAmount: 1.00m));
        await _machine.TransitionAsync(id, GameEvent.OutcomeGenerated,
            new TransitionData());
        await _machine.TransitionAsync(id, GameEvent.EvaluationComplete,
            new TransitionData(TotalWin: 5.00m, BonusTriggered: false));
        var final = await _machine.TransitionAsync(id, GameEvent.CreditConfirmed,
            new TransitionData(TotalWin: 5.00m));

        Assert.AreEqual(GameState.Idle, final.CurrentState);
        Assert.AreEqual(0m, final.PendingDebit);
        Assert.AreEqual(0m, final.PendingCredit);
        Assert.IsNull(final.BonusState);
    }

    // ── Full bonus cycle: Idle → Bonus → Idle ────────────────────

    [TestMethod]
    public async Task FullBonusCycle_TenSpins_ReturnedToIdle()
    {
        var session = await CreateSessionInState(GameState.Idle);
        string id   = session.SessionId;

        // Trigger spin + bonus
        await _machine.TransitionAsync(id, GameEvent.SpinRequested,
            new TransitionData(BetAmount: 1.00m));
        await _machine.TransitionAsync(id, GameEvent.DebitConfirmed,
            new TransitionData(DebitAmount: 1.00m));
        await _machine.TransitionAsync(id, GameEvent.OutcomeGenerated, new());
        await _machine.TransitionAsync(id, GameEvent.EvaluationComplete,
            new TransitionData(BonusTriggered: true,
                FreeSpinsAwarded: 10, FreeSpinsMultiplier: 2.5m));
        await _machine.TransitionAsync(id, GameEvent.BonusInitialised, new());

        // Play 10 bonus spins (no wins for simplicity)
        for (int spin = 0; spin < 9; spin++)
        {
            await _machine.TransitionAsync(id, GameEvent.BonusSpinRequested, new());
            await _machine.TransitionAsync(id, GameEvent.BonusOutcomeGenerated, new());
            await _machine.TransitionAsync(id, GameEvent.BonusEvaluationComplete,
                new TransitionData(TotalWin: 0m,
                    BonusSpinsRemaining: 10 - spin - 1));
        }

        // Final spin
        await _machine.TransitionAsync(id, GameEvent.BonusSpinRequested, new());
        await _machine.TransitionAsync(id, GameEvent.BonusOutcomeGenerated, new());
        await _machine.TransitionAsync(id, GameEvent.BonusEvaluationComplete,
            new TransitionData(TotalWin: 0m, BonusSpinsRemaining: 0));
        await _machine.TransitionAsync(id, GameEvent.BonusCompleted, new());

        var (final, _) = await _store.LoadWithVersionAsync(id);

        Assert.AreEqual(GameState.Idle, final!.CurrentState);
        Assert.IsNull(final.BonusState,
            "BonusState must be null after bonus completes.");
    }

    private async Task<SessionState> CreateSessionInState(GameState targetState)
    {
        // Create a new session and drive it to the target state
        var session = SessionStateFactory.CreateNew(
            sessionId: Guid.NewGuid().ToString("N"),
            playerId:  "test-player",
            currency:  "EUR",
            gameId:    "crystal-forge-v1");

        await _store.SaveWithVersionAsync(session.SessionId, session, -1);

        // Drive to target state using known-valid transitions
        if (targetState == GameState.Idle) return session;

        foreach (var (evt, data) in GetPathToState(targetState))
            session = await _machine.TransitionAsync(session.SessionId, evt, data);

        return session;
    }
}

7.2 Property-Based Tests

/// <summary>
/// Property-based tests verify that state machine invariants hold
/// across ALL valid transition sequences, not just the happy path.
/// Uses FsCheck (or a similar library) for property generation.
/// </summary>
[TestClass]
public class StateMachinePropertyTests
{
    /// <summary>
    /// Property: No sequence of valid transitions can produce
    /// a session with PendingDebit > 0 in state Idle.
    /// </summary>
    [TestMethod]
    public void Property_NoPendingDebitInIdleState()
    {
        Prop.ForAll(
            GenerateValidTransitionSequence(),
            sequence =>
            {
                var state = ExecuteSequence(sequence);
                if (state.CurrentState == GameState.Idle)
                    return state.PendingDebit == 0;
                return true; // Don't check other states
            }
        ).QuickCheckThrowOnFailure();
    }

    /// <summary>
    /// Property: A session in any non-terminal state can always reach Idle
    /// via some sequence of valid transitions.
    /// (Liveness: the system always makes progress)
    /// </summary>
    [TestMethod]
    public void Property_EveryStateCanReachIdle()
    {
        foreach (GameState state in Enum.GetValues<GameState>())
        {
            if (state == GameState.Terminated) continue;

            var validEvents = TransitionTable.ValidEventsFrom(state);
            Assert.IsTrue(
                validEvents.Count > 0 || state == GameState.Idle,
                $"State {state} has no valid events — it's a deadlock state.");
        }
    }

    /// <summary>
    /// Property: BonusState is non-null if and only if the session is in a bonus state.
    /// </summary>
    [TestMethod]
    public void Property_BonusStateConsistency()
    {
        Prop.ForAll(
            GenerateValidTransitionSequence(),
            sequence =>
            {
                var state = ExecuteSequence(sequence);
                var checker = new SessionInvariantChecker();
                var result  = checker.Check(state);
                return result.IsValid;
            }
        ).QuickCheckThrowOnFailure();
    }
}

Part VIII. State Visibility: Monitoring and Observability

8.1 Real-Time State Distribution Dashboard

For production operations, you need to know the state distribution of all active sessions at all times. A large number of sessions stuck in CreditPending is an immediate operational alert.

public class SessionStateMonitor : BackgroundService
{
    private readonly ISessionStore _store;
    private readonly IMetricsCollector _metrics;

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var distribution = await _store.GetStateDistributionAsync(ct);

            foreach (var (state, count) in distribution)
            {
                // Publish to Prometheus / Datadog / CloudWatch
                _metrics.Gauge(
                    "slot_sessions_by_state",
                    count,
                    tags: new[] { $"state:{state}" });

                // Alert on stuck states
                if (IsStuckAlert(state, count))
                {
                    _metrics.Counter("slot_stuck_session_alert",
                        tags: new[] { $"state:{state}" });
                }
            }

            await Task.Delay(TimeSpan.FromSeconds(30), ct);
        }
    }

    private static bool IsStuckAlert(GameState state, long count) => state switch
    {
        GameState.CreditPending      when count > 10   => true,
        GameState.BonusCreditPending when count > 10   => true,
        GameState.DebitPending       when count > 100  => true,
        GameState.SpinExecuting      when count > 100  => true,
        _ => false
    };
}

8.2 The State Transition Log as an Audit Trail

Every state transition is logged with full context. This log serves triple duty: debugging, audit, and incident investigation.

Sample state transition log (structured, queryable):

[2026-03-16T14:23:07.100Z] session=01942a8f  IDLE ──SpinRequested──► DEBIT_PENDING
  bet=1.00 EUR  seq=42

[2026-03-16T14:23:07.255Z] session=01942a8f  DEBIT_PENDING ──DebitConfirmed──► SPIN_EXECUTING
  debitTxId=agg-tx-88201  balanceBefore=125.40  balanceAfter=124.40

[2026-03-16T14:23:07.256Z] session=01942a8f  SPIN_EXECUTING ──OutcomeGenerated──► EVALUATING
  stops=[14,7,22,3,18]  processingMs=0

[2026-03-16T14:23:07.256Z] session=01942a8f  EVALUATING ──EvaluationComplete──► CREDIT_PENDING
  totalWin=2.50  wins=[{line:0,sym:3,count:3,amt:2.50}]

[2026-03-16T14:23:07.402Z] session=01942a8f  CREDIT_PENDING ──CreditConfirmed──► IDLE
  creditTxId=agg-tx-88202  balanceAfter=126.90  roundTrip=302ms

From this log, any regulatory query about spin sequence 42 for session 01942a8f can be fully answered without touching any other data store.


Summary

An explicit state machine is not over-engineering for a slot game. It is the minimum viable architecture for a system that must be fair, auditable, resilient to failure, and certifiable by a regulatory laboratory.

The key principles from this article:

Make invalid states unrepresentable. Every combination of flags that represents an invalid game state is a potential bug. Replace flags with a typed GameState enum — the compiler enforces that you handle all states.

The transition table is the source of truth. Every valid (state, event) → next state mapping is in one place. If a transition is not in the table, it is invalid. This makes the state machine's behaviour completely predictable and auditable.

Optimistic concurrency prevents double-spins. The Redis version-based CAS operation ensures that concurrent spin attempts for the same session fail atomically — one succeeds, one fails gracefully.

Every dangerous state has exactly one recovery action. Design the crash recovery system state-by-state. For DebitPending: rollback. For CreditPending: retry credit. For SpinExecuting: depends on the engine — rollback if outcome not yet generated. The state tells you exactly what to do.

Invariant checking catches corruption early. Run invariant checks after every transition in staging. Run them on session load in production. Violations are bugs — find them before money is affected.

The transition log is the audit trail. Log every (fromState, event, toState) transition with full context. This log answers any regulatory question about session history without needing any other data source.

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

Policy