DEV Community

Payam ghaderkourehpaz
Payam ghaderkourehpaz

Posted on

Eliminating Server Dice Skepticism: Cryptographic Commit-Reveal & CSPRNG Verification in Real-Time Web Games

In multiplayer turn-based gaming, especially games driven by stochastic mechanics like Backgammon or digital tabletop titles, one recurring friction point dominates player communities: the perception that the dice or random events are rigged.

Cognitive biases (confirmation bias, loss aversion, and streak salience) lead even seasoned players to believe that server-side algorithms selectively generate rolls to favor the computer AI, prolong games, or reward paying users. In fact, in traditional web implementations where dice rolls are generated server-side using standard random functions and pushed over WebSockets, the player has zero cryptographic capability to verify whether a clutch double-six was genuine or synthesized by the server.

To eliminate this skepticism entirely, modern web game architectures must shift from opaque randomness to Provably Fair Commit-Reveal Systems.

In this article, we’ll break down the architectural blueprint for implementing a zero-overhead, provably fair dice pipeline using CSPRNG seed generation, SHA-256 commitments, and HMAC client entropy blending, based on our real-world production stack running in Boardgammon (which you can test live on the web client at boardgammon.com/play).


1. Architectural Overview: How Commit-Reveal Works

A commit-reveal protocol guarantees two invariants:

  1. The Server Cannot Cheat: The server cannot alter the dice roll after seeing the player's board state or tactical decisions.
  2. The Client Cannot Cheat: The player cannot manipulate or predict future rolls to gain an unfair advantage.
+-------------------------------------------------------------------+
|                        MATCH INITIALIZATION                       |
+-------------------------------------------------------------------+
  Server generates Secret Server Seed (S_server) via CSPRNG
  Server computes: Commitment Hash H = SHA256(S_server)
  Server transmits H to Client *before* game start
                     |
                     v
+-------------------------------------------------------------------+
|                        PER-TURN ROLL DERIVATION                   |
+-------------------------------------------------------------------+
  Client sends: Client Seed (S_client) + Turn Nonce (N)
  Deterministic Derivation:
    RawBytes = HMAC-SHA256(Key: S_server, Message: S_client + ":" + N)
    Die1 = (Bytes[0..3] mod 6) + 1
    Die2 = (Bytes[4..7] mod 6) + 1
  Server sends Die1, Die2 to Client
                     |
                     v
+-------------------------------------------------------------------+
|                        POST-MATCH AUDIT & REVEAL                  |
+-------------------------------------------------------------------+
  Match concludes -> Server reveals S_server
  Client verifies:
    1. SHA256(S_server) == H  (Confirms server never altered its seed)
    2. Re-computes all turn rolls from S_server + S_client + N
Enter fullscreen mode Exit fullscreen mode

By decoupling the commitment (published beforehand) from the revelation (published after the actions are locked in), neither side can tamper with the outcome.


2. Server Implementation: Cryptographic Seed Generation (Go)

On the backend, pseudo-random generators like math/rand are completely unsuitable because their internal state can be reconstructed after observing a short sequence of outputs. Instead, we use crypto/rand to draw entropy directly from the OS kernel CSPRNG (/dev/urandom or getrandom(2)).

Here is a streamlined Go service implementation:

package fairdice

import (
    "crypto/hmac"
    "crypto/rand"
    "crypto/sha256"
    "encoding/binary"
    "encoding/hex"
    "fmt"
)

type MatchFairness struct {
    ServerSeed string // 256-bit secret hex string
    CommitHash string // SHA-256 of ServerSeed
    ClientSeed string // Entropy provided by client
    Nonce      uint64 // Incremented per roll
}

// NewMatchFairness initializes a new provably fair match context
func NewMatchFairness(clientSeed string) (*MatchFairness, error) {
    seedBytes := make([]byte, 32)
    if _, err := rand.Read(seedBytes); err != nil {
        return nil, fmt.Errorf("failed to read secure entropy: %w", err)
    }
    serverSeed := hex.EncodeToString(seedBytes)

    // Compute public commitment: SHA256(ServerSeed)
    hash := sha256.Sum256([]byte(serverSeed))
    commitHash := hex.EncodeToString(hash[:])

    return &MatchFairness{
        ServerSeed: serverSeed,
        CommitHash: commitHash,
        ClientSeed: clientSeed,
        Nonce:      0,
    }, nil
}

