Back to Blog
lootbalancingrngitems

Drop Rates That Feel Fair: Weighted Loot Tables and Bad-Luck Protection

A 20% drop rate leaves 1 in 9 players empty-handed after ten kills. Here is how to design the tail instead of the average: single weighted tables, pity counters, escalating chances and a simulation you can run before you ship.

GameDesignerX TeamSeptember 15, 20267 min read

A playtester messages you at 11pm: "Your boar drop is broken." It isn't. You set the fang to 20%, they killed nine boars, they got nothing. The code is fine. The design is not, because you balanced the average and shipped the tail.

This post is about the other half of loot design — the half that decides whether a drop rate feels generous, fair, or rigged. It covers weighted tables, bad-luck protection, duplicate handling, and how to simulate all of it before a player ever swings a sword.

The average is the least interesting number

An independent roll at probability p produces a geometric distribution, and geometric distributions have long, ugly tails. At a 20% drop rate the average player needs five kills. But:

Attempts Chance of still having nothing (p = 20%) (p = 5%)
5 32.8% 77.4%
10 10.7% 59.9%
20 1.2% 35.8%
50 ~0% 7.7%

Read the 20% column as player counts, not percentages. If 1,000 people fight that boar, roughly 107 of them will be empty-handed after ten kills, and about 12 will still be empty-handed after twenty. Those dozen players are the ones who write the Steam review, and from inside their session the game is provably broken.

Two numbers are worth pinning to your balance sheet for every random reward:

  • P50 — the median number of attempts. At 20%, that's 4 (the first n where the miss chance drops below 50%).
  • P95 — the unlucky-player number. At 20%, that's 14 attempts. 1 in 20 players will grind at least that long.

Design the encounter around P95. If fourteen boar fights at ninety seconds each is thirty minutes of nothing, the rate is wrong no matter how nice the average looks.

One roll, one table

A common early mistake is rolling each item independently: 20% fang, 10% hide, 5% tusk. Now the possible outcomes multiply, "nothing" has its own silent probability (68.4% here), and you cannot reason about the result without a calculator.

Roll once into a weighted table instead. Weights as integers out of a round total are easier to tune and easier to explain to a non-programmer:

Outcome Weight Share
Nothing 400 40.0%
Boar fang (common) 400 40.0%
Thick hide (uncommon) 150 15.0%
Cracked tusk (rare) 48 4.8%
Tusk of the Herd (legendary) 2 0.2%
function roll(table) {
  const total = table.reduce((s, e) => s + e.weight, 0);
  let r = Math.random() * total;
  for (const entry of table) if ((r -= entry.weight) < 0) return entry.id;
}

Three properties make this worth the refactor. The shares always sum to 100%, so there is no hidden "nothing" branch. Adding an item forces you to take weight from something else, which is exactly the conversation you want to have. And you can nest tables — a rare entry that points at a second table of eight rare items — so you tune how often rare happens separately from which rare it is. Keep those tables somewhere your whole team can read them; the items and balance sheets in GameDesignerX exist for this, but a shared spreadsheet with a column for weight and a column for derived percentage does the same job.

Bad-luck protection, three ways

Once you accept that the tail is the problem, you are no longer designing a probability. You are designing a distribution. Three techniques, in increasing order of effort:

1. Hard pity

Count failures. At N failures, force the drop and reset the counter. A 5% rate with hard pity at 40 is not a 5% rate — the true average drops to about 17 attempts — but the worst case is now a number you chose rather than a number the RNG chose. This is the cheapest fix and it should be your default for anything gated behind a long activity: raid bosses, crafting materials, story-critical keys.

2. Escalating chance

Start low and ramp the rate with every failure. A legendary that begins at 1% and gains 1% per miss has an expected wait of about 12 attempts, a guaranteed drop by the 14th for most players (33% are still empty at 14), and a hard ceiling at 100 attempts where the chance hits certainty. Early rolls stay genuinely exciting because they are genuinely rare; late rolls stay bearable because the ramp is doing the work. Reset the counter on success.

3. Pseudo-random distribution

This is the technique behind crit and proc chances in Warcraft III and Dota 2. Instead of a fixed p, the chance on the nth consecutive failure is C × n, where C is a constant chosen so the long-run average equals your advertised rate. Because the chance climbs after every miss and resets after every hit, both tails get squeezed: long droughts become impossible past a hard bound, and long streaks become much rarer than under a flat roll. The constant has to be solved numerically for each target rate, so it's more work than the ramp, but it's the right tool for high-frequency, combat-feel randomness where streaks read as "the game is lying to me."

Use PRD for things that happen every few seconds. Use hard pity or an escalating ramp for things that happen every few hours. Don't use any of them for the 40% "common junk" tier — variance there is free flavour.

And handle duplicates

If your reward pool can hand back something the player already owns, decide what a duplicate means before launch. The usual answers: remove the item from the pool once owned (cleanest, but the pool shrinks and the last item becomes brutal), or convert the duplicate into a currency that buys any item outright. The second option quietly turns your random system into a deterministic one for committed players, which is usually what you want.

Simulate before you ship

You do not need a live build to test this. Thirty lines of JavaScript will tell you what ten thousand players experience:

function simulate(runs, attempt) {
  const results = [];
  for (let i = 0; i < runs; i++) {
    let n = 0, fails = 0;
    while (true) { n++; if (attempt(fails)) break; fails++; }
    results.push(n);
  }
  results.sort((a, b) => a - b);
  return {
    p50: results[Math.floor(runs * 0.5)],
    p95: results[Math.floor(runs * 0.95)],
    worst: results[runs - 1],
  };
}

// flat 5% vs. 1% escalating by 1% per failure
simulate(10000, () => Math.random() < 0.05);
simulate(10000, (f) => Math.random() < Math.min(1, 0.01 * (f + 1)));

Run it, multiply P95 by the real length of one attempt, and look at the minutes. That number — not the drop rate — is the thing you are actually shipping. Record the result next to the item so the next person to touch the table knows what the rate was chosen to produce.

A checklist before you lock a reward table

  • Every random reward has a documented P50 and P95, converted into minutes of play, not attempts.
  • One roll per drop event, into a single weighted table whose weights sum to a round number.
  • "Nothing" is an explicit row in the table, not an implicit leftover.
  • Anything that takes more than ten minutes per attempt has pity, a ramp, or a deterministic alternative.
  • Duplicates have a defined conversion, or the item leaves the pool.
  • Nested tables separate rarity frequency from item selection, so you can add items without re-tuning rarity.
  • A seeded simulation run is saved alongside the table, so a rate change gets re-simulated instead of re-argued.
  • Anything story-critical is not random at all.

The last line is the one that saves projects. Randomness is a pacing tool for optional rewards. The moment a required item is behind a dice roll, you have handed the RNG authority over whether your game is completable — and the boar will win that argument more often than you think.