Neon Royale

Seed, ServerSeed, ClientSeed — Provably Fair Mechanics: Transparency and Verifiable Fairness

Neon AdminNeon Admin·Mar 20, 2026
Seed, ServerSeed, ClientSeed — Provably Fair Mechanics: Transparency and Verifiable Fairness

Introduction

Trust is the fundamental commodity of the gambling industry. A player sitting at a slot machine in a physical casino can watch the reels spin, watch the symbols land, and trust — at least in principle — that the machine is not cheating them. The randomness is visible, tactile, and regulated by physical mechanics they can observe.

An online slot is a different proposition entirely. The player sees a beautifully animated interface on their screen. Behind it, somewhere on a server they cannot access, a computation happens. A number is generated. Symbols are determined. A win or loss is calculated. The player is shown the result and expected to believe it.

In traditional regulated online gambling, this trust is established through a chain of institutional actors: the certification laboratory that tested the RNG, the regulator that issued the licence, the operator that holds the licence and is legally accountable. The player trusts the outcome because they trust the institutions.

Provably Fair is a fundamentally different model. Instead of asking the player to trust institutions, it gives the player the cryptographic tools to verify, independently and after the fact, that every outcome they received was determined before they placed their bet and was not manipulated. Trust is replaced by mathematical proof.

This article is the complete technical implementation guide for Provably Fair in the context of slot games. We build the full system from first principles: the cryptographic primitives, the commit-reveal protocol, the HMAC-SHA256 derivation of reel stops from combined seeds, the commitment publication and session reveal mechanisms, the client-side verification code, and the architectural integration with the state machine and session management systems from previous articles. We also examine the relationship between Provably Fair and traditional regulatory certification — they are complementary, not competing, and understanding how they interact is essential for deploying Provably Fair in regulated markets.


Part I. The Problem Provably Fair Solves

1.1 The Fundamental Trust Problem

Consider the simplest possible online slot outcome: the server generates a number between 1 and 32 to determine which symbol lands on a reel. From the player's perspective:

What the player sees:    Diamond lands on reel 1
What the player knows:   The server said Diamond landed
What the player cannot:  Verify that the server's RNG actually produced
                         the stop position that places Diamond in that row

The player has no way to verify that the number produced by the server's RNG genuinely determined the symbol they saw. The server could, in principle, have decided the outcome first (no win, always) and then generated a "random" number that justifies it. This attack is called outcome post-selection — and it is mathematically impossible to detect through observation of results alone.

Provably Fair closes this gap with a cryptographic commitment scheme that makes post-selection impossible even if the server wanted to cheat:

BEFORE the spin:   Server commits to its secret seed.
                   Player cannot predict the outcome from the commitment.

DURING the spin:   Player provides their own seed.
                   Outcome = f(ServerSeed, ClientSeed, SpinNonce)
                   Neither party can control the outcome alone.

AFTER the session: Server reveals the secret seed.
                   Player verifies: Hash(revealed seed) = commitment.
                   Player recomputes: f(revealed seed, clientSeed, nonce)
                                    = announced outcome ✓

Post-selection is impossible because the server committed to its seed before receiving the player's seed. Changing the server seed would change the commitment hash — a detectable change. The player's seed ensures the server cannot predict the outcome either, preventing any forward bias.

1.2 What Provably Fair Does Not Solve

Intellectual honesty requires stating what Provably Fair does not protect against:

It does not guarantee a specific RTP. A Provably Fair system with biased reel strips still returns less than its pay table implies. Provably Fair proves the outcome was random — not that the game's mathematical model is fair.

It does not prevent front-running. If the server could observe the client seed before generating its own server seed (which a correct implementation prevents through proper timing), it could bias the outcome.

It does not protect against client seed guessing. If the client seed is predictable (e.g., derived from a weak JavaScript random), the server could predict it and adjust its server seed accordingly. The client seed must be cryptographically random.

It does not replace regulatory certification in licensed markets. Regulators care about provable RTP, responsible gambling features, and audit logs — not just cryptographic fairness proofs. Provably Fair and certification are complementary.

1.3 Who Uses Provably Fair

Provably Fair originated in the crypto gambling space, where:

Operators frequently lack traditional gaming licences

Players have no regulatory recourse in case of disputes

The technical sophistication of the player base supports cryptographic verification

In licensed markets, Provably Fair is increasingly adopted as a transparency layer on top of standard certification — providing players with self-service verification while maintaining full regulatory compliance.


Part II. Cryptographic Foundations

2.1 Hash Functions and Commitment Schemes

The entire Provably Fair mechanism rests on the security properties of cryptographic hash functions. Specifically, SHA-256.

SHA-256 properties relevant to Provably Fair:

Pre-image resistance:
  Given h = SHA256(x), it is computationally infeasible to find x.
  
  This means: given the commitment (hash of server seed),
  the player cannot discover the server seed before the session ends.
  The server's secret is safe until revealed.

Second pre-image resistance:
  Given x and h = SHA256(x), it is computationally infeasible to find
  x' ≠ x such that SHA256(x') = h.
  
  This means: the server cannot change its server seed after publishing
  the commitment while maintaining the same commitment hash.
  The server is bound to the committed seed.

