Poisson Arrivals: A Quant Toolkit for Game Balance
The Poisson distribution shows up everywhere in game design — drop rates, enemy spawns, queueing — and most of the time we're using it wrong. A short primer.
If something in your game arrives at random — drops, enemy spawns, players queueing into a match — there's a strong chance it's modeled by a Poisson process. There's an even stronger chance the way you're tuning it is wrong.
This post is the short version of the Poisson primer I wish someone had handed me three years ago.
The setup
A Poisson process has one number: a rate, λ (lambda). It's the expected number of events per unit time. From λ alone, you can derive:
- The probability of seeing exactly
kevents in timet. - The expected wait between events.
- The variance of the count, which equals the mean (this trips people up).
import numpy as np
from math import factorial, exp
def pmf(k: int, lam: float) -> float:
"""P(X = k) when X ~ Poisson(lam)."""
return exp(-lam) * lam ** k / factorial(k)
# Probability of exactly 3 zombies spawning when we expect 5
print(pmf(3, 5.0)) # ~0.140The classic mistake: tuning the mean, ignoring the variance
Suppose you want a "rare" loot drop — say, 1 in every 100 chests. The naive design says: "give the player a chest every minute, and roll a 1% chance for the rare." 100 minutes per drop on average. Done.
Except the variance of that wait is enormous. Roughly half of players will wait longer than the median (~70 minutes), but ~5% will wait more than 300 minutes — five times the average. Those are the people writing angry forum posts.
rng = np.random.default_rng(0)
# Time between drops follows an Exponential(rate) distribution
# rate = 1/100 per chest, chests every minute → 1/100 per minute
waits = rng.exponential(scale=100.0, size=100_000)
print(f"mean: {waits.mean():.1f} min")
print(f"median: {np.median(waits):.1f} min")
print(f"p90: {np.percentile(waits, 90):.0f} min")
print(f"p99: {np.percentile(waits, 99):.0f} min")Output:
mean: 100.0 min
median: 69.4 min
p90: 230 min
p99: 461 minThe 99th-percentile player waits 4.6× the mean. If you're tuning by mean only, you're tuning for nobody.
The fix: pity timers turn Poisson into bounded variance
Most modern games solve this with a pity timer: a hard cap on how long the player can wait. After N attempts, the next drop is guaranteed.
You can model this analytically as a truncated geometric distribution. The math is dull, but the effect is sharp: you trade a tiny amount of expected wait for a huge reduction in variance.
| Strategy | Mean wait | p99 wait |
|---|---|---|
| Pure Poisson | 100 min | 461 min |
| Pity at 200 | ~91 min | 200 min |
| Pity at 150 | ~83 min | 150 min |
A pity at 150 lowers the average wait and caps the worst case. The only downside is the variance is now too low for some players to feel suspense — which you fix by adding a small upward bias to drop rate over time, recovering some of the dispersion without the long tail.
This same pattern works for matchmaking queues, AI spawn timing, and procedural event generation. Bound the tail.
When Poisson is wrong
Poisson assumes independence: each event is unaffected by what came before. That's a fine model for cosmic rays, but it's a bad model for:
- Player aggression — kills bunch up. A real PvP zone has clusters of activity, not a smooth rate.
- Server load — requests correlate (someone tweets, you spike).
- Supply/demand events — anything where one event triggers another.
For those, reach for a negative binomial (Poisson-with-overdispersion) or a Hawkes process (events that beget events). Both are one Python library away.
The takeaway
If you're tuning a random system in your game, three questions to ask:
- What's the variance of the wait time, not just the mean?
- What does the 99th-percentile player experience?
- Is the assumption of independence between events actually true?
Most balance pain in shipped games is a Poisson-tail problem hiding in plain sight.
canonical: https://islandside.dev/blog/poisson-arrival-times-game-balance