The setup
A loot table maps outcomes to weights. A common pattern:
const LOOT_TABLE = [
{ item: "Common Sword", weight: 600 }, // 60%
{ item: "Uncommon Shield", weight: 300 }, // 30%
{ item: "Rare Potion", weight: 90 }, // 9%
{ item: "Epic Helmet", weight: 9 }, // 0.9%
{ item: "Legendary Artifact", weight: 1 }, // 0.1%
];
Weights don't have to sum to 100 or 1000 — they're relative.
The algorithm
- Sum all weights →
total. - Pull one random integer in
[1, total]from the API. - Walk the table accumulating weights until you pass the random value.
async function pullLoot(clientSeed, table) {
const total = table.reduce((s, e) => s + e.weight, 0);
const url = new URL("https://api.provable.io/api/ints");
url.searchParams.set("clientSeed", clientSeed);
url.searchParams.set("count", "1");
url.searchParams.set("min", "1");
url.searchParams.set("max", String(total));
const res = await fetch(url, {
headers: { "x-api-key": process.env.PROVABLE_KEY }
});
const { outcome, serverHash } = await res.json();
const roll = outcome[0];
let cumulative = 0;
for (const entry of table) {
cumulative += entry.weight;
if (roll <= cumulative) {
return { item: entry.item, roll, total, serverHash };
}
}
}
console.log(await pullLoot("user_42_pull_001", LOOT_TABLE));
Pulling 10 at once
For a "10x pull" box, request count=10 and walk the table for each roll:
async function pullTen(clientSeed, table) {
const total = table.reduce((s, e) => s + e.weight, 0);
const url = `https://api.provable.io/api/ints?clientSeed=${clientSeed}&count=10&min=1&max=${total}`;
const { outcome } = await fetch(url).then(r => r.json());
return outcome.map(roll => {
let c = 0;
for (const e of table) { c += e.weight; if (roll <= c) return e.item; }
});
}
Why this is provably fair
The split is deterministic — the same roll always maps to the same item. The roll itself comes from a committed serverHash. Publish the table, the seed, and the hash, and a player who pulls a Common can confirm "the API really did roll 437 out of 1000, and 437 falls in the Common Sword band".
Production tips
- Versioned tables. Pin the loot table to a version, log it with the roll, and never silently mutate weights — that breaks reproducibility.
- Pity timers. Add a counter alongside the API call (e.g. force an Epic if the user hits 50 pulls without one). The roll stays fair; the pity logic is a deterministic transform on top.
- Per-user seeds. Use the user ID or session as the
clientSeedso pulls don't collide across users.
Next steps
FAQ
Do the weights have to sum to 100?
No. The weights can be any positive integers — the algorithm normalizes them by summing all weights and drawing an integer in [1, total]. Use whatever units make your drop table easy to read.
How does the weighted-draw algorithm work?
Sum the weights to get total, draw one uniform integer in [1, total] from /api/ints, then walk the weight table accumulating until the running total covers the drawn number. That item is the result, and the math is fully reproducible.
How do I do a 10x pull?
Call /api/ints once with count=10, min=1, max=sumOfWeights. You get ten independent draws in a single response — same serverHash anchors all ten outcomes for verification.
Can I add pity timers without breaking fairness?
Yes — publish the pity rule alongside the weight table. The base draw stays verifiable, and players can independently confirm both the random roll and the deterministic pity upgrade.