Collision resistance:
  It is computationally infeasible to find any x, x' where
  SHA256(x) = SHA256(x').
  
  This means: the server cannot prepare two different server seeds that
  produce the same commitment, then switch between them based on the
  player's seed.

2.2 HMAC and Keyed Hashing

The outcome derivation uses HMAC-SHA256 (Hash-based Message Authentication Code) rather than plain SHA-256. HMAC adds a key to the hash computation:

HMAC-SHA256(key, message) = SHA256((key ⊕ opad) || SHA256((key ⊕ ipad) || message))

Where opad and ipad are fixed padding constants. The critical property: knowing HMAC-SHA256(k, m) for many messages m reveals nothing about k (assuming SHA-256 is secure).

In the Provably Fair context:

key     = Server Seed     (kept secret until session end)
message = Client Seed + ":" + Nonce + ":" + ReelIndex
output  = HMAC-SHA256(ServerSeed, ClientSeed:Nonce:ReelIndex)

This construction means:

The server cannot predict the output before knowing the client seed (depends on client seed)

The player cannot compute the output before the server seed is revealed (depends on server seed)

The output is deterministic: the same inputs always produce the same output

2.3 Deriving a Reel Stop from Hash Output

HMAC-SHA256 produces 32 bytes (256 bits) of output. We need an integer in [0, reelSize). The derivation must be:

Unbiased: every stop position has equal probability

Deterministic: same inputs → same output

Efficient: minimal additional randomness calls needed

The standard approach uses rejection sampling — Lemire's method in efficient form:

/// <summary>
/// Derives an unbiased integer in [0, range) from a 32-byte hash output.
/// Uses rejection sampling to eliminate modular bias.
/// For typical reel sizes (≤ 64), rejection is extremely rare.
/// </summary>
public static int DeriveUnbiasedInt(byte[] hashOutput, int range)
{
    if (range <= 0) throw new ArgumentOutOfRangeException(nameof(range));
    if (range == 1) return 0;

    // We'll work through the hash output 4 bytes at a time.
    // If we exhaust 32 bytes without a valid value, we extend with counter.
    for (int offset = 0; offset + 4 <= hashOutput.Length; offset += 4)
    {
        uint value    = BitConverter.ToUInt32(hashOutput, offset);
        uint threshold = uint.MaxValue % (uint)range;

        // Reject values below threshold to eliminate bias
        // (extremely rare: ~1/reelSize probability)
        if (value < threshold) continue;

        return (int)(value % (uint)range);
    }

    // Hash exhausted (extremely unlikely for small ranges)
    // Use last 4 bytes with counter extension
    uint fallback = BitConverter.ToUInt32(hashOutput, 28);
    return (int)(fallback % (uint)range);
}

For a reel of size 32: threshold = 2^32 % 32 = 0. No rejection is needed — every value of the 4-byte uint maps cleanly to a reel stop via modulo 32.

For a reel of size 30: threshold = 2^32 % 30 = 16. Values in [0, 16) are rejected. Expected rejections: 16 / 2^32 ≈ 0.0000037% — essentially never in practice.


Part III. The Protocol Design

3.1 The Complete Provably Fair Flow

┌──────────────────────────────────────────────────────────────────┐
│              PROVABLY FAIR SESSION LIFECYCLE                     │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│  SESSION START                                                   │
│  ─────────────────────────────────────────────────────────────   │
│  1. Server generates ServerSeed (32 cryptographically random     │
│     bytes from OS CSPRNG)                                        │
│  2. Server computes Commitment = SHA256(ServerSeed)              │
│  3. Server STORES ServerSeed (secret, not logged outside HSM)    │
│  4. Server PUBLISHES Commitment to player (in session init)      │
│     → Player can verify this commitment will be kept             │
│  5. Server generates or accepts ClientSeed from player           │
│     → Client-side: crypto.randomUUID() or similar CSPRNG         │
│  6. SpinNonce initialised to 1 for this session                  │
│                                                                  │
│  PER SPIN                                                        │
│  ─────────────────────────────────────────────────────────────   │
│  7. For each reel i (0..4):                                      │
│     message_i = UTF8(ClientSeed + ":" + Nonce + ":" + i)         │
│     hmac_i    = HMAC-SHA256(key=ServerSeed, msg=message_i)       │
│     stop_i    = DeriveUnbiasedInt(hmac_i, reelSize_i)            │
│  8. Spin outcome determined from {stop_0..stop_4}                │
│  9. SpinNonce++                                                  │
│                                                                  │
│  PLAYER CAN CHANGE CLIENT SEED (anytime between spins)           │
│  ─────────────────────────────────────────────────────────────   │
│  10. Player submits new ClientSeed                               │
│      Server acknowledges; new seed takes effect next spin        │
│      Previous spins remain verifiable with old client seed       │
│                                                                  │
│  SESSION END (server seed rotation)                              │
│  ─────────────────────────────────────────────────────────────   │
│  11. Player decides to end session / change server seed          │
│  12. Server publishes (reveals) ServerSeed                       │
│  13. Player verifies: SHA256(ServerSeed) == published Commitment │
│  14. New session: new ServerSeed, new Commitment, Nonce resets   │
│                                                                  │
│  INDEPENDENT VERIFICATION                                        │
│  ─────────────────────────────────────────────────────────────   │
│  15. Player computes:                                            │
│      hmac_i = HMAC-SHA256(ServerSeed, ClientSeed:Nonce:i)        │
│      stop_i = DeriveUnbiasedInt(hmac_i, reelSize_i)              │
│      Verifies this matches the announced spin outcome            │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

3.2 The Message Format

The HMAC message format must be precisely specified and documented. Every field, every separator, every encoding rule must be unambiguous — the player's verification code must be able to reproduce it exactly.

Message format for reel i, spin nonce n, client seed cs:
  "{cs}:{n}:{i}"

Where:
  cs = client seed (hex string, lowercase, 64 characters for 32-byte seed)
  n  = spin nonce (decimal integer, no leading zeros, 1-indexed)
  i  = reel index (decimal integer, 0-indexed)
  
Examples:
  ClientSeed = "a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1"
  SpinNonce  = 42
  ReelIndex  = 0
  Message    = "a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1:42:0"
  
  ReelIndex  = 1
  Message    = "a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1:42:1"

The choice of ":" as separator is conventional. What matters is that it is documented, consistent, and impossible to collide (the client seed hex string contains only hex characters, never ":").


Part IV. Full C# Server Implementation

4.1 The Provably Fair Session

/// <summary>
/// A Provably Fair session manages the cryptographic state for one
/// server seed period. A new session begins when the server seed
/// is rotated (either by player request or by the system).
///
/// The server seed is never exposed while the session is active.
/// Only the SHA-256 commitment is shared with the player.
/// </summary>
public sealed class ProvablyFairSession
{
    // ── Private: never exposed during session ──────────────────────
    private readonly byte[]  _serverSeedBytes;

    // ── Public: shared with player at session start ────────────────
    public string   SessionId    { get; }
    public string   Commitment   { get; }   // SHA256(ServerSeed), hex lowercase
    public string   ServerSeedHash => Commitment;  // Alias for clarity

    // ── Mutable: changes per spin or by player action ─────────────
    public string   ClientSeed   { get; private set; }
    public long     SpinNonce    { get; private set; }

    // ── Revealed after session ends ───────────────────────────────
    public string?  RevealedServerSeed { get; private set; }
    public bool     IsRevealed         => RevealedServerSeed is not null;

    // ── Audit metadata ────────────────────────────────────────────
    public DateTime CreatedAt    { get; }
    public DateTime? RevealedAt  { get; private set; }
    public long     TotalSpins   => SpinNonce - 1;

    private ProvablyFairSession(
        string sessionId,
        byte[] serverSeedBytes,
        string commitment,
        string clientSeed)
    {
        SessionId        = sessionId;
        _serverSeedBytes = serverSeedBytes;
        Commitment       = commitment;
        ClientSeed       = clientSeed;
        SpinNonce        = 1;
        CreatedAt        = DateTime.UtcNow;
    }

    /// <summary>
    /// Creates a new Provably Fair session.
    /// Generates a cryptographically secure server seed from the OS CSPRNG.
    /// Computes and stores the SHA-256 commitment.
    /// </summary>
    public static ProvablyFairSession Create(
        string sessionId,
        string? clientSeed = null)
    {
        // Server seed: 32 bytes from OS CSPRNG (BCryptGenRandom / /dev/urandom)
        byte[] serverSeedBytes = new byte[32];
        RandomNumberGenerator.Fill(serverSeedBytes);

        // Commitment: SHA-256 of the raw seed bytes
        byte[] hash       = SHA256.HashData(serverSeedBytes);
        string commitment = Convert.ToHexString(hash).ToLowerInvariant();

        // Client seed: provided by player or generated server-side
        // When server-generated, it can be changed by the player before first spin
        string cs = clientSeed
            ?? Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();

        return new ProvablyFairSession(sessionId, serverSeedBytes, commitment, cs);
    }

    /// <summary>
    /// Generates reel stop positions for a single spin.
    /// Uses HMAC-SHA256 with the server seed as key.
    /// Increments the spin nonce after generation.
    /// </summary>
    public int[] GenerateReelStops(IReadOnlyList<int> reelSizes)
    {
        if (IsRevealed)
            throw new InvalidOperationException(
                "Cannot generate stops after server seed has been revealed.");

        var stops = new int[reelSizes.Count];

        for (int reelIndex = 0; reelIndex < reelSizes.Count; reelIndex++)
        {
            stops[reelIndex] = DeriveStop(
                _serverSeedBytes,
                ClientSeed,
                SpinNonce,
                reelIndex,
                reelSizes[reelIndex]);
        }

        SpinNonce++;
        return stops;
    }

    /// <summary>
    /// Updates the client seed. Takes effect on the next spin.
    /// The new seed is accepted regardless of its source —
    /// it is the player's contribution to the outcome derivation.
    /// </summary>
    public void UpdateClientSeed(string newClientSeed)
    {
        if (string.IsNullOrWhiteSpace(newClientSeed))
            throw new ArgumentException("Client seed cannot be empty.");

        // Validate: must be a valid hex string of reasonable length
        if (newClientSeed.Length < 16 || newClientSeed.Length > 128)
            throw new ArgumentException(
                "Client seed must be between 16 and 128 characters.");

        if (!newClientSeed.All(c => "0123456789abcdefABCDEF".Contains(c)))
            throw new ArgumentException(
                "Client seed must be a hexadecimal string.");

        ClientSeed = newClientSeed.ToLowerInvariant();
    }

    /// <summary>
    /// Reveals the server seed at session end.
    /// After this call, no more stops can be generated.
    /// The player can verify: SHA256(RevealedServerSeed) == Commitment.
    /// </summary>
    public string RevealServerSeed()
    {
        if (IsRevealed)
            return RevealedServerSeed!;

        RevealedServerSeed = Convert.ToHexString(_serverSeedBytes).ToLowerInvariant();
        RevealedAt         = DateTime.UtcNow;
        return RevealedServerSeed;
    }

    /// <summary>
    /// Static verification: confirms a revealed server seed matches a commitment.
    /// Called by the player (or on their behalf) after session end.
    /// </summary>
    public static bool VerifyCommitment(string revealedSeedHex, string commitment)
    {
        try
        {
            byte[] seedBytes    = Convert.FromHexString(revealedSeedHex);
            byte[] hash         = SHA256.HashData(seedBytes);
            string computedHash = Convert.ToHexString(hash).ToLowerInvariant();
            return computedHash.Equals(commitment, StringComparison.OrdinalIgnoreCase);
        }
        catch
        {
            return false;  // Invalid hex string
        }
    }

    /// <summary>
    /// Static derivation: reproduces a specific spin's reel stops.
    /// Can be called by the player after session reveal for independent verification.
    /// The algorithm is public and documented — any implementation produces the same result.
    /// </summary>
    public static int[] DeriveStopsForSpin(
        string              revealedServerSeedHex,
        string              clientSeed,
        long                spinNonce,
        IReadOnlyList<int>  reelSizes)
    {
        byte[] serverSeedBytes = Convert.FromHexString(revealedServerSeedHex);
        var    stops           = new int[reelSizes.Count];

        for (int reelIndex = 0; reelIndex < reelSizes.Count; reelIndex++)
        {
            stops[reelIndex] = DeriveStop(
                serverSeedBytes, clientSeed, spinNonce,
                reelIndex, reelSizes[reelIndex]);
        }

        return stops;
    }

    // ── Core derivation function ───────────────────────────────────

    private static int DeriveStop(
        byte[] serverSeedBytes,
        string clientSeed,
        long   spinNonce,
        int    reelIndex,
        int    reelSize)
    {
        // Message: "{clientSeed}:{spinNonce}:{reelIndex}"
        string  message      = $"{clientSeed}:{spinNonce}:{reelIndex}";
        byte[]  messageBytes = Encoding.UTF8.GetBytes(message);

        // HMAC-SHA256(key=ServerSeed, message)
        byte[]  hmacOutput   = HMACSHA256.HashData(serverSeedBytes, messageBytes);

        // Derive unbiased integer in [0, reelSize)
        return DeriveUnbiasedInt(hmacOutput, reelSize);
    }

    private static int DeriveUnbiasedInt(byte[] hashBytes, int range)
    {
        // Try each 4-byte slice with rejection sampling
        for (int offset = 0; offset + 4 <= hashBytes.Length; offset += 4)
        {
            uint   value     = BinaryPrimitives.ReadUInt32LittleEndian(
                                   hashBytes.AsSpan(offset, 4));
            uint   threshold = (uint)(-(int)range % range);
            // threshold = (2^32) mod range = first value where modulo is unbiased

            if (value >= threshold)
                return (int)(value % (uint)range);
        }

        // Fallback for pathological cases (essentially never occurs for range ≤ 2^30)
        uint fallback = BinaryPrimitives.ReadUInt32LittleEndian(hashBytes.AsSpan(0, 4));
        return (int)(fallback % (uint)range);
    }
}

4.2 The Provably Fair Repository

/// <summary>
/// Persists Provably Fair session state.
/// The server seed bytes must be stored encrypted at rest —
/// they are the cryptographic secret that guarantees fairness.
/// </summary>
public interface IProvablyFairRepository
{
    Task SaveSessionAsync(ProvablyFairRecord record, CancellationToken ct = default);
    Task<ProvablyFairRecord?> LoadBySessionIdAsync(string sessionId, CancellationToken ct = default);
    Task UpdateNonceAsync(string sessionId, long newNonce, CancellationToken ct = default);
    Task UpdateClientSeedAsync(string sessionId, string newClientSeed, long atNonce, CancellationToken ct = default);
    Task RevealAsync(string sessionId, string revealedSeedHex, CancellationToken ct = default);
    Task<List<SpinVerificationRecord>> GetSpinsForVerificationAsync(
        string sessionId, long? fromNonce, long? toNonce, CancellationToken ct = default);
}

/// <summary>
/// The persisted record for a Provably Fair session.
/// ServerSeedEncrypted must use AES-256-GCM with a KMS-managed key.
/// </summary>
public sealed record ProvablyFairRecord(
    string   SessionId,
    string   GameSessionId,       // Links to main session record
    string   ServerSeedEncrypted, // AES-256-GCM encrypted; never stored plaintext
    string   Commitment,          // SHA256(ServerSeed), public
    string   ClientSeed,          // Current active client seed
    long     CurrentNonce,
    DateTime CreatedAt,
    string?  RevealedServerSeed,  // Populated after session end
    DateTime? RevealedAt
);

/// <summary>
/// Record sufficient for independent verification of a single spin.
/// Stored per-spin for verifiability.
/// </summary>
public sealed record SpinVerificationRecord(
    string SessionId,
    long   SpinNonce,
    string ClientSeedAtSpin,      // Client seed active at this spin's execution
    string Commitment,            // Server seed commitment at time of spin
    int[]  DeclaredStops,         // Stops declared in the spin response
    int[]  ReelSizes,             // Reel configuration at time of spin
    string GameVersion,           // Enables future re-certification audits
    DateTime SpinTimestamp
);

4.3 Integrating with the Spin Use Case

/// <summary>
/// Extension of the SpinUseCase that derives reel stops from
/// a Provably Fair session instead of calling the OS CSPRNG directly.
///
/// The two approaches are architecturally compatible:
/// - Standard RNG: stops = CSPRNG.NextReelStops(reelSizes)
/// - Provably Fair: stops = pfSession.GenerateReelStops(reelSizes)
///
/// Both produce statistically equivalent, unpredictable stop positions.
/// The Provably Fair version additionally allows post-hoc verification.
/// </summary>
public sealed class ProvablyFairSpinEngine : ISpinEngine
{
    private readonly IProvablyFairRepository _pfRepo;
    private readonly GameConfig              _config;

    public async Task<SpinOutcome> ExecuteAsync(
        string    gameSessionId,
        decimal   betAmount,
        CancellationToken ct = default)
    {
        // Load the active Provably Fair session
        var pfRecord = await _pfRepo.LoadBySessionIdAsync(gameSessionId, ct)
            ?? throw new ProvablyFairSessionNotFoundException(gameSessionId);

        // Reconstruct session from record (server seed decrypted from KMS)
        var pfSession = await RehydrateSessionAsync(pfRecord, ct);

        // Derive reel stops from combined seeds
        int[] stops = pfSession.GenerateReelStops(_config.ReelSizes);

        // Persist updated nonce immediately, before any other operation
        // This ensures the nonce is consistent with the generated stops
        // even if the server crashes before the spin response is sent
        await _pfRepo.UpdateNonceAsync(
            gameSessionId, pfSession.SpinNonce, ct);

        // Store per-spin verification record
        await _pfRepo.SaveSpinVerificationAsync(new SpinVerificationRecord(
            SessionId:       gameSessionId,
            SpinNonce:       pfSession.SpinNonce - 1,  // Just incremented
            ClientSeedAtSpin: pfSession.ClientSeed,
            Commitment:      pfSession.Commitment,
            DeclaredStops:   stops,
            ReelSizes:       _config.ReelSizes,
            GameVersion:     _config.Version,
            SpinTimestamp:   DateTime.UtcNow
        ), ct);

        // Build visible grid and evaluate wins (same as standard engine)
        int[,] grid  = BuildGrid(stops);
        var    wins  = EvaluateAllLines(grid, betAmount / _config.LinesCount);
        int    scatters = CountScatters(grid);

        return new SpinOutcome(
            ReelStops:       stops,
            VisibleGrid:     grid,
            Wins:            wins,
            TotalWin:        wins.Sum(w => w.Amount),
            ScatterCount:    scatters,
            BonusTriggered:  scatters >= _config.BonusTriggerScatterCount,
            FreeSpinsAwarded: scatters >= _config.BonusTriggerScatterCount
                ? _config.GetFreeSpinsForScatterCount(scatters) : 0
        );
    }

    private async Task<ProvablyFairSession> RehydrateSessionAsync(
        ProvablyFairRecord record, CancellationToken ct)
    {
        // Decrypt server seed from KMS-managed key
        byte[] serverSeedBytes = await _keyManagement
            .DecryptAsync(record.ServerSeedEncrypted, ct);

        // Reconstruct session with current nonce and client seed
        return ProvablyFairSession.Rehydrate(
            record.SessionId,
            serverSeedBytes,
            record.Commitment,
            record.ClientSeed,
            record.CurrentNonce);
    }
}

4.4 The Server Seed Rotation Endpoint

[ApiController]
[Route("api/v1/provably-fair")]
public sealed class ProvablyFairController : ControllerBase
{
    private readonly IProvablyFairRepository _pfRepo;
    private readonly ISessionCache           _sessions;

    /// <summary>
    /// GET /api/v1/provably-fair/state
    /// Returns the current session's Provably Fair state.
    /// Always called on game launch to display commitment to player.
    /// </summary>
    [HttpGet("state")]
    public async Task<IActionResult> GetState(CancellationToken ct)
    {
        string? token = ExtractBearerToken();
        var session   = await ValidateSession(token, ct);

        var pf = await _pfRepo.LoadBySessionIdAsync(session.SessionId, ct)
            ?? throw new NotFoundException("Provably Fair session not found.");

        return Ok(new ProvablyFairStateResponse(
            Commitment:      pf.Commitment,
            ClientSeed:      pf.ClientSeed,
            CurrentNonce:    pf.CurrentNonce,
            TotalSpinsPlayed: pf.CurrentNonce - 1,
            NextSpinNonce:   pf.CurrentNonce,
            IsRevealed:      pf.RevealedServerSeed is not null,
            RevealedSeed:    pf.RevealedServerSeed  // null until revealed
        ));
    }

    /// <summary>
    /// POST /api/v1/provably-fair/client-seed
    /// Player submits a new client seed. Takes effect on next spin.
    /// </summary>
    [HttpPost("client-seed")]
    public async Task<IActionResult> UpdateClientSeed(
        [FromBody] UpdateClientSeedRequest request,
        CancellationToken ct)
    {
        string? token = ExtractBearerToken();
        var session   = await ValidateSession(token, ct);

        if (session.IsSpinInProgress)
            return BadRequest(new { error = "Cannot change client seed while spin is in progress." });

        // Validate client seed format
        if (string.IsNullOrWhiteSpace(request.ClientSeed)
            || request.ClientSeed.Length < 16
            || !IsValidHex(request.ClientSeed))
        {
            return BadRequest(new { error = "Invalid client seed format." });
        }

        var pf = await _pfRepo.LoadBySessionIdAsync(session.SessionId, ct);
        if (pf is null) return NotFound();

        string normalised = request.ClientSeed.ToLowerInvariant();
        await _pfRepo.UpdateClientSeedAsync(
            session.SessionId, normalised, pf.CurrentNonce, ct);

        return Ok(new
        {
            accepted          = true,
            clientSeed        = normalised,
            effectiveFromNonce = pf.CurrentNonce
        });
    }

    /// <summary>
    /// POST /api/v1/provably-fair/rotate
    /// Rotate the server seed: reveal current seed, generate new one.
    /// Can only be called between spins (not during a spin or bonus round).
    /// </summary>
    [HttpPost("rotate")]
    public async Task<IActionResult> RotateServerSeed(CancellationToken ct)
    {
        string? token = ExtractBearerToken();
        var session   = await ValidateSession(token, ct);

        if (session.IsSpinInProgress || session.IsInBonus)
            return Conflict(new { error =
                "Cannot rotate server seed during an active spin or bonus round." });

        var pf = await _pfRepo.LoadBySessionIdAsync(session.SessionId, ct);
        if (pf is null) return NotFound();

        // Decrypt and reveal the current server seed
        byte[] currentSeedBytes = await _keyManagement.DecryptAsync(
            pf.ServerSeedEncrypted, ct);
        string revealedHex = Convert.ToHexString(currentSeedBytes).ToLowerInvariant();

        // Verify commitment matches (sanity check)
        if (!ProvablyFairSession.VerifyCommitment(revealedHex, pf.Commitment))
        {
            // This should never happen — would indicate storage corruption
            _logger.LogCritical("Commitment mismatch on reveal for session {Id}",
                session.SessionId);
            return StatusCode(500, new { error = "Internal integrity error." });
        }

        // Persist the reveal
        await _pfRepo.RevealAsync(session.SessionId, revealedHex, ct);

        // Create new Provably Fair session with fresh server seed
        var newPfSession = ProvablyFairSession.Create(
            session.SessionId + "-rotated-" + pf.CurrentNonce,
            clientSeed: pf.ClientSeed   // Carry over client seed
        );

        string encryptedNewSeed = await _keyManagement.EncryptAsync(
            newPfSession.ServerSeedBytesForStorage, ct);

        await _pfRepo.SaveSessionAsync(new ProvablyFairRecord(
            SessionId:            session.SessionId,
            GameSessionId:        session.SessionId,
            ServerSeedEncrypted:  encryptedNewSeed,
            Commitment:           newPfSession.Commitment,
            ClientSeed:           newPfSession.ClientSeed,
            CurrentNonce:         1,
            CreatedAt:            DateTime.UtcNow,
            RevealedServerSeed:   null,
            RevealedAt:           null
        ), ct);

        return Ok(new RotateServerSeedResponse(
            // Old session — for player to verify
            Previous: new PreviousSessionData(
                Commitment:    pf.Commitment,
                RevealedSeed:  revealedHex,
                TotalSpins:    pf.CurrentNonce - 1,
                Verified:      true   // Server confirms self-verification
            ),
            // New session
            Next: new NextSessionData(
                Commitment:  newPfSession.Commitment,
                ClientSeed:  newPfSession.ClientSeed
            )
        ));
    }

    /// <summary>
    /// GET /api/v1/provably-fair/verify/{sessionId}
    /// Returns all data needed to verify spins for a completed session.
    /// Available after the server seed has been revealed.
    /// </summary>
    [HttpGet("verify/{sessionId}")]
    public async Task<IActionResult> GetVerificationData(
        string sessionId,
        [FromQuery] long? fromNonce,
        [FromQuery] long? toNonce,
        CancellationToken ct)
    {
        var pf = await _pfRepo.LoadBySessionIdAsync(sessionId, ct);
        if (pf is null) return NotFound();

        if (pf.RevealedServerSeed is null)
            return BadRequest(new { error =
                "Server seed has not been revealed yet. " +
                "The session must end before verification data is available." });

        var spins = await _pfRepo.GetSpinsForVerificationAsync(
            sessionId, fromNonce, toNonce, ct);

        return Ok(new VerificationDataResponse(
            SessionId:       sessionId,
            RevealedSeed:    pf.RevealedServerSeed,
            Commitment:      pf.Commitment,
            CommitmentValid: ProvablyFairSession.VerifyCommitment(
                pf.RevealedServerSeed, pf.Commitment),
            TotalSpins:      pf.CurrentNonce - 1,
            Spins:           spins.Select(MapToVerificationDto).ToList()
        ));
    }

    private static bool IsValidHex(string s)
        => s.All(c => "0123456789abcdefABCDEF".Contains(c));
}

Part V. Client-Side Implementation

5.1 TypeScript Provably Fair Module

The client-side verification module must be completely self-contained — it cannot depend on any server communication. The player should be able to copy this code and run it in any JavaScript environment.

/**
 * Provably Fair verification module.
 * Self-contained: no server communication required.
 * Can be audited, copied, and run independently by players.
 * 
 * Algorithm:
 *   1. Verify: SHA256(revealedServerSeed) == commitment
 *   2. For each spin:
 *      message_i = UTF8(`${clientSeed}:${spinNonce}:${reelIndex}`)
 *      hmac_i    = HMAC-SHA256(key=serverSeedBytes, msg=messageBytes)
 *      stop_i    = unbiasedInt(hmac_i, reelSize_i)
 *   3. Compare derived stops with declared stops
 */

export interface SpinRecord {
  spinNonce:     number;
  clientSeed:    string;    // hex string, active at time of spin
  declaredStops: number[];  // stops announced in spin response
  reelSizes:     number[];  // from game config at time of spin
}

export interface VerificationResult {
  commitmentValid:  boolean;
  spins:            SpinVerificationResult[];
  allSpinsValid:    boolean;
  invalidSpinCount: number;
}

export interface SpinVerificationResult {
  spinNonce:      number;
  derivedStops:   number[];
  declaredStops:  number[];
  isValid:        boolean;
  discrepancies:  string[];
}

export class ProvablyFairVerifier {
  /**
   * Verifies all spins in a completed session.
   */
  async verifySession(
    revealedServerSeed: string,   // hex string, revealed at session end
    commitment:         string,   // SHA256(serverSeed), published at session start
    spins:              SpinRecord[]
  ): Promise<VerificationResult> {

    // Step 1: Verify the server seed matches the commitment
    const commitmentValid = await this.verifyCommitment(
      revealedServerSeed, commitment);

    // Step 2: Verify each spin independently
    const spinResults: SpinVerificationResult[] = [];
    const serverSeedBytes = hexToBytes(revealedServerSeed);

    for (const spin of spins) {
      const result = await this.verifySpin(serverSeedBytes, spin);
      spinResults.push(result);
    }

    const invalidCount = spinResults.filter(r => !r.isValid).length;

    return {
      commitmentValid,
      spins:            spinResults,
      allSpinsValid:    commitmentValid && invalidCount === 0,
      invalidSpinCount: invalidCount,
    };
  }

  /**
   * Verifies SHA256(revealedServerSeed) === commitment.
   */
  async verifyCommitment(
    revealedServerSeed: string,
    commitment:         string
  ): Promise<boolean> {
    const seedBytes    = hexToBytes(revealedServerSeed);
    const hashBuffer   = await crypto.subtle.digest('SHA-256', seedBytes);
    const hashHex      = bytesToHex(new Uint8Array(hashBuffer));
    return hashHex.toLowerCase() === commitment.toLowerCase();
  }

  /**
   * Derives reel stops for a single spin and compares to declared stops.
   */
  async verifySpin(
    serverSeedBytes: Uint8Array,
    spin:            SpinRecord
  ): Promise<SpinVerificationResult> {

    const derivedStops: number[] = [];

    for (let reelIndex = 0; reelIndex < spin.reelSizes.length; reelIndex++) {
      const stop = await this.deriveReelStop(
        serverSeedBytes,
        spin.clientSeed,
        spin.spinNonce,
        reelIndex,
        spin.reelSizes[reelIndex]
      );
      derivedStops.push(stop);
    }

    const discrepancies: string[] = [];
    let isValid = true;

    for (let i = 0; i < derivedStops.length; i++) {
      if (derivedStops[i] !== spin.declaredStops[i]) {
        isValid = false;
        discrepancies.push(
          `Reel ${i}: derived=${derivedStops[i]}, declared=${spin.declaredStops[i]}`
        );
      }
    }

    return {
      spinNonce:    spin.spinNonce,
      derivedStops,
      declaredStops: spin.declaredStops,
      isValid,
      discrepancies,
    };
  }

  /**
   * Core derivation: HMAC-SHA256(serverSeed, clientSeed:nonce:reelIndex)
   * → unbiased integer in [0, reelSize)
   */
  private async deriveReelStop(
    serverSeedBytes: Uint8Array,
    clientSeed:      string,
    spinNonce:       number,
    reelIndex:       number,
    reelSize:        number
  ): Promise<number> {

    // Import server seed as HMAC key
    const key = await crypto.subtle.importKey(
      'raw',
      serverSeedBytes,
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['sign']
    );

    // Build message: "{clientSeed}:{spinNonce}:{reelIndex}"
    const message     = `${clientSeed}:${spinNonce}:${reelIndex}`;
    const messageBytes = new TextEncoder().encode(message);

    // Compute HMAC-SHA256
    const hmacBuffer = await crypto.subtle.sign('HMAC', key, messageBytes);
    const hmacBytes  = new Uint8Array(hmacBuffer);

    // Derive unbiased integer
    return this.deriveUnbiasedInt(hmacBytes, reelSize);
  }

  /**
   * Extracts an unbiased integer in [0, range) from hash bytes.
   * Uses rejection sampling to eliminate modular bias.
   */
  private deriveUnbiasedInt(hashBytes: Uint8Array, range: number): number {
    const view      = new DataView(hashBytes.buffer);
    const threshold = (2 ** 32) % range;  // Values below this are biased

    for (let offset = 0; offset + 4 <= hashBytes.length; offset += 4) {
      const value = view.getUint32(offset, true);  // little-endian, matching C#

      if (value >= threshold) {
        return value % range;
      }
      // If biased: try next 4-byte slice
    }

    // Fallback (essentially never reached for range ≤ 2^30)
    return view.getUint32(0, true) % range;
  }
}

// ── Utility functions ──────────────────────────────────────────────

function hexToBytes(hex: string): Uint8Array {
  const cleanHex = hex.toLowerCase().replace(/[^0-9a-f]/g, '');
  const bytes    = new Uint8Array(cleanHex.length / 2);
  for (let i = 0; i < bytes.length; i++) {
    bytes[i] = parseInt(cleanHex.substr(i * 2, 2), 16);
  }
  return bytes;
}

function bytesToHex(bytes: Uint8Array): string {
  return Array.from(bytes)
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
}

// ── Standalone verification function for player use ────────────────

/**
 * Simplified verification function for players to run in the browser console.
 * Copy-paste this into any browser's developer console to verify a spin.
 */
export async function verifySpinInConsole(
  revealedServerSeed: string,
  clientSeed:         string,
  spinNonce:          number,
  declaredStops:      number[],
  reelSizes:          number[]
): Promise<void> {
  console.log('=== Provably Fair Verification ===');
  console.log(`Server seed: ${revealedServerSeed}`);
  console.log(`Client seed: ${clientSeed}`);
  console.log(`Spin nonce:  ${spinNonce}`);
  console.log(`Reel sizes:  [${reelSizes.join(', ')}]`);
  console.log(`Declared:    [${declaredStops.join(', ')}]`);

  const verifier = new ProvablyFairVerifier();
  const seedBytes = hexToBytes(revealedServerSeed);
  const result    = await verifier.verifySpin(seedBytes, {
    spinNonce, clientSeed, declaredStops, reelSizes
  });

  console.log(`Derived:     [${result.derivedStops.join(', ')}]`);
  console.log(`Result: ${result.isValid ? '✓ VALID' : '✗ INVALID'}`);

  if (!result.isValid) {
    console.error('Discrepancies:');
    result.discrepancies.forEach(d => console.error('  ' + d));
  }
}

5.2 The Client-Side Provably Fair UI

/**
 * Provably Fair UI component for the game client.
 * Displays the current session's cryptographic state
 * and allows players to change their client seed.
 */
export class ProvablyFairPanel {
  private state: ProvablyFairStateResponse | null = null;
  private network: ProvablyFairNetworkLayer;

  async show(): Promise<void> {
    this.state = await this.network.getState();
    this.render();
  }

  private render(): void {
    if (!this.state) return;

    const html = `
      <div class="pf-panel">
        <h3>🔐 Provably Fair</h3>
        
        <div class="pf-section">
          <label>Server Seed Commitment (SHA-256)</label>
          <div class="pf-hash" title="This hash proves the server committed
               to its seed before you played. Verify it after your session.">
            ${this.formatHash(this.state.commitment)}
          </div>
          <small>Generated before your session began. 
                 Cannot change without detection.</small>
        </div>
        
        <div class="pf-section">
          <label>Your Client Seed</label>
          <div class="pf-seed">${this.state.clientSeed}</div>
          <button onclick="pfPanel.changeClientSeed()">
            🎲 Generate New Client Seed
          </button>
          <small>Your contribution to the outcome. Change anytime.</small>
        </div>
        
        <div class="pf-section">
          <label>Current Nonce</label>
          <div class="pf-nonce">${this.state.currentNonce}</div>
          <small>Spin number. Increments with each spin.</small>
        </div>
        
        <div class="pf-section">
          <button onclick="pfPanel.rotateServerSeed()" class="pf-rotate-btn">
            🔄 Rotate Server Seed
          </button>
          <small>Reveals the current seed so you can verify past spins.
                 A new session begins with a fresh server seed.</small>
        </div>
        
        ${this.state.isRevealed ? this.renderVerificationSection() : ''}
        
        <a href="/verify" target="_blank" class="pf-verify-link">
          🔍 Verify session history
        </a>
      </div>
    `;

    document.getElementById('pf-container')!.innerHTML = html;
  }

  async changeClientSeed(): Promise<void> {
    // Generate 32 cryptographically random bytes
    const randomBytes = new Uint8Array(32);
    crypto.getRandomValues(randomBytes);
    const newSeed = Array.from(randomBytes)
      .map(b => b.toString(16).padStart(2, '0'))
      .join('');

    const result = await this.network.updateClientSeed(newSeed);
    if (result.accepted) {
      this.state!.clientSeed = result.clientSeed;
      this.render();
    }
  }

  async rotateServerSeed(): Promise<void> {
    if (!confirm(
      'Rotating the server seed will reveal your current seed ' +
      'so you can verify past spins. Continue?')) return;

    const result = await this.network.rotateSeed();

    // Show verification data for the revealed session
    this.showRevealedSessionData(result.previous);

    // Update with new session data
    this.state!.commitment = result.next.commitment;
    this.state!.clientSeed = result.next.clientSeed;
    this.state!.currentNonce = 1;
    this.render();
  }

  private renderVerificationSection(): string {
    return `
      <div class="pf-section pf-revealed">
        <label>✅ Server Seed Revealed</label>
        <div class="pf-hash">${this.formatHash(this.state!.revealedSeed!)}</div>
        <small>Verify: SHA256("${this.state!.revealedSeed}") 
               should equal the commitment above.</small>
      </div>
    `;
  }

  private formatHash(hash: string): string {
    // Display in 8-character chunks for readability
    return hash.match(/.{1,8}/g)?.join(' ') ?? hash;
  }
}

Part VI. The Independent Verification Page

6.1 A Complete Standalone Verifier

This is the self-contained HTML page players can bookmark and use to verify any past session — entirely client-side, no network requests, no trust required:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Crystal Forge — Provably Fair Verifier</title>
  <style>
    body { font-family: monospace; max-width: 800px; margin: 40px auto; padding: 20px; }
    input, textarea { width: 100%; box-sizing: border-box; padding: 8px; margin: 4px 0; }
    .valid   { color: #22c55e; font-weight: bold; }
    .invalid { color: #ef4444; font-weight: bold; }
    .result  { background: #1a1a2e; color: #e2e8f0; padding: 12px; margin: 8px 0;
               border-radius: 4px; font-size: 0.85em; }
    button { background: #6366f1; color: white; border: none; padding: 10px 20px;
             cursor: pointer; border-radius: 4px; margin: 8px 0; }
  </style>
</head>
<body>
  <h1>🔐 Crystal Forge — Provably Fair Verifier</h1>
  <p>Verify any past session entirely client-side. No data is sent to any server.</p>

  <h2>Step 1: Verify Server Seed Commitment</h2>
  <label>Revealed Server Seed (after session end):</label>
  <input id="serverSeed" type="text" placeholder="64-char hex string...">

  <label>Commitment (SHA-256, shown at session start):</label>
  <input id="commitment" type="text" placeholder="64-char hex string...">

  <button onclick="verifyCommitment()">Verify Commitment</button>
  <div id="commitmentResult" class="result"></div>

  <h2>Step 2: Verify Individual Spin</h2>
  <label>Client Seed (active at time of spin):</label>
  <input id="clientSeed" type="text" placeholder="64-char hex string...">

  <label>Spin Nonce:</label>
  <input id="spinNonce" type="number" value="1" min="1">

  <label>Reel Sizes (comma-separated):</label>
  <input id="reelSizes" type="text" value="32,32,32,32,32">

  <label>Declared Stops from spin response (comma-separated):</label>
  <input id="declaredStops" type="text" placeholder="14,7,22,3,18">

  <button onclick="verifySpin()">Verify Spin</button>
  <div id="spinResult" class="result"></div>

  <h2>Step 3: Batch Verify from JSON</h2>
  <label>Paste verification JSON (from /api/v1/provably-fair/verify/{sessionId}):</label>
  <textarea id="verificationJson" rows="10" placeholder='{"revealedSeed":"...","spins":[...]}'></textarea>
  <button onclick="verifyBatch()">Verify All Spins</button>
  <div id="batchResult" class="result"></div>

  <script>
    // ── Crypto utilities ──────────────────────────────────────────

    function hexToBytes(hex) {
      const clean = hex.toLowerCase().replace(/[^0-9a-f]/g, '');
      const bytes = new Uint8Array(clean.length / 2);
      for (let i = 0; i < bytes.length; i++)
        bytes[i] = parseInt(clean.substr(i * 2, 2), 16);
      return bytes;
    }

    function bytesToHex(bytes) {
      return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
    }

    async function sha256(bytes) {
      const hash = await crypto.subtle.digest('SHA-256', bytes);
      return bytesToHex(new Uint8Array(hash));
    }

    async function hmacSha256(keyBytes, messageBytes) {
      const key = await crypto.subtle.importKey(
        'raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
      const sig  = await crypto.subtle.sign('HMAC', key, messageBytes);
      return new Uint8Array(sig);
    }

    function unbiasedInt(hashBytes, range) {
      const view      = new DataView(hashBytes.buffer);
      const threshold = (2 ** 32) % range;
      for (let offset = 0; offset + 4 <= hashBytes.length; offset += 4) {
        const value = view.getUint32(offset, true);
        if (value >= threshold) return value % range;
      }
      return view.getUint32(0, true) % range;
    }

    async function deriveStop(seedBytes, clientSeed, nonce, reelIdx, reelSize) {
      const msg   = new TextEncoder().encode(`${clientSeed}:${nonce}:${reelIdx}`);
      const hmac  = await hmacSha256(seedBytes, msg);
      return unbiasedInt(hmac, reelSize);
    }

    // ── Verification functions ─────────────────────────────────────

    async function verifyCommitment() {
      const serverSeed  = document.getElementById('serverSeed').value.trim();
      const commitment  = document.getElementById('commitment').value.trim();
      const el          = document.getElementById('commitmentResult');

      if (!serverSeed || !commitment) {
        el.innerHTML = 'Please enter both values.'; return;
      }

      const seedBytes   = hexToBytes(serverSeed);
      const computed    = await sha256(seedBytes);
      const isValid     = computed === commitment.toLowerCase();

      el.innerHTML = isValid
        ? `<span class="valid">✓ VALID</span><br>
           SHA256("${serverSeed.slice(0,8)}...") = ${computed}`
        : `<span class="invalid">✗ INVALID</span><br>
           Expected: ${commitment.toLowerCase()}<br>
           Computed: ${computed}<br>
           These do not match — the server changed its seed!`;
    }

    async function verifySpin() {
      const serverSeed = document.getElementById('serverSeed').value.trim();
      const clientSeed = document.getElementById('clientSeed').value.trim();
      const nonce      = parseInt(document.getElementById('spinNonce').value);
      const sizes      = document.getElementById('reelSizes').value
                           .split(',').map(s => parseInt(s.trim()));
      const declared   = document.getElementById('declaredStops').value
                           .split(',').map(s => parseInt(s.trim()));
      const el         = document.getElementById('spinResult');

      if (!serverSeed || !clientSeed) {
        el.innerHTML = 'Please complete Step 1 first.'; return;
      }

      const seedBytes = hexToBytes(serverSeed);
      const derived   = [];

      for (let i = 0; i < sizes.length; i++) {
        const stop = await deriveStop(seedBytes, clientSeed, nonce, i, sizes[i]);
        derived.push(stop);
      }

      const isValid = derived.every((s, i) => s === declared[i]);
      const details = derived.map((s, i) =>
        `Reel ${i+1}: derived=${s}, declared=${declared[i]} ${s===declared[i]?'✓':'✗'}`
      ).join('<br>');

      el.innerHTML = `
        <span class="${isValid ? 'valid' : 'invalid'}">
          ${isValid ? '✓ ALL STOPS VERIFIED' : '✗ DISCREPANCY DETECTED'}
        </span><br><br>
        Server seed: ${serverSeed.slice(0,8)}...<br>
        Client seed: ${clientSeed.slice(0,8)}...<br>
        Nonce: ${nonce}<br><br>
        ${details}
      `;
    }

    async function verifyBatch() {
      const json = document.getElementById('verificationJson').value;
      const el   = document.getElementById('batchResult');

      let data;
      try { data = JSON.parse(json); }
      catch { el.innerHTML = 'Invalid JSON.'; return; }

      const seedBytes = hexToBytes(data.revealedSeed);
      const commitmentValid = (await sha256(seedBytes)) === data.commitment.toLowerCase();

      let results = [`Commitment: ${commitmentValid ? '✓ Valid' : '✗ INVALID'}<br>`];
      let invalid = 0;

      for (const spin of data.spins) {
        const derived = [];
        for (let i = 0; i < spin.reelSizes.length; i++) {
          derived.push(await deriveStop(seedBytes, spin.clientSeed,
            spin.spinNonce, i, spin.reelSizes[i]));
        }
        const ok = derived.every((s, i) => s === spin.declaredStops[i]);
        if (!ok) invalid++;
        results.push(
          `Nonce ${spin.spinNonce}: ${ok ? '✓' : '✗ FAIL'} ` +
          `[${derived.join(',')}] vs [${spin.declaredStops.join(',')}]`
        );
      }

      el.innerHTML = `
        <strong>Results: ${data.spins.length - invalid}/${data.spins.length} valid</strong><br>
        ${results.join('<br>')}
      `;
    }
  </script>
</body>
</html>

Part VII. Security Analysis

7.1 Attack Model and Resistance

Attack 1: Server pre-selects outcome, then generates fake server seed

The server decides the outcome first (always lose), then tries to construct a server seed that produces the desired reel stops with the player's client seed.

Resistance: Impossible by SHA-256 pre-image resistance. Given a desired output from HMAC-SHA256, finding the key (server seed) that produces it requires brute-forcing SHA-256. Cost: 2^256 operations. Computationally infeasible.

Attack 2: Server generates multiple server seeds, picks the one that produces the best outcome for the house

The server generates N server seeds, evaluates each one against the expected client seed, and publishes the commitment of the most favourable one.

Resistance: Partially prevented by timing. The commitment is published before the player provides their client seed (or before their client seed changes for the next spin). If the server generates the commitment before knowing the client seed, it cannot cherry-pick.

However: if the server can observe the client seed before generating the commitment (e.g., in a broken implementation where the sequence is wrong), this attack is feasible. Protocol correctness matters. The commitment must be published before the player provides any seed that will be used in combination with it.

Implementation protection:

// ✓ CORRECT: Commitment published before client seed is known
var pf = ProvablyFairSession.Create(sessionId);  // Generates server seed + commitment
await database.SaveCommitmentAsync(pf.Commitment); // Persisted
await SendCommitmentToPlayer(pf.Commitment);       // Published to player

// Later: player provides client seed
pf.UpdateClientSeed(playerProvidedSeed);           // Only now is client seed known

Attack 3: Client uses a predictable client seed

If the client generates its seed with a weak RNG (e.g., Math.random()), the server can predict the client seed in advance and bias the server seed selection.

Mitigation: The server should always use crypto.subtle.getRandomValues() for the server-generated default client seed, document that players should also use CSPRNG for custom seeds, and optionally provide server-side entropy mixing.

Attack 4: Replay attack — reusing a nonce

If the same (server seed, client seed, nonce) triple is used twice, the outcomes are identical. An attacker who knows an outcome was going to be favourable could try to replay it.

Resistance: The nonce is strictly monotonically increasing per session and stored persistently. It is impossible for the same nonce to be used twice within the same session. The nonce is updated to persistent storage before the spin response is sent.

Attack 5: Commitment is changed after publication

The server publishes commitment C, then later the player's session looks up the commitment and it has been changed to C'.

Resistance: The commitment is immutable once published. It is stored in the database with a created-at timestamp and no update path. The audit log records the commitment at the time of each spin — any discrepancy between the stored commitment and the audit log is immediately detectable.

7.2 The Nonce Must Be Persisted Before Outcome Use

A subtle but critical ordering constraint:

// ❌ WRONG ORDER: nonce incremented AFTER outcome is committed to response
int[] stops = pfSession.GenerateReelStops(config.ReelSizes);
// pfSession.SpinNonce is now N+1 (internally)

var outcome = engine.Evaluate(stops);           // Win calculated
var response = BuildResponse(outcome);          // Response prepared
await SendResponseToClient(response);           // Response sent

await pfRepo.UpdateNonceAsync(sessionId, N+1); // Nonce saved LAST
// If server crashes here: nonce is still N in database.
// Player reconnects, same nonce N is used again.
// Outcome is different (no client seed change) = same stops → same outcome.
// Player saw different outcome on first load. DISCREPANCY.

// ✓ CORRECT ORDER: nonce persisted BEFORE response is used
int[] stops = pfSession.GenerateReelStops(config.ReelSizes);

await pfRepo.UpdateNonceAsync(sessionId, pfSession.SpinNonce); // FIRST
// If server crashes here: nonce is N+1 in database.
// Player reconnects, nonce is N+1. The spin at nonce N is lost.
// But: there's a pending spin record showing stops for nonce N.
// Recovery: use the stored stops from the verification record.

var outcome  = engine.Evaluate(stops);
var response = BuildResponse(outcome);
await SendResponseToClient(response);

Part VIII. Provably Fair and Regulatory Certification

8.1 The Relationship Between the Two Systems

Provably Fair and traditional certification address overlapping but distinct concerns:

Provably Fair

Regulatory Certification

Outcome was determined by
the committed server seed
before the player bet

Server cannot change its
seed retroactively

Both seeds contributed to the
outcome

Player can self-verify
any past spin

The RNG output is statistically
indistinguishable from random


The game's RTP matches its
documented pay table

The game's audit logs enable
complete spin reconstruction

The RNG algorithm is reviewed
by an independent laboratory

Does NOT prove:
RTP is correct
Pay table is fair
RNG algorithm is sound

Does NOT prove:
Player can independently verify
any individual outcome
(requires trust in institutions)

8.2 Regulatory Acceptance of Provably Fair

The regulatory picture varies by jurisdiction:

UK (UKGC): No specific Provably Fair requirement or prohibition. A Provably Fair layer does not reduce the certification requirements — the game still needs GLI or BMM certification. Some operators use Provably Fair as an additional transparency feature.

Malta (MGA): Similar to UK. Provably Fair is voluntary but not a substitute for certification.

Netherlands (KSA): No specific Provably Fair requirements. The KSA's focus is on Net Win Rate and player protection features.

Curaçao: Most crypto-focused operators use Curaçao licences. Provably Fair is common in this space, sometimes as the primary trust mechanism in lieu of traditional certification.

Consensus view: Provably Fair and traditional certification are complementary. Using both gives the strongest possible trust proposition: the game is independently certified AND players can self-verify individual outcomes.

8.3 What the Certification Lab Makes of Provably Fair

When GLI or BMM reviews a Provably Fair slot, their additional checks are:

Does the HMAC derivation match the documented algorithm exactly? They compute HMAC-SHA256 for test vectors and verify the game produces the same stops.

Is the server seed generated from a certified entropy source? The CSPRNG used to generate the server seed must meet the same GLI-11 requirements as the primary game RNG.

Is the commitment published before the client seed is provided? They verify the protocol sequence through audit log inspection.

Is the nonce persisted before the outcome is consumed? They test crash recovery scenarios.

Can the verification algorithm in the player-facing code be independently reproduced? They run the documented verification algorithm on known test vectors.


Part IX. Testing the Provably Fair System

9.1 Unit Tests with Known Test Vectors

[TestClass]
public class ProvablyFairTests
{
    // Test vector: known inputs → known outputs
    // These must be published in documentation so players can verify the algorithm

    private const string TestServerSeedHex =
        "b94f6f125c79e3a5ffaa826f584c10d7cc3b2d13f2f3b813e0c42c3697f9f21a";
    private const string TestClientSeed    =
        "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
    private const long   TestNonce         = 1;

    [TestMethod]
    public void DeriveStop_KnownTestVector_ReelSize32()
    {
        byte[] serverSeedBytes = Convert.FromHexString(TestServerSeedHex);
        byte[] keyBytes        = serverSeedBytes;

        string message    = $"{TestClientSeed}:{TestNonce}:0";
        byte[] msgBytes   = Encoding.UTF8.GetBytes(message);
        byte[] hmac       = HMACSHA256.HashData(keyBytes, msgBytes);

        int stop = ProvablyFairSession.TestDeriveUnbiasedInt(hmac, 32);

        // Pre-computed expected value for this test vector:
        // HMAC-SHA256(key=TestServerSeed, msg="TestClientSeed:1:0")
        // First 4 bytes (little-endian uint32) mod 32 — no rejection needed
        Assert.AreEqual(14, stop,   // expected stop position
            "Reel 0 stop position mismatch for test vector.");
    }

    [TestMethod]
    public void CommitmentVerification_MatchingSeeds_ReturnsTrue()
    {
        var session = ProvablyFairSession.Create("test-session");
        string revealed = session.RevealServerSeed();

        bool isValid = ProvablyFairSession.VerifyCommitment(
            revealed, session.Commitment);

        Assert.IsTrue(isValid, "Commitment should match revealed seed.");
    }

    [TestMethod]
    public void CommitmentVerification_TamperedSeed_ReturnsFalse()
    {
        var session = ProvablyFairSession.Create("test-session");
        string revealed = session.RevealServerSeed();

        // Tamper: change last character
        string tampered = revealed[..^1] + (revealed[^1] == 'a' ? 'b' : 'a');

        bool isValid = ProvablyFairSession.VerifyCommitment(
            tampered, session.Commitment);

        Assert.IsFalse(isValid, "Tampered seed should not verify.");
    }

    [TestMethod]
    public void GenerateStops_ThenVerify_StopsMatch()
    {
        var pf         = ProvablyFairSession.Create("test-session");
        var reelSizes  = new[] { 32, 32, 32, 32, 32 };
        long nonce     = pf.SpinNonce;
        string cs      = pf.ClientSeed;

        int[] stops    = pf.GenerateReelStops(reelSizes);

        // Reveal
        string revealed = pf.RevealServerSeed();

        // Independent verification
        int[] derived  = ProvablyFairSession.DeriveStopsForSpin(
            revealed, cs, nonce, reelSizes);

        CollectionAssert.AreEqual(stops, derived,
            "Derived stops must match original stops.");
    }

    [TestMethod]
    public void NonceIncrements_AfterEachSpin()
    {
        var pf = ProvablyFairSession.Create("test-session");
        Assert.AreEqual(1L, pf.SpinNonce);

        pf.GenerateReelStops(new[] { 32, 32, 32, 32, 32 });
        Assert.AreEqual(2L, pf.SpinNonce);

        pf.GenerateReelStops(new[] { 32, 32, 32, 32, 32 });
        Assert.AreEqual(3L, pf.SpinNonce);
    }

    [TestMethod]
    [ExpectedException(typeof(InvalidOperationException))]
    public void GenerateStops_AfterReveal_Throws()
    {
        var pf = ProvablyFairSession.Create("test-session");
        pf.RevealServerSeed();

        pf.GenerateReelStops(new[] { 32, 32, 32, 32, 32 });
        // Should throw: cannot generate after reveal
    }

    [TestMethod]
    public void ClientSeedChange_DoesNotAffectPreviousSpins()
    {
        var pf = ProvablyFairSession.Create("test-session");
        string originalClientSeed = pf.ClientSeed;
        var    reelSizes          = new[] { 32, 32, 32, 32, 32 };

        long   nonce1 = pf.SpinNonce;
        int[]  stops1 = pf.GenerateReelStops(reelSizes);  // Nonce 1

        pf.UpdateClientSeed(
            "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");

        long   nonce2 = pf.SpinNonce;
        int[]  stops2 = pf.GenerateReelStops(reelSizes);  // Nonce 2, new seed

        string revealed = pf.RevealServerSeed();

        // Verify spin 1 with original client seed
        int[] derived1 = ProvablyFairSession.DeriveStopsForSpin(
            revealed, originalClientSeed, nonce1, reelSizes);
        CollectionAssert.AreEqual(stops1, derived1,
            "Spin 1 must verify with original client seed.");

        // Verify spin 2 with new client seed
        int[] derived2 = ProvablyFairSession.DeriveStopsForSpin(
            revealed, pf.ClientSeed, nonce2, reelSizes);
        CollectionAssert.AreEqual(stops2, derived2,
            "Spin 2 must verify with new client seed.");

        // Spin 1 must NOT verify with new seed (different derivation)
        int[] wrong1 = ProvablyFairSession.DeriveStopsForSpin(
            revealed, pf.ClientSeed, nonce1, reelSizes);

        // They might coincidentally match, but it's very unlikely
        // (probability 1/(32^5) ≈ 0.0003%)
        // This test verifies that the wrong seed changes the derivation
        bool allMatch = stops1.Zip(wrong1).All(pair => pair.First == pair.Second);
        // Don't assert inequality (could be coincidental) — just verify the algorithm
        // produces different inputs
        string msg1 = $"{originalClientSeed}:{nonce1}:0";
        string msg2 = $"{pf.ClientSeed}:{nonce1}:0";
        Assert.AreNotEqual(msg1, msg2, "Different seeds must produce different messages.");
    }

    [TestMethod]
    public void UnbiasedInt_AllValuesEquallyLikely_ChiSquare()
    {
        // Statistical test: does DeriveUnbiasedInt produce uniform output?
        const int reelSize  = 32;
        const int samples   = 320_000;
        var       observed  = new long[reelSize];

        for (int i = 0; i < samples; i++)
        {
            byte[] randomBytes = RandomNumberGenerator.GetBytes(32);
            int    stop        = ProvablyFairSession.TestDeriveUnbiasedInt(
                randomBytes, reelSize);
            observed[stop]++;
        }

        double expected  = (double)samples / reelSize;
        double chiSquare = observed.Sum(o => Math.Pow(o - expected, 2) / expected);

        // Chi-square with df=31, alpha=0.01: critical value = 50.89
        // If chiSquare > 50.89, distribution is not uniform at 99% confidence
        Assert.IsTrue(chiSquare < 50.89,
            $"Chi-square = {chiSquare:F2}, expected < 50.89. " +
            "DeriveUnbiasedInt may not be producing uniform output.");
    }
}

Summary

Provably Fair is not a marketing feature - it is a cryptographic proof of fairness that fundamentally changes the trust relationship between a game provider and its players. When implemented correctly, it allows any player to verify independently, without trusting any institution, that every spin they received was determined by a commitment the server made before the bet was placed and could not have been changed after.

The key implementation principles from this article:

The commitment is published before the client seed is known. This ordering is the entire security guarantee. A system where the server generates the commitment after knowing the client seed is not Provably Fair - it is theatrical Provably Fair with no actual security property.

HMAC-SHA256 is the right primitive. Plain SHA-256 of a concatenation is not equivalent - HMAC provides a proper keyed construction where knowing the output for many messages reveals nothing about the key (server seed).

The nonce must be persisted before the outcome is consumed. Any crash between generating stops and persisting the nonce creates an opportunity for nonce reuse. Persist first, use second.

The verification algorithm must be public, documented, and independently reproducible. The standalone verification page — no dependencies, no network requests, copy-paste into any browser - is the gold standard. If players cannot verify without trusting your infrastructure, you have not implemented Provably Fair.

Provably Fair and regulatory certification are complementary. One proves individual outcomes; the other proves the mathematical model. Both are needed for the strongest possible trust proposition. Neither substitutes for the other.

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

Policy