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:
- The Server Cannot Cheat: The server cannot alter the dice roll after seeing the player's board state or tactical decisions.
- 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
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
}
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 };
}
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)