// Roll derives deterministic dice values for the current turn
func (m *MatchFairness) Roll() (int, int) {
    m.Nonce++

    // Message payload: ClientSeed:Nonce
    msg := fmt.Sprintf("%s:%d", m.ClientSeed, m.Nonce)

    // HMAC-SHA256(ServerSeed, Message)
    mac := hmac.New(sha256.New, []byte(m.ServerSeed))
    mac.Write([]byte(msg))
    digest := mac.Sum(nil)

    // Extract two independent 32-bit integers
    val1 := binary.BigEndian.Uint32(digest[0:4])
    val2 := binary.BigEndian.Uint32(digest[4:8])

    die1 := int(val1%6) + 1
    die2 := int(val2%6) + 1

    return die1, die2
}
Enter fullscreen mode Exit fullscreen mode

3. Client Verification via the Web Cryptography API (TypeScript)

When the match finishes, the server reveals S_server. The web client can perform verification client-side without relying on third-party libraries using the native window.crypto.subtle API:

export interface AuditParams {
  serverSeed: string;
  commitHash: string;
  clientSeed: string;
  turnCount: number;
}

export async function verifyMatchFairness(params: AuditParams): Promise<{
  isValidCommitment: boolean;
  rolls: Array<{ turn: number; dice: [number, number] }>;
}> {
  const enc = new TextEncoder();

  // 1. Verify Commitment Hash: SHA-256(serverSeed) === commitHash
  const seedBytes = enc.encode(params.serverSeed);
  const hashBuffer = await window.crypto.subtle.digest('SHA-256', seedBytes);
  const calculatedCommit = Array.from(new Uint8Array(hashBuffer))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('');

  const isValidCommitment = calculatedCommit.toLowerCase() === params.commitHash.toLowerCase();
  if (!isValidCommitment) {
    throw new Error('Cryptographic commitment failure! Server seed does not match original hash.');
  }

  // 2. Import Server Seed as HMAC Key
  const hmacKey = await window.crypto.subtle.importKey(
    'raw',
    seedBytes,
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  );

  // 3. Reconstruct all dice rolls deterministically
  const rolls: Array<{ turn: number; dice: [number, number] }> = [];

  for (let turn = 1; turn <= params.turnCount; turn++) {
    const message = enc.encode(`${params.clientSeed}:${turn}`);
    const signature = await window.crypto.subtle.sign('HMAC', hmacKey, message);
    const view = new DataView(signature);

    const die1 = (view.getUint32(0, false) % 6) + 1;
    const die2 = (view.getUint32(4, false) % 6) + 1;

    rolls.push({ turn, dice: [die1, die2] });
  }

  return { isValidCommitment, rolls };
}
Enter fullscreen mode Exit fullscreen mode

4. Real-World Production Considerations

When deploying this pipeline into a high-concurrency real-time environment (like our Go + WebSockets server on Boardgammon), several operational lessons emerged:

A. Nonce Drift and Reconnection

If a player briefly disconnects and reconnects via WebSocket, monotonic turn sequence counters must never desync. We tie the nonce strictly to the server-authoritative turn index stored in Postgres/Redis, rather than client-reported tick numbers.

B. Transparent Public Audit Pages

Rather than hiding the math behind a black-box badge, our architecture generates a public verification URL (e.g., /audit/{matchId}) upon match completion. This allows any player to copy the raw JSON payload and independently run verification in their browser console or via curl. We also maintain a full protocol specification at boardgammon.com/fair-dice for full transparency.

C. Zero Impact on Gameplay Latency

Because SHA-256 hashing and HMAC derivation require less than 5 microseconds per call on modern CPUs, calculating rolls through cryptographic HMAC adds literally zero perceptible latency to WebSocket tick loops compared to naive random number generation.


Conclusion

Dice skepticism in online board gaming is not an insurmountable problem; it is simply a symptom of opaque architecture. By implementing a standardized commit-reveal scheme with CSPRNG entropy, you give players mathematically undeniable proof that every roll was fair, unmanipulated, and cast before either player made their move.

Top comments (0)