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:

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=302msFrom 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.
