Lesson 15: Randomness for Games
Loot drops, critical hits, enemy spawns and whole generated levels all run on chance, and chance is only fun when you control it. In this lesson you learn to make randomness you can replay, weight and shape, and to bend luck so it feels fair without being predictable.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Create a
random.Random(seed)generator per game system and replay any run from its seed. - Build a weighted loot table with
random.choicesand explain how weights become chances. - Choose between uniform, bell-shaped, triangular and exponential randomness for a game value.
- Compare pure chance with a shuffle bag, a pity timer and pseudo-random distribution (PRD), and measure the real rate of each.
- Write a random spawn timer in seconds that never skips or doubles a spawn.
Project: Loot Drops, a seeded loot table with rarity weights, live tally bars and a pity timer that guarantees an Epic.
In This Lesson
๐ฒ Chance on Purpose
Think of a card dealer. A fair dealer shuffles, and nobody can guess the next card. But the deck is still a deck: it holds exactly 52 cards, and if you filmed the shuffle, you could deal the same hand again. Game randomness works the same way. Python's random module is a pseudo-random number generator: a formula that produces numbers that look random, starting from a number called the seed. Same seed, same sequence, every time.
That is not a weakness; it is a superpower. It means you can:
- Replay a bug. "The game crashed on the third room" becomes "run seed 81237 and walk to the third room."
- Share a world. Players can trade a seed instead of a whole level.
- Shape luck. Weighted tables, fair streaks and guaranteed rewards are all just rules on top of the same generator.
๐ก Why this matters
Uncontrolled randomness is where "I can't reproduce it" bugs and "this game cheats" reviews come from. A few habits from this lesson, one generator per system with a known seed and luck rules you have measured, prevent both.
๐ฑ Seeds and Generators of Your Own
The functions you know from Python, random.randint() and friends, all share one hidden generator. That causes two problems in a game. If the enemy code calls random.random() one extra time, the loot sequence shifts too, so a replay no longer matches. And if some helper calls random.seed(123), it silently resets the generator for every other system.
The fix is to give each system its own generator with random.Random(seed). It has all the same methods (random(), randint(), uniform(), choice(), choices(), shuffle(), โฆ):
import random
def roll_dice(rng, count):
"""Roll `count` six-sided dice with the generator you pass in."""
return [rng.randint(1, 6) for _ in range(count)]
def make_rng(seed=None):
"""A generator of its own. seed=None means 'surprise me'; 0 is a real seed."""
if seed is not None:
print(f" (seeded with {seed}, so this run can be replayed)")
return random.Random(seed)
loot_rng = make_rng(42)
print("Loot, seed 42: ", roll_dice(loot_rng, 5))
replay_rng = make_rng(42)
print("Replay, seed 42: ", roll_dice(replay_rng, 5)) # identical list
zero_rng = make_rng(0)
print("Seed 0: ", roll_dice(zero_rng, 5))
# Two systems, two generators: extra rolls in one never shift the other.
enemy_rng, weather_rng = make_rng(7), make_rng(7)
roll_dice(enemy_rng, 100) # the enemies roll a lot
print("Weather, seed 7: ", roll_dice(weather_rng, 3))
print("Fresh check: ", roll_dice(make_rng(7), 3)) # same as the weather
print("Unseeded: ", roll_dice(make_rng(), 5)) # different every run
Run it twice. Every line is the same both times except the last one, because random.Random() with no seed seeds itself from the operating system's randomness source (or the clock, if there is none). Notice three habits in the code:
- One generator per system, passed in as a parameter:
roll_dice(rng, count). The enemies rolled 100 times and the weather didn't notice. if seed is not None:, notif seed:. Zero is a perfectly good seed, butif 0:is false, soif seed:would quietly treat seed 0 as "no seed."- No
random.seed()anywhere. Seeding happens once, when the generator is made.
Print or show the seed somewhere (a debug overlay, a log line, the pause menu) so that when a tester finds something odd, they can tell you the seed.
โ Growth Mindset: "It Only Happens Sometimes" Is Solvable
A bug that shows up one run in twenty can feel impossible, like the game is haunted. It isn't; you just can't make it happen on demand yet. Seed your generators, log the seed, and when the bug appears you have a way to replay it exactly. Learning to turn "sometimes" into "every time with seed 4417" is one of the most useful debugging skills you will pick up in this course.
๐ Weighted Loot Tables
Loot needs a rarity table: common things often, rare things rarely. choices() takes the items and a list of weights:
RARITIES = ["Common", "Uncommon", "Rare", "Epic"]
WEIGHTS = [70, 20, 8, 2] # relative: they don't have to add up to 100
rarity = rng.choices(RARITIES, weights=WEIGHTS)[0] # choices returns a LIST
Each item's chance is its weight divided by the total: here 70 รท 100 = 70% Common and 2 รท 100 = 2% Epic. Weights of [7, 2, 0.8, 0.2] give exactly the same chances. Two traps: choices (with an s) returns a list, even for one pick, so take [0]; and choice (no s) has no weights at all.
What does choices do inside? Picture the weights as lengths laid end to end on a ruler 100 units long. Pick a random point on the ruler, and see whose stretch it lands in:
def weighted_pick(rng, items, weights):
roll = rng.uniform(0, sum(weights)) # a random point on the ruler
running = 0
for item, weight in zip(items, weights):
running += weight # the end of this item's stretch
if roll < running:
return item
return items[-1] # the roll landed exactly on the end
Use choices in real code; the hand-written version is for understanding, and for when you want to show players the odds.
๐ The Shape of Chance
"Random" is a family, not one thing. randint(1, 6) makes every face equally likely. But the height of a crowd of villagers, or the time between enemy spawns, doesn't look like that.
damage = rng.uniform(8, 12) # every value from 8 to 12 equally likely
height = rng.gauss(170, 8) # bell curve: mostly near 170, rarely far away
height = max(150, min(190, height)) # a bell curve has no hard limits: clamp it
gap = rng.expovariate(1 / 2.0) # exponential: short gaps common, average 2.0 s
spread = rng.triangular(0, 10, 8) # between 0 and 10, most often near 8
One shape catches people out. To scatter points evenly inside a circle, a random angle and a random distance are not enough: equal distances crowd the small middle, so points bunch up there. Take the square root of the random fraction instead:
angle = rng.uniform(0, 2 * math.pi)
r = radius * math.sqrt(rng.random()) # sqrt spreads points evenly over the area
x, y = cx + r * math.cos(angle), cy + r * math.sin(angle)
โ๏ธ Luck That Feels Fair
A 25% critical hit is truly random only if long dry spells can happen. With pure chance, missing ten times in a row happens about once in every 18 tries of ten attacks (0.7510 โ 5.6%). Players remember those streaks and decide the game is rigged. Three tools bend the streaks without changing the average much.
The demo rolls a 25% chance 60 times three ways. Gold squares are hits. Roll a few times and compare the longest dry streak in each row.
1. The shuffle bag
Put the outcomes in a bag, shuffle it, and draw until it is empty; then refill. A bag of one hit and three misses gives exactly one hit in every four draws, so the longest possible dry spell is six (a hit first in one bag, last in the next).
class ShuffleBag:
"""Every item comes out once per bag, in a random order."""
def __init__(self, items, rng):
self.items = list(items)
self.rng = rng
self.bag = []
def draw(self):
if not self.bag:
self.bag = self.items[:]
self.rng.shuffle(self.bag)
return self.bag.pop()
Bags suit things that should feel evenly spread: which of four music tracks plays next, which enemy type spawns, which falling piece comes next in a block puzzle.
2. The pity timer
Keep counting misses. After a set number, force the reward. The exercise uses this for Epic loot: at most 39 drops in a row without one.
if self.since_epic >= PITY_LIMIT - 1:
rarity = "Epic" # the pity timer ran out
else:
rarity = self.roll_rarity()
self.since_epic = 0 if rarity == "Epic" else self.since_epic + 1
3. Pseudo-random distribution (PRD)
PRD starts each streak with a small chance c and adds c after every miss: c, then 2c, then 3c, until a hit resets it. Long streaks become impossible and hits are more evenly spaced. The catch: c is not the chance you want. If you set c = 0.25 for a 25% crit, the chance climbs so fast that the real rate is much higher. Instead, compute c so the average comes out right, and measure it:
import random
def prd_rate(c):
"""Average success rate when the chance is c, 2c, 3c, ... until a success."""
expected_tries = 0.0
still_failing = 1.0 # chance we reach try n without a success
n = 1
while still_failing > 0:
chance = min(1.0, c * n)
expected_tries += n * still_failing * chance
still_failing *= 1 - chance
n += 1
return 1 / expected_tries
def prd_constant(p):
"""Find c so that the average rate really is p (binary search)."""
low, high = 0.0, p
for _ in range(50):
mid = (low + high) / 2
if prd_rate(mid) < p:
low = mid
else:
high = mid
return (low + high) / 2
class PseudoRandomCrit:
def __init__(self, p, seed=None):
self.c = prd_constant(p)
self.rng = random.Random(seed)
self.misses = 0
def roll(self):
self.misses += 1
if self.rng.random() < self.c * self.misses:
self.misses = 0
return True
return False
p = 0.25
print(f"Using c = p = {p}: real rate {prd_rate(p):.1%}")
crit = PseudoRandomCrit(p, seed=1)
print(f"Correct constant for {p:.0%}: c = {crit.c:.5f}")
rolls = [crit.roll() for _ in range(100_000)]
print(f"Measured over 100,000 rolls: {sum(rolls) / len(rolls):.1%}")
Running it prints:
Using c = p = 0.25: real rate 45.1%
Correct constant for 25%: c = 0.08474
Measured over 100,000 rolls: 25.0%
So a naive constant turns a "25%" crit into about 45%, while the computed one really delivers 25%. prd_rate works out the average number of tries between hits from the chances of hitting on try 1, 2, 3, โฆ, and prd_constant is a binary search: try a middle value, keep the half that contains the answer, repeat.
Should you tell players? Be honest in anything you publish. If the tooltip says 25%, the measured rate should be 25%.
โฒ๏ธ Random Timers in Seconds
Enemies that spawn at exactly two-second intervals feel mechanical. Pick a random wait instead, and count it down in seconds with dt, like every other timer in this course:
spawn_timer = rng.uniform(1.0, 3.0) # seconds until the first spawn
# every frame:
spawn_timer -= dt
while spawn_timer <= 0: # while, not if: a long frame can pass two spawn times
spawn_enemy()
spawn_timer += rng.uniform(1.0, 3.0) # add, don't reset: keep the leftover time
Two details make it exact. += keeps the leftover: if the timer reached โ0.03, the next wait is 0.03 s shorter, so spawns don't drift later and later. And while handles a slow frame that passes two spawn times at once. Swap uniform(1.0, 3.0) for expovariate(1 / 2.0) if you want waits that are usually short with the occasional long lull.
๐๏ธ Practice Exercise: Loot Drops
Objective: build a loot table that you can replay from its seed, that follows its rarity weights, and that never lets a player go too long without an Epic.
Time: about 30 minutes. Starter file: loot_drops_starter.py (your instructor has it). It draws the drop cards and tally bars, but every rarity is equally likely, the seed does nothing, and there is no pity timer. Its numbered comments match the steps below.
- Run the starter and press T a few times. Watch Epics show up far too often. (โ 2 min)
- Give the table its own generator:
random.Random(seed). (โ 3 min) - Pick rarities with
choicesand theWEIGHTSlist. (โ 5 min) - Add the pity timer to
drop(). (โ 8 min) - Test it: note the first five drops, press R twice (back to seed 42), and check that the same five come again. Press T until you have 500 drops and compare the bars with the white target lines. (โ 12 min)
You are done when:
- seed 42 gives the same drops in the same order every run, and seed 7 gives different ones;
- after a few hundred drops the bars sit close to 70%, 20%, 8% and 2% (a little above for Epic, because of the pity timer);
- "Since Epic" never goes above 39;
- closing the window prints the first five drops for seed 42.
๐ก Hint
If R doesn't change anything, the table is still using the shared random module; make sure self.rng is a random.Random(seed) and that every roll uses self.rng, including the item name. If you get a list like ['Common'] instead of a string, add [0] after choices(...).
โ Example Solution
If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top, plus an extra and frame_budget() condition on the main loop. They let the instructor's checker run the program automatically for a fixed number of frames; when you run it yourself they do nothing. You never need to write them.
"""Loot Drops: Intermediate Lesson 15 practice exercise (solution).
A seeded loot table with weighted rarities and a pity timer. The same
seed always gives the same drops, the tally bars drift toward the
weights, and an Epic is guaranteed at least once every PITY_LIMIT drops.
Controls: Space = one drop, T = ten drops, R = switch seed (42 / 7).
"""
import random
import pygame
WIDTH, HEIGHT = 800, 480
FPS = 60
RARITIES = ["Common", "Uncommon", "Rare", "Epic"]
WEIGHTS = [70, 20, 8, 2] # relative weights, not percentages
PITY_LIMIT = 40 # at most 39 drops in a row without an Epic
ITEMS = {
"Common": ["Rusty Sword", "Bread", "Rope", "Torch"],
"Uncommon": ["Iron Shield", "Healing Potion", "Lantern"],
"Rare": ["Moon Bow", "Frost Ring"],
"Epic": ["Dragon Crown", "Star Blade"],
}
COLORS = {
"Common": (170, 170, 180),
"Uncommon": (80, 200, 100),
"Rare": (80, 140, 240),
"Epic": (190, 100, 230),
}
BG_COLOR = (24, 26, 38)
TEXT_COLOR = (235, 235, 240)
class LootTable:
"""Weighted drops from this table's OWN random generator, plus a pity timer."""
def __init__(self, seed=None):
self.seed = seed
self.rng = random.Random(seed) # seed=None: a different run every time
self.since_epic = 0
def roll_rarity(self):
"""One weighted pick. random.choices returns a list, so take [0]."""
return self.rng.choices(RARITIES, weights=WEIGHTS)[0]
def drop(self):
"""Return (rarity, item). Forces an Epic when the pity timer runs out."""
if self.since_epic >= PITY_LIMIT - 1:
rarity = "Epic"
else:
rarity = self.roll_rarity()
self.since_epic = 0 if rarity == "Epic" else self.since_epic + 1
return rarity, self.rng.choice(ITEMS[rarity])
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Loot Drops")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 26)
small = pygame.font.Font(None, 20)
table = LootTable(42)
tally = {r: 0 for r in RARITIES}
recent = [] # last 6 (rarity, item) pairs
first_drops = [] # printed at the end: same seed, same list
def new_table(seed):
nonlocal table, tally, recent
table = LootTable(seed)
tally = {r: 0 for r in RARITIES}
recent = []
def do_drop():
rarity, item = table.drop()
tally[rarity] += 1
recent.append((rarity, item))
del recent[:-6]
if table.seed == 42 and len(first_drops) < 5:
first_drops.append(item)
running = True
while running:
clock.tick(FPS)
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_SPACE:
do_drop()
elif event.key == pygame.K_t:
for _ in range(10):
do_drop()
elif event.key == pygame.K_r:
new_table(7 if table.seed == 42 else 42)
screen.fill(BG_COLOR)
total = sum(tally.values())
header = f"Seed {table.seed} Drops {total} Since Epic {table.since_epic}/{PITY_LIMIT - 1}"
screen.blit(font.render(header, True, TEXT_COLOR), (20, 16))
screen.blit(small.render("SPACE drop T ten drops R switch seed", True, (160, 165, 180)), (20, 44))
for i, (rarity, item) in enumerate(recent):
card = pygame.Rect(20 + i * 128, 80, 118, 70)
pygame.draw.rect(screen, COLORS[rarity], card, border_radius=8)
screen.blit(small.render(rarity, True, (20, 20, 30)), (card.x + 8, card.y + 10))
screen.blit(small.render(item, True, (20, 20, 30)), (card.x + 8, card.y + 40))
weight_sum = sum(WEIGHTS)
for i, rarity in enumerate(RARITIES):
y = 200 + i * 62
share = tally[rarity] / total if total else 0.0
target = WEIGHTS[i] / weight_sum
pygame.draw.rect(screen, (50, 54, 70), (200, y, 500, 36))
pygame.draw.rect(screen, COLORS[rarity], (200, y, 500 * share, 36))
pygame.draw.line(screen, TEXT_COLOR, (200 + 500 * target, y - 4), (200 + 500 * target, y + 40), 2)
screen.blit(font.render(rarity, True, COLORS[rarity]), (20, y + 8))
label = f"{tally[rarity]:4d} {share:6.1%} (weight {target:.0%})"
screen.blit(small.render(label, True, TEXT_COLOR), (210, y + 11))
pygame.display.flip()
pygame.quit()
print("First drops with seed 42:", ", ".join(first_drops))
if __name__ == "__main__":
main()
new_table and do_drop are small helper functions defined inside main(), so they can use main's variables. The line nonlocal table, tally, recent works like global, one level up: it tells Python that assigning to those names inside new_table changes main's variables instead of creating new local ones. do_drop doesn't need it, because it only changes the dictionary and list it already has (tally[rarity] += 1, recent.append(...)) and never assigns a new object to the name itself.
๐ Learning Journal
Take five minutes to write in your learning journal. Jot down:
- Key concepts you learned today
- Techniques that clicked (and the ones that haven't, yet)
- Questions or confusion to bring to the next session
- Ideas to try in your own game
- Progress and feelings: how did this lesson go for you?
โ๏ธ This lesson's prompts:
- Think of a game where luck once felt unfair to you. Which tool from this lesson would have helped, and why?
- Where in your own game would you want players to share a seed, and where should every run be a surprise?
- The naive PRD constant gave 45% instead of 25%. What does that teach you about trusting a formula you haven't measured?
๐ Summary
Python's random numbers come from a formula started by a seed, so the same seed replays the same run. You gave each system its own random.Random(seed), checked seeds with is not None so 0 works, and never reseeded the shared generator. You picked loot with weighted choices, chose a distribution to fit each value, and measured three ways to make luck feel fair: a shuffle bag, a pity timer and PRD with a computed constant. Finally, you wrote spawn timers in seconds that keep their leftover time.
๐ Key Takeaways
- Same seed, same sequence: log your seeds and you can replay any run.
- One
random.Randomper system keeps systems from disturbing each other. choices(items, weights=...)[0]picks by relative weight; it returns a list.- Shuffle bags, pity timers and PRD limit streaks; measure the real rate of any luck rule.
- Random timers count down by
dt, use+=andwhile, and never drift.
๐ญ Looking Ahead
Random numbers that jump around are perfect for loot but terrible for landscapes. In the next lesson, Noise Terrain & Biomes, you make randomness smooth, and grow a whole island with beaches, forests and snowy peaks from a single seed.
โ Common Questions
Is Python's random good enough for games?
For gameplay, yes. Python's documentation warns that it is not suitable for security, such as passwords or tokens; for those, the secrets module exists. Loot, spawns and levels are fine with random.Random.
Will the same seed give the same numbers on a friend's computer?
For the same Python version, random.Random(seed) gives the same sequence for methods like random(), choice() and shuffle(). Python's documentation describes which methods it keeps reproducible across versions; if seeds are part of your game (shared worlds, daily challenges), test them on the Python version you ship.
My loot table says 2% Epic but I got three in a row. Is it broken?
Probably not. Rare things do cluster sometimes; three Epics in a row is unlikely but possible. Check with many rolls: press T until you have hundreds of drops and compare the bar with its target line. A table is only wrong if the long-run rate is wrong.
Should I use a pity timer, a shuffle bag or PRD?
A pity timer is for rare, valuable rewards. A shuffle bag is for things that should feel evenly mixed. PRD is for frequent chances such as crits and dodges, where you want hits spread out but still unpredictable. Many games use more than one.
Where do I store the seed for a saved game?
Save it with the rest of the game state, as a plain integer. A generator's full internal state can also be saved with rng.getstate() and restored with rng.setstate() if you need to resume a sequence partway through.
๐ฏ Quick Quiz
Question 1: What does random.Random(42) give you?
Question 2: What does rng.choices(["Common", "Rare"], weights=[9, 1]) return?
Question 3: Why is if seed: a bug in a function that takes an optional seed?
Question 4: The exercise sets PITY_LIMIT = 40. What does its pity timer guarantee?
Question 5: A spawner does spawn_timer -= dt, then while spawn_timer <= 0: spawn and add a new wait. Why while instead of if?
๐ Going Further
- Crit meter: add a PRD critical hit to the loot program (C key to attack) and show the measured rate next to the promised 25%.
- Item bag: use a
ShuffleBagfor the item names inside each rarity, so you don't get the same Common item three times in a row. - Daily seed: build a seed from today's date, for example
int(date.today().strftime("%Y%m%d")), so everyone gets the same drops today. - Name generator: glue random syllables into monster names with a seeded generator, and print ten names for seeds 1 to 3.
- Read the docs: Python's random module, especially
choices,gauss,expovariateand the notes on reproducibility. - Coming up in Game Dev III: Advanced: Dungeons & Caves, where seeded generators carve whole dungeon layouts.