Provably Fair Dice Roller
2025-11-09
When you roll dice online, you’re often trusting the website not to cheat. But what if you didn’t have to trust anyone — not even the server?
This project implements a provably fair dice roller, where every roll can be verified mathematically. It’s built on simple cryptographic rules that make each result reproducible, testable, and truly random in a uniform sense.
🧩 The Core Mechanism
Every roll depends on three ingredients:
- Server Seed — a secret random string stored privately on the backend.
- Client Seed — a random value generated by the user’s browser.
- Timestamp (used as nonce) — the moment the roll request was made, ensuring uniqueness.
Each roll result is generated using the formula:
SHA256(serverSeed + ":" + clientSeed + ":" + nonce)
Here, the nonce isn’t a separate counter but derived from the timestamp itself. In multi-dice rolls, we increment the timestamp numerically (timestamp + i) to create a deterministic yet distinct nonce for each die. This guarantees every roll is unique, even within the same session.
From that hash, the system converts the first 8 hexadecimal characters to an integer and maps it to the dice sides:
intVal = parseInt(hash.substring(0, 8), 16)
result = (intVal % sides) + 1
Because SHA-256 produces uniformly distributed output, this modulus operation creates statistically uniform results — every side of the die has an equal chance.
🎲 The Roll Flow (Server API)
When you click Roll, the client sends a POST request to /api/roll containing:
{
"clientSeed": "<user-seed>",
"multi": [
{ "diceType": "d6", "count": 2 },
{ "diceType": "d20", "count": 1 }
]
}
Step-by-step breakdown
-
Input validation
The server checks that theclientSeedand dice configuration are valid. -
Timestamp creation
The server records the current timestamp (ISO format). This timestamp will serve as both a verifier reference and a base nonce for all rolls. -
Nonce and hashing
For each die, the server calculates:SHA256(serverSeed + ":" + clientSeed + ":" + (timestampAsNumber + i))The result of this hash determines the outcome via the modulus operation.
-
Result aggregation
The server groups the results by dice type (e.g., alld6together) and flattens them intoallRolls. -
Proof hash generation
Once all dice are rolled, the server computes a final proof hash:SHA256(serverSeed + ":" + clientSeed + ":" + timestamp + ":" + JSON.stringify(allRolls))This is what links the entire batch together — changing even one roll alters the final hash completely.
-
Response
The API returns:{ "groups": [...], "allRolls": [...], "hash": "<proof-hash>", "timestamp": "<iso-timestamp>", "serverSeedHash": "<sha256-of-server-seed>" }
Why this works
Even though the client doesn’t know the real serverSeed, the server provides its SHA-256 hash (serverSeedHash).
This ensures integrity — the server can’t secretly change its seed later without breaking all previous verifications.
✅ The Verification Flow
Verification happens through /api/verify.
Here’s what the client sends back for verification:
{
"hash": "<proof-hash>",
"clientSeed": "<same-seed-used>",
"timestamp": "<original-timestamp>",
"multi": [
{ "diceType": "d6", "count": 2 },
{ "diceType": "d20", "count": 1 }
]
}
Step-by-step breakdown
-
Reconstruction of nonces
The server reuses the same timestamp and increments it as before for each roll. -
Roll regeneration
Using the same deterministic formula:SHA256(serverSeed + ":" + clientSeed + ":" + (timestampAsNumber + i))it reconstructs every single roll exactly as the original server did.
-
Rebuilding the proof hash
The verifier computes:SHA256(serverSeed + ":" + clientSeed + ":" + timestamp + ":" + JSON.stringify(reconstructedRolls))and checks whether it matches the
hashsent by the user. -
Validation result
- If hashes match → ✅ Verified: proof is mathematically consistent.
- If hashes differ → ❌ Invalid: either the data was tampered with, or the timestamp/clientSeed don’t match the original roll.
⚖️ Why It’s Fair
1. Uniform Distribution
SHA-256 outputs are designed to be uniformly distributed across its full 256-bit range. That means, in theory, every possible hash value is equally likely.
When we compute:
intVal = parseInt(hash.substring(0, 8), 16);
result = (intVal % sides) + 1;
we extract 32 bits (8 hex characters) from the hash and take modulo sides.
⚠️ Modulo Bias — The Tiny Imperfection
Since 2^32 isn’t perfectly divisible by most dice sizes, a microscopic bias technically exists.
Example:
2^32 = 4,294,967,296
For a d6: 4,294,967,296 / 6 = 715,827,882 remainder 4
Meaning 4 out of 4.29 billion values slightly favor some sides — a bias of roughly 0.000000093%.
✅ Why It’s Still Fair
- The bias is astronomically small — far smaller than any real-world physical die imperfection.
- SHA-256 ensures that outputs are cryptographically random, so the bias cannot be exploited or predicted.
- Every side has practically equal probability, guaranteeing uniform random distribution for all human and computational intents.
If one wanted to remove even this theoretical bias, rejection sampling or using the entire 256-bit hash could be applied — but this project prioritizes simplicity and performance while staying provably fair.
2. Verifiable Randomness
Since both client and server can reconstruct the exact same sequence using the same seeds and timestamp, the randomness is transparent and provable. No party can retroactively alter or bias the results without breaking the hash proof.
3. Forward Secrecy
The server only publishes the serverSeedHash, not the actual seed. As long as the seed stays secret, future rolls remain unpredictable.
🧮 Example
Suppose:
SERVER_SEED = "secret123"
CLIENT_SEED = "uuid-987"
TIMESTAMP = "2025-11-09T10:00:00.000Z"
The numeric base nonce is 1731146400000.
For a d6 roll with 3 dice:
nonce(1) = 1731146400000
nonce(2) = 1731146400001
nonce(3) = 1731146400002
Each hash produces a deterministic roll between 1–6. You can verify these later using /api/verify with the same parameters — and the same results will reappear.
🧠 Why Limit Roll History to 50
Each entry stores all metadata required for verification — client seed, hash, timestamp, and roll results. To avoid overloading localStorage and degrading browser performance, only the most recent 50 rolls are kept. Older rolls can always be exported manually if players wish to archive them.
💬 Final Thoughts
This project shows that fairness isn’t about trust — it’s about math. By combining timestamp-based nonces with client and server entropy, every roll is both unpredictable and verifiable.
It’s fair not because we say it is, but because anyone can check that it is.
Whether you’re building a tabletop dice simulator or a blockchain game mechanic, this model offers a simple, robust blueprint for provably fair randomness.
Check this project here.
Built with Next.js, crypto, and the love of rolling critical hits.