Back to Blog
VideogamesOctober 20, 202616 min read

Random Isn't Fair: When Your Game Should Cheat With Chance

A 25% crit chance can miss ten times in a row, and players will read it as a bug. Why crits need pseudo-randomness (PRD, shuffle bags, true hit) while loot can stay purely random.

IM
Ignacio MelendezFull-Stack & Game Developer
Random Isn't Fair: When Your Game Should Cheat With Chance

A character has a 25% crit chance. A player tries it out, attacks ten times in a row, and doesn't land a single crit. A report comes in: crits are broken. But the system is working correctly. The problem is that the player hasn't experienced "one in every four" attacks being a crit (even if the math says otherwise).

True randomness has streaks, and we players are terrible at accepting them. Some systems can afford it, like opening a chest and getting nothing "legendary". Others can't: a critical hit, a dodge chance, or a 90% hit chance that misses several times in a row. Those feel like a bug in the game, not bad luck on the player's side.

That's why many games cheat with chance in the player's favor.

1. Randomness and its streaks

A 25% crit chance means the probability of missing ten times in a row is 0.7510≈5.6%0.75^{10} \approx 5.6\%. The math says it can happen, even if it sounds anecdotal. In a real session, though, the player doesn't attack ten times; they attack hundreds of times. Over 200 hits, the probability of at least one streak of ten misses in a row is 96%. It's not that it might happen: it will happen almost every time.

The human brain doesn't handle percentages well. That's where the "gambler's fallacy" comes from: the belief that after many misses, a hit is "due", when every roll is independent of the previous one. The bias works the other way too: two crits in a row feel like a fluke. We expect randomness to spread evenly in the short term, and real randomness does exactly the opposite: it clusters.

A player doesn't judge the probability; they judge the sequence they live through.

Sid Meier talked about this in his GDC 2010 talk on The Psychology of Game Design. In Civilization, when a player lost a battle with 3 to 1 odds in their favor, they felt robbed by the game, even though losing 25% of the time was exactly what the number said. The conclusion of the talk is that the game didn't have to be faithful to the math, but to the player's expectations. From there, the team started tweaking rolls in the player's favor, and little by little the game got closer to what actually felt like "fair randomness".

Civilization V armies facing each other on a hex field, with archer and infantry units around a lake
In Civilization every battle shows its odds before you attack. When a clear advantage ends in defeat, the player blames the game, not the dice. Image: Firaxis Games / 2K.

2. PRD. Lessons from Warcraft III and Dota 2

Warcraft III popularized a technique that Dota 2 later inherited, and still uses for critical hits and most chance-based effects: Pseudo-Random Distribution (PRD). The idea behind it is simple: the probability isn't fixed; it goes up with every miss and resets on a hit. The formula is:

P(N)=C×NP(N) = C \times N

Where NN is the number of attempts since the last hit and CC is a constant. The first attack always has a probability of CC, the second 2C2C, the third 3C3C, and so on until the crit lands and the counter goes back to 1. If C×NC \times N reaches 1, the hit is guaranteed (the same as forcing a 100% chance).

The important part is that CC is not the probability shown on the weapon, the stat sheet, or the spell description. For a 25% crit, CC is roughly 0.0847 (using the same formula as the base). In other words, the first attack only has an 8.5% chance of being a crit, but since the probability keeps growing, in the long run it ends up being exactly 25%. What changes is the distribution: crits are spaced out much more evenly. A table to make it clearer:

Nominal probabilityConstant CCMax attempts without a hit
10%0.014768
15%0.032232
25%0.084712
30%0.11899
50%0.30214

With 25%, the longest possible streak is 11 misses, because on the twelfth attempt the effect is already forced (12×0.0847>112 \times 0.0847 > 1). With pure randomness there's no ceiling, and you could try a thousand times without success (it sounds unbelievable, but that's what the math says).

CC has no closed-form formula. It's computed numerically: for a given CC you get the average number of attempts until a hit, and you adjust CC until the inverse of that average matches the nominal probability. That way, changing the probability is as simple as changing that constant.

PRD also has a side effect that Dota 2 uses on purpose: it slightly punishes whoever makes few "rolls". Since the first hit after a proc has a very low chance, you can't jump into a fight and expect an instant crit. You have to "charge it up", so the outcome depends more on consistency than on the luck of a single isolated hit. Although, as we've said several times, the player can still get lucky and land the crit on the first hit.

A Dota 2 lane fight with Juggernaut spinning with his sword among creeps and enemy heroes
In Dota 2, chance-based crits, evasion and stuns use PRD. In a fight like this, nobody shows up with their crit already charged. Image: Valve.

3. Shuffle bag: Tetris's 7-bag

