M6.3 — Drop Tables & Weighted Rolls¶
What you'll learn
- How a weighted drop table decides what (if anything) an enemy drops, and the cumulative-weight sampling technique behind it.
- Why small weights — not nested random gates — are the clean way to make rare things rare but reachable.
- How rarity is itself rolled from a weighted table, and how a magic-find stat biases it upward.
- Where the drop roll hooks in:
SignalBus.enemy_died(M4.1), so loot needs no change to enemies.
How it applies
- The drop rate is the reward schedule. How often something good drops is the single biggest lever on whether the game feels rewarding or stingy. It is a tuning surface a designer adjusts and a tester verifies statistically, and it must be expressed as data, not buried in code.
- Rares must be rare but not mythical. A player who never sees a yellow drop quits; one who sees
them constantly stops caring. The art is a low-but-real chance, and the clean way to express that
is a small weight in a table — not a tower of
if randf() < 0.01gates that are hard to reason about and harder to tune. - Magic-find is the genre's grind knob. A stat that biases drops toward higher rarity gives players a reason to build for loot, not just for combat. It must compose with the base rates predictably.
- Weighted sampling is one technique used everywhere. The same cumulative-weight pick selects affixes (M6.2), enemies in a spawner pack, and random events. Learn it once here; reuse it across the project.
Try it first
Before reading on: an enemy should usually drop nothing, sometimes a common, rarely a rare, and
very rarely an epic. Write down how you'd implement that. Does your approach make it easy to answer
"what is the exact chance of a rare?" and easy to tune that chance? What happens to your other
probabilities when you change one? Spend a few minutes — the obvious approach (a chain of if randf() <
p checks) has a specific weakness this chapter fixes.
Concepts¶
Weighted selection¶
A drop table is a list of outcomes, each with a relative weight. The chance of an outcome is its
weight divided by the total weight. To sample: sum the weights, draw a random number in [0, total), and
walk the list subtracting weights until you cross the draw — the outcome you land on is the pick.
Example
The cumulative-weight pick — the core technique, reused from M6.2's affix selection:
# entries: Array of { "value": <anything>, "weight": int }
static func weighted_pick(entries: Array, rng: RandomNumberGenerator):
var total := 0
for e in entries:
total += e.weight
if total <= 0:
return null
var draw := rng.randi_range(0, total - 1)
for e in entries:
if draw < e.weight:
return e.value
draw -= e.weight
return entries.back().value # safety; unreachable with correct weights
draw starts somewhere in [0, total); each iteration either lands in the current entry's slice
(draw < weight) or moves past it (draw -= weight). An outcome with weight 1 against a total of 1000
is picked ~0.1% of the time — its rarity is just its small weight, expressed as data you can read and
tune.
Why weights beat nested gates (the productive-failure payoff)¶
The intuitive approach is a chain: if randf() < 0.5: drop_common; elif randf() < 0.1: drop_rare; …. Two
problems:
- The probabilities are entangled. Each
elifonly runs if the previous failed, so the actual chance of a rare is(1 − 0.5) × 0.1, not0.1— and changing the common rate silently changes the rare rate. You cannot read a probability off the code. - It doesn't scale or compose. Adding a tier means re-deriving every downstream probability, and you can't reuse the logic for affixes or spawns.
A weighted table fixes both: every outcome's chance is weight / total, independent and readable; adding
an outcome is adding a row; and the same weighted_pick works for any weighted choice in the game. The
"make rares rare" requirement becomes "give rare a small weight," which is a number a designer sets and a
tester verifies — not a fragile arithmetic of nested conditionals.
Two rolls: whether, and what rarity¶
A drop is two weighted questions:
- Does anything drop? A table like
{ nothing: 70, item: 30 }— most kills drop nothing. - If something drops, what rarity? A table like
{ common: 1000, magic: 200, rare: 40, epic: 5 }— rares are the small weight.
Then the base type is chosen (often another weighted table, possibly filtered by enemy/area), and the item is rolled at that rarity (M6.2). Splitting "whether" from "what" keeps each tunable on its own.
Example
The drop decision, hooked to enemy death:
# res://scripts/loot_system.gd (a node under World, or an autoload)
extends Node
@export var drop_chance_table: Array # [{value:"nothing",weight:70},{value:"item",weight:30}]
@export var rarity_table: Array # [{value:0,weight:1000},{value:1,weight:200},...]
@export var base_types: Array[ItemData] # eligible base items
func _ready() -> void:
SignalBus.enemy_died.connect(_on_enemy_died)
func _on_enemy_died(enemy: Node, at_position: Vector2) -> void:
var rng := GameState.rng
if weighted_pick(drop_chance_table, rng) != "item":
return
var tier: int = weighted_pick(_apply_magic_find(rarity_table, enemy), rng)
var base: ItemData = weighted_pick_items(base_types, rng)
var item := ItemFactory.roll(base, tier, enemy.item_level, rng) # M6.1 + M6.2
_spawn_pickup(item, at_position) # M6.4
Loot listens to enemy_died (M4.1) — adding the whole loot system touched no enemy code. Each roll
draws from GameState.rng so a seeded run reproduces the exact drops.
Magic-find¶
Magic-find is a stat that biases the rarity roll toward higher tiers. The clean implementation multiplies the weights of the higher tiers (or scales them by a factor derived from the stat) before sampling — it does not add a separate gate. Because the table is weights, "20% magic find" can mean "rare and epic weights ×1.2", which shifts the distribution predictably and composes with the base rates.
Example
func _apply_magic_find(table: Array, enemy: Node) -> Array:
var mf := 1.0 + player_magic_find() # e.g. 1.20 for +20%
var out := []
for e in table:
var w: float = e.weight
if e.value >= Rarity.Tier.RARE: # boost rare and above
w *= mf
out.append({ "value": e.value, "weight": int(round(w)) })
return out
Magic-find scales the rare-and-above weights, raising their share of the total without touching the sampling code. The effect is monotonic and readable: more magic-find, proportionally more high-rarity drops.
Tuning and testing the schedule¶
Because rates are weights, the drop schedule is verifiable statistically. A tester can roll the table N times against a fixed seed sequence and assert the observed rarity distribution matches the weights within tolerance — and assert boundaries: a tier with weight 0 never appears; the lowest-weight tier appears but rarely; magic-find shifts the distribution in the expected direction. This turns "loot feels off" into a measurable claim about a distribution.
Walkthrough¶
- Create
res://scripts/loot_system.gdwithweighted_pick, the_on_enemy_diedflow, and_apply_magic_find. Add it as a node underWorld(or as an autoload if you prefer global loot). - Configure tables in the Inspector (or in code for now): a drop-chance table (
nothing70 /item30), a rarity table (common1000 /magic200 /rare40 /epic5), and assign a fewbase_types. - Give the enemy an
item_level(from its StatBlock or a field) so M6.2's affix gating has a value. - Stub
_spawn_pickuptoprint("dropped ", item.display_name, " [", item.rarity, "]")until M6.4 builds the real pickup. - Press
F5, kill many skeletons, and watch the Output: most kills drop nothing, most drops are common, rares are occasional, epics scarce. WithGameState.rngseeded, the sequence is reproducible. - Tune: raise the
itemweight to see more drops; raiserare's weight to see more yellows. Note that changing one weight changes only its share, not the others' absolute behavior — the readability the nested-gate approach lacked.
Optional sanity check
Temporarily set the epic weight to 0 and kill a hundred enemies (or loop the roll a thousand times):
no epics should ever appear — the zero-weight boundary. Then set magic-find high and confirm the
observed rare+epic share rises versus magic-find zero, against the same seed sequence — the monotonic
bias. These two checks verify the schedule statistically rather than by feel. Restore your tuned
weights.
Self-check quiz¶
Q1 — Why is a weighted table preferred over a chain of if randf() < p checks for drops?
A. randf is deprecated.
B. In a chain, each outcome's real probability depends on all the previous checks failing, so you can't
read or independently tune any one chance; a weighted table gives each outcome an independent,
readable weight / total and composes/reuses cleanly.
C. Weighted tables are faster.
D. Chains can't produce rare outcomes.
Reveal answer
B. The chain entangles probabilities (a rare's real chance is conditioned on earlier checks failing) and silently shifts when you tune one branch; the table makes every chance independent and legible and the sampling reusable for affixes, spawns, and events. A is false. C is negligible. D is false (chains can, just unreadably).
Q2 — In the cumulative-weight pick, an outcome has weight 1 and the total weight is 1000. How often is it chosen, and how do you make it rarer?
A. 1% of the time; lower the total. B. ~0.1% of the time (weight / total = 1/1000); make it rarer by lowering its weight or raising others' weights. C. It's never chosen because 1 is too small. D. 50% of the time; weights are ignored.
Reveal answer
B. An outcome's chance is its weight over the total, so 1/1000 ≈ 0.1%; you tune rarity by adjusting weights. A miscomputes (and "lower the total" would make it more common). C is false (a weight of 1 is reachable, just rare). D ignores the mechanism.
Q3 — How does magic-find bias drops without breaking the predictability of the table?
A. It adds a separate if gate that overrides the table.
B. It multiplies the weights of the higher-rarity tiers before sampling, shifting the distribution
monotonically while still using the same weight / total math.
C. It rerolls the drop until a rare appears.
D. It guarantees a rare every N kills.
Reveal answer
B. Scaling the high-tier weights changes their share of the total predictably and composes with the base rates, all through the same sampling — more magic-find means proportionally more high rarity. A reintroduces the entangled-gate problem. C and D are different (and less tunable) mechanics that break the clean distribution.
Integration question¶
Q4 — open
The loot system listens to SignalBus.enemy_died (M4.1), rolls drops via weighted tables, and produces
an item via the M6.1/M6.2 factory. Explain why expressing rarity as weights (rather than nested
probability gates) is what makes the drop schedule both designer-tunable and tester-verifiable, and
trace how a single skeleton's death becomes a yellow item on the floor — naming every weighted roll
and where seeded RNG makes it reproducible.
Reveal expected answer
Weights make the schedule tunable and testable because each outcome's probability is an independent,
readable ratio (weight / total): a designer sets "rare = 40 out of ~1245 total" and knows the
exact chance, and changing one weight changes only that outcome's share without silently perturbing
the others — unlike nested gates, where every probability is conditioned on the prior checks and
can't be read or tuned in isolation. A tester can then verify the schedule statistically: roll the
table N times under a fixed seed and assert the observed distribution matches the weights within
tolerance, plus boundaries (weight 0 never appears, magic-find shifts the distribution upward). The
path of one death: the skeleton dies and emits SignalBus.enemy_died(self, position) (M4.1); the
loot system's handler fires (it subscribed in _ready, so no enemy code changed); it draws from
GameState.rng — first a whether roll on the drop-chance table (nothing vs item), then, on
item, a rarity roll on the rarity table after magic-find scales the rare+ weights, landing on
RARE; a base type roll picks the base item; ItemFactory.roll deep-duplicates the base (M6.1) and
rolls 3–4 affixes via the weighted affix pick (M6.2), assembling a yellow ItemData; and
_spawn_pickup (M6.4) places it at the death position with the Rarity.COLORS[RARE] color. Every
roll draws from the one seeded GameState.rng, so a given seed reproduces the entire sequence —
whether it drops, what rarity, which base, which affixes, and their values — turning random loot
into a deterministic, testable function of the seed.