Skip to content

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.01 gates 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:

  1. The probabilities are entangled. Each elif only runs if the previous failed, so the actual chance of a rare is (1 − 0.5) × 0.1, not 0.1 — and changing the common rate silently changes the rare rate. You cannot read a probability off the code.
  2. 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:

  1. Does anything drop? A table like { nothing: 70, item: 30 } — most kills drop nothing.
  2. 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

  1. Create res://scripts/loot_system.gd with weighted_pick, the _on_enemy_died flow, and _apply_magic_find. Add it as a node under World (or as an autoload if you prefer global loot).
  2. Configure tables in the Inspector (or in code for now): a drop-chance table (nothing 70 / item 30), a rarity table (common 1000 / magic 200 / rare 40 / epic 5), and assign a few base_types.
  3. Give the enemy an item_level (from its StatBlock or a field) so M6.2's affix gating has a value.
  4. Stub _spawn_pickup to print("dropped ", item.display_name, " [", item.rarity, "]") until M6.4 builds the real pickup.
  5. Press F5, kill many skeletons, and watch the Output: most kills drop nothing, most drops are common, rares are occasional, epics scarce. With GameState.rng seeded, the sequence is reproducible.
  6. Tune: raise the item weight to see more drops; raise rare'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.