PRD smooths out a yes or no event: the attack crits, you dodge the hit, the damage is reduced... But when the outcome has several possible results, there's another, more common technique: the Shuffle Bag. Instead of rolling a die every time, you put all the outcomes in a "bag", shake and mix them, and draw results one by one without putting them back. When the bag is empty, you refill it and the process starts over.

One of the best examples is Tetris. Early versions picked each piece independently, so a player could place thirty pieces before seeing a long bar (the I). It wasn't until the early 2000s that Tetris adopted the generator known as the 7-bag: the seven pieces go into a bag, get shuffled, and come out one at a time. That way, every seven pieces the player sees every piece type, without exception.

A Tetris Effect: Connected game with an L piece falling, a long bar on hold and a square as the next piece
Modern Tetris games, like Tetris Effect, show the next piece and let you hold one. With the 7-bag, the long bar never takes more than 12 pieces to come back. Image: Monstars and Resonair / Enhance.

This strategy guarantees that between two identical pieces there are at most 12 pieces of other types (when one comes out first in a bag and the next one comes out last in the following bag). It also ensures you never get more than four S or Z pieces in a row, which are the hardest to place correctly. There's still a sense of randomness, since the player doesn't know what's coming or in what order, but a game can no longer be ruined by "bad luck".

A shuffle bag also works for yes or no events. A bag with 1 hit and 3 misses gives an exact 25% in every batch of four attempts. It's more rigid than PRD (an attentive player can count and therefore "rig their luck"), but it works great for things like NPC dialogue or sound effects, where repeating the same result twice in a row looks bad.

4. Fire Emblem and "true hit"

In the article about Fire Emblem growth rates I explained how a single 0 to 99 roll decides whether a stat goes up. For attacks, the Fire Emblem series implemented something sneakier (since the Game Boy Advance): instead of one roll, it makes two and uses the average. This is what the community calls true hit or the 2RN system.

The effect is that high percentages are higher than they claim to be, and by extension, low ones are lower. The average of two rolls clusters around 50, so the extreme values lose weight. It looks something like this:

Displayed hitActual hit (2RN)
20%8.2%
30%18.3%
50%50.5%
70%82.3%
80%92.2%
90%98.1%

This is what Sid Meier described as "the game adjusting the probability to what the player expects it to mean". For example, an 80% that behaves like a 92%.

The same happens with enemies. Against well-positioned player units, their dodge percentages are low, and the system makes them avoid fewer hits that "shouldn't" miss.

True hit is the most "dishonest" technique of the ones covered here, because the number on the UI isn't the real one. It works in Fire Emblem because the bias always favors the player and almost nobody notices. If the bias worked against the player, it would be indefensible as soon as someone measured it, and in games with active communities there's always someone who measures it.

5. Randomness in loot boxes and drops

With crits, the streak is the enemy. With loot boxes, card packs, and boss and enemy drops, however, the surprise is the product. If the legendary item were guaranteed every twenty packs, the nineteen packs before it would stop mattering and become a mere formality. What makes opening a pack exciting is that the item you want (usually the rarest, lowest-probability one) could show up every single time.

In these cases you don't smooth the distribution; you cut off the tail. Hearthstone is the classic example: every pack has a chance of a legendary card, but if the player has gone 40 packs without one, the next one is guaranteed to have it. Nothing in the game tells you this. In fact, it was the community that discovered it after analyzing and comparing thousands of pack openings. Genshin Impact does the same with its gacha: 0.6% per pull, with the probability shooting up from pull 74 onward (what's known as soft pity) and a guaranteed 5-star on pull 90 (called hard pity).

Without that limit, 0.6% over 90 pulls only gives a 41.8% chance of getting something good. That would mean a large share of players would end up without a good reward after spending a considerable amount. Pity doesn't change what the average player experiences, the one who opens a pack or makes a pull every now and then and keeps playing. This strategy only protects the unluckiest player, who is precisely the one most likely to quit the game out of frustration.

In a crit the streak is a bug, but in a loot box the surprise is the product. Pity doesn't remove it; it just puts a cap on bad luck.

6. When to help chance along

After looking at these four cases, the decision comes down to these questions about the event:

  1. How often is it rolled? If it happens dozens of times per minute (crits, dodges, procs), the player ends up perceiving the sequence and the streaks become noticeable. If it's rolled once per session (a boss drop), each roll is experienced on its own, and every player and run gets a different experience.
  2. Did the player make a decision that depends on the outcome? If they built a crit build or positioned their unit for the 80%, a bad streak punishes their decision and they may feel they've wasted their time. If they're just opening a pack, there's no decision to punish.
  3. Does failing feel like a punishment or like a missing reward? Missing an attack can cost them the game. Not getting the legendary just means they didn't win something rare or extra.
  4. Can the player actually see the streaks? An on-screen crit counter or a roll history makes any clustering jump out. A drop nobody tracks doesn't create a sense of unfairness.

