Why you'd switch — and why you might not
Math.random() is fast, free, and synchronous. Its outcome is also impossible for anyone outside your server to audit. crypto.randomInt upgrades you to a cryptographic PRNG, but doesn't change that audit story at all — neither call leaves a paper trail.
Provable.io gives you a per-outcome serverHash a player or auditor can later check against the live seed state or a daily Merkle root. That's the only thing it gives you that the standard library doesn't. If "I trust me" is a valid threat model for your app (a single-player toy, a coin-flip prank), don't migrate. If your users have money or pride on the line, this guide is for you.
The three patterns you'll port
1. A range int
// Before
const sides = 6;
const roll = 1 + Math.floor(Math.random() * sides);
// After
const res = await fetch(
`https://api.provable.io/api/ints?clientSeed=${encodeURIComponent(playerId)}&count=1&min=1&max=${sides}`,
{ headers: { "x-api-key": process.env.PROVABLE_API_KEY } },
);
const { outcome, serverHash, shortId, permalink } = await res.json();
const roll = outcome[0];
Persist serverHash and permalink alongside the roll. That's the part your player can audit. Try the underlying call live:
curl "https://api.provable.io/api/ints?clientSeed=player_42&count=1&min=1&max=6"
2. A weighted pick
// Before — homegrown weighted pick
function weightedPick(items, weights) {
const total = weights.reduce((s, w) => s + w, 0);
let r = Math.random() * total;
for (let i = 0; i < items.length; i++) {
r -= weights[i];
if (r < 0) return items[i];
}
}
// After — one call to /api/pick
const url = new URL("https://api.provable.io/api/pick");
url.searchParams.set("clientSeed", `loot:${dropId}`);
url.searchParams.set("items", JSON.stringify(items));
url.searchParams.set("weights", JSON.stringify(weights));
const { outcome, index, serverHash } = await (await fetch(url, {
headers: { "x-api-key": process.env.PROVABLE_API_KEY },
})).json();
The server applies the same mathematically-correct weighted selection — no rounding bugs, no modulo-bias surprises — and it's verifiable. Try it:
curl "https://api.provable.io/api/pick?clientSeed=loot:drop-42&items=%5B%22common%22%2C%22rare%22%2C%22legendary%22%5D&weights=%5B70%2C25%2C5%5D"
3. A shuffle
// Before — Fisher-Yates with Math.random
function shuffle(deck) {
for (let i = deck.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[deck[i], deck[j]] = [deck[j], deck[i]];
}
return deck;
}
// After — /api/shuffle returns the shuffled array directly
const url = new URL("https://api.provable.io/api/shuffle");
url.searchParams.set("clientSeed", `table-${tableId}:hand-${handNumber}`);
url.searchParams.set("items", JSON.stringify(deck));
const { outcome: shuffled, serverHash } = await (await fetch(url, {
headers: { "x-api-key": process.env.PROVABLE_API_KEY },
})).json();
One round trip, an entire shuffled deck, one serverHash covering the whole permutation. Shuffle a tiny deck right now:
curl "https://api.provable.io/api/shuffle?clientSeed=table-1:hand-1&items=%5B%22A%E2%99%A0%22%2C%22K%E2%99%A0%22%2C%22Q%E2%99%A0%22%2C%22J%E2%99%A0%22%2C%2210%E2%99%A0%22%5D"
Behavioral differences to plan for
Math.random() / crypto.randomInt | Provable.io | |
|---|---|---|
| Latency | ~0 (in-process) | ~50-200ms (network) |
| Sync / async | Sync | Async — every call is HTTP |
| Reproducible | No | Yes, given the inputs |
| Verifiable by others | No | Yes via /api/verifyServerHash |
| Failure modes | None | HTTP errors, rate limits, quota |
| Quota | Infinite | Per-account daily limit |
The async + latency item is the big one. Don't call Provable.io inside a render hot path; do it on the server, when the round transitions, and stream / push the result to the client.
Phase the change in behind a feature flag
Big-bang flips are scary. A safer rollout:
- Extract a single seam. Every
Math.random()use site routes through a thin wrapper —random.rangeInt(min, max, ctx),random.pick(items, weights, ctx),random.shuffle(items, ctx). Thectxcarries the logical id you'd use as aclientSeed(player id, round id, table id). - Two implementations. The legacy one keeps calling
Math.random(). The new one calls Provable.io. - Feature-flag the switch per code path. Start with the lowest-stakes path (cosmetic shuffles, dev tools), then bigger ones, then the headline use case.
- Shadow it before flipping. When the flag is off, still fire the Provable.io call in the background and log latency / error rate. This catches quota, network, and rate-limit issues before they hit users.
- Use test-mode keys in CI. See Test mode vs live mode — your snapshot tests stay deterministic.
Reference wrapper
// random.js
import { randomInt } from "node:crypto";
const USE_PROVABLE = process.env.RNG_BACKEND === "provable";
const KEY = process.env.PROVABLE_API_KEY;
export async function rangeInt(min, max, ctx) {
if (!USE_PROVABLE) return randomInt(min, max + 1);
const url = new URL("https://api.provable.io/api/ints");
url.searchParams.set("clientSeed", ctx);
url.searchParams.set("count", "1");
url.searchParams.set("min", min);
url.searchParams.set("max", max);
const res = await fetch(url, { headers: { "x-api-key": KEY } });
if (!res.ok) throw new Error(`provable: http ${res.status}`);
const { outcome, serverHash, permalink } = await res.json();
return { value: outcome[0], serverHash, permalink };
}
The wrapper's return shape changes between the two backends — that's the point. Code paths that switched have to handle the audit trail; code paths that haven't keep the simple primitive return type. Migrate intentionally.
Things you'll trip over once
- Reusing the same
clientSeedacross unrelated calls. Two calls with the same seed and same parameters return successive draws from the seed chain, which is great for one round but confusing if "round-1" and "everything-else" share a seed. - Forgetting to use the test key in CI. Your unit tests will burn your daily live quota without it.
- Treating an HTTP failure as if the draw didn't happen. It might have — see Using
Idempotency-Keyto make retries safe.