If the answers point to "often, with a decision, punishment, visible", it's worth smoothing the whole distribution with PRD or a shuffle bag. If they point to "rarely, no decision, reward, invisible", pure randomness is best, with pity if the worst case is very painful. And true hit is for when you want the displayed number to mean what the player believes it means, accepting that it's a lie.

7. Implementation in Unity and Unreal

For the implementation, the abstraction is a minimal interface, IRandomSource, that answers a single question: did it happen this time? On top of it there are four interchangeable implementations: pure randomness, PRD, shuffle bag, and a pity counter that wraps any of the others. Gameplay code depends on the interface, so switching a crit from pure randomness to PRD means changing one line.

In Unity, each source receives an IRng from the article about RNG in roguelikes, so it inherits the seeded streams and reproducibility. In Unreal, the native equivalent is FRandomStream, which is already a generator with its own seed.

1public interface IRandomSource
2{
3    bool Roll();
4    void Reset();
5}
6
7public sealed class TrueRandom : IRandomSource
8{
9    private readonly IRng _rng;
10    private readonly float _probability;
11
12    public TrueRandom(IRng rng, float probability)
13    {
14        _rng = rng;
15        _probability = probability;
16    }
17
18    public bool Roll() => _rng.Chance(_probability);
19
20    public void Reset() { }
21}

PRD and computing the constant

PRD needs the constant CC. It can be computed with a binary search: for a candidate CC, you add up the expected number of attempts until the first hit and compare its inverse with the target probability. If it comes out low, you raise CC; if it comes out high, you lower it. Fifty iterations are more than enough precision.

1public static class PrdConstant
2{
3    public static float For(float probability)
4    {
5        if (probability <= 0f) return 0f;
6        if (probability >= 1f) return 1f;
7
8        double low = 0, high = probability;
9        for (int i = 0; i < 50; i++)
10        {
11            double mid = (low + high) / 2;
12            if (ExpectedRate(mid) < probability) low = mid;
13            else high = mid;
14        }
15        return (float)((low + high) / 2);
16    }
17
18    // Resulting average probability when using P(N) = C * N
19    private static double ExpectedRate(double c)
20    {
21        double expectedAttempts = 0, noHitYet = 1;
22        for (int n = 1; noHitYet > 0; n++)
23        {
24            double p = System.Math.Min(1, c * n);
25            expectedAttempts += n * noHitYet * p;
26            noHitYet *= 1 - p;
27        }
28        return 1 / expectedAttempts;
29    }
30}
31
32public sealed class PseudoRandomDistribution : IRandomSource
33{
34    private readonly IRng _rng;
35    private readonly float _c;
36    private int _attempts;
37
38    public PseudoRandomDistribution(IRng rng, float probability)
39    {
40        _rng = rng;
41        _c = PrdConstant.For(probability);
42    }
43
44    public bool Roll()
45    {
46        _attempts++;
47        if (_rng.Chance(Mathf.Min(1f, _c * _attempts)))
48        {
49            _attempts = 0;
50            return true;
51        }
52        return false;
53    }
54
55    public void Reset() => _attempts = 0;
56}

Compute CC once, when the source is created, and never on every roll. If the crit chance changes mid-game (an item, a buff), create a new source or recompute CC at that moment. If there are many different probabilities, you can also precompute a table in the editor and save it as an asset.

Shuffle bag and pity counter

The shuffle bag stores a list of hits and misses, shuffles it with Fisher-Yates, and consumes it one by one. The pity counter is a decorator: it wraps another source and forces a hit when the miss limit is reached. The usual pattern is to combine it with TrueRandom for drops.

1public sealed class ShuffleBag : IRandomSource
2{
3    private readonly IRng _rng;
4    private readonly List<bool> _bag = new();
5    private int _index;
6
7    public ShuffleBag(IRng rng, int hits, int size)
8    {
9        _rng = rng;
10        for (int i = 0; i < size; i++) _bag.Add(i < hits);
11        Reset();
12    }
13
14    public bool Roll()
15    {
16        if (_index >= _bag.Count) Reset();
17        return _bag[_index++];
18    }
19
20    public void Reset()
21    {
22        _rng.Shuffle(_bag);
23        _index = 0;
24    }
25}
26
27public sealed class PityCounter : IRandomSource
28{
29    private readonly IRandomSource _inner;
30    private readonly int _hardPity;
31
32    public int Misses { get; private set; }
33
34    public PityCounter(IRandomSource inner, int hardPity)
35    {
36        _inner = inner;
37        _hardPity = hardPity;
38    }
39
40    public bool Roll()
41    {
42        if (Misses + 1 >= _hardPity)
43        {
44            Reset();
45            return true;
46        }
47
48        bool hit = _inner.Roll();
49        Misses = hit ? 0 : Misses + 1;
50        return hit;
51    }
52
53    public void Reset()
54    {
55        Misses = 0;
56        _inner.Reset();
57    }
58}

Exposing Misses isn't a whim. Many gacha games show the pity counter in the UI. And even if you don't show it, it's worth saving it with the game state, so that if the player quits on pack 39, pack 40 is still the guaranteed one when they come back.

The state of the PRD, the bag, and the pity is part of the save. If it isn't serialized alongside the RNG state (as explained in the roguelikes article), loading a save resets the streaks and breaks reproducibility: same seed, different crits.

Using it in gameplay

With everything in place, the combat code doesn't know what kind of randomness it's using. In Unity, the component asks the RngFactory for its stream and creates the source in Awake. In Unreal, a UActorComponent derives its FRandomStream from the root seed with the same per-domain XOR technique, and creates the source in BeginPlay. That way everything is abstracted, and you can update it at any time without having to "redo" everything.

1public class CritSystem : MonoBehaviour
2{
3    [SerializeField, Range(0f, 1f)] private float critChance = 0.25f;
4    [SerializeField] private float critMultiplier = 2f;
5
6    private IRandomSource _crit;
7
8    public void Init(RngFactory rngFactory)
9    {
10        IRng combatRng = rngFactory.For(RngDomain.Combat);
11        _crit = new PseudoRandomDistribution(combatRng, critChance);
12    }
13
14    public int ResolveDamage(int baseDamage) =>
15        _crit.Roll() ? Mathf.RoundToInt(baseDamage * critMultiplier) : baseDamage;
16}
17
18public class LootTable
19{
20    private readonly IRandomSource _legendary;
21
22    public LootTable(RngFactory rngFactory)
23    {
24        IRng lootRng = rngFactory.For(RngDomain.LootDrops);
25        _legendary = new PityCounter(new TrueRandom(lootRng, 0.05f), hardPity: 40);
26    }
27
28    public bool RollLegendary() => _legendary.Roll();
29}

In C#, you add Combat to the RngDomain enum from the previous article. That way crits don't consume numbers from the loot stream, and adding a particle effect doesn't change which hits crit. In Unreal, ERngDomain is the same enumeration declared as an enum class. From Unreal Blueprints, wrap the interface in a UObject or expose only methods like ResolveDamage, as in the example.

8. Simulation: pure randomness vs PRD

To check that all of this really changes the experience, I simulated a million attacks with a 25% crit chance using both sources. Both give practically the same average (25.1% with pure randomness and 25.0% with PRD), but the miss streaks between crits are very different:

Misses between two critsPure randomPRD
0 (back-to-back crits)25.2%8.5%
1 to 232.8%34.9%
3 to 524.5%46.1%
6 to 912.1%10.5%
10 to 144.2%0.05%
15 or more1.3%0%
Longest streak4411

PRD concentrates almost half the cases in the 3 to 5 miss range, which is what the player expects from "one in every four". The two extremes that generate complaints disappear: chained crits (one in four with pure randomness) and streaks of ten or more misses (over 5% with pure randomness, practically zero with PRD). With pure randomness, a million attacks produced a streak of 44 misses in a row. With PRD, the maximum was 11, exactly the theoretical limit from the table in section 2.

Conclusions

  • Pure randomness has streaks, and players judge it by the sequence they live through, not by the nominal probability. With 25% and 200 rolls, a streak of ten misses is almost certain.
  • The PRD from Warcraft III and Dota 2 keeps the average and removes extreme streaks by growing the probability with every miss. The constant CC is computed numerically, just once.
  • The shuffle bag guarantees the distribution in every batch, like Tetris's 7-bag. It's more rigid than PRD, but perfect when you don't want repetitions.
  • Fire Emblem's true hit changes the real probability so it matches the perceived one. It works because it always favors the player.
  • In loot boxes and drops, the surprise is the product. You don't need to smooth the distribution. You can use a pity system that only trims the worst case.
  • To decide, ask how often it's rolled, whether there's a player decision behind it, whether failing punishes or just doesn't reward, and whether the streaks are visible.
  • Implementing everything behind an interface like IRandomSource lets you switch techniques without touching gameplay, and hooking it up to seeded streams keeps reproducibility.

Related Articles

View all articles
How RNG Works in Roguelikes

How RNG Works in Roguelikes

Roguelikes depend on chance, but that chance has structure. A deep dive into seeds, determinism, and how to implement a reproducible RNG system in Unity/C#.

Fire Emblem's Stats and the Illusion of Tactical Depth

Fire Emblem's Stats and the Illusion of Tactical Depth

Fire Emblem's growth rates are simple percentage rolls, but they generate brutal variance, emotional attachment, and entire subcultures of save-state manipulation.