Skip to content

Game Development

Procedural Loot Without Pain: seeded RNG, loot tables, and tests

Game Development 6 min read

Why loot generation goes wrong

Indie games usually don’t fail because their loot system is “too complex”. They fail because loot is treated as a side-effect: something that happens during gameplay and quietly depends on timing, frame order, or hidden state. That leads to a classic pattern:

  • QA reports “the drop rate feels off” but you can’t reproduce it.
  • Speedrunners exploit RNG patterns because the generator is predictable in the wrong way.
  • Content tweaks force code changes because the logic is scattered.
  • Save/load doesn’t restore loot state, so reloading changes future drops.

The fix isn’t “make everything deterministic everywhere”. The fix is to design loot generation as a pure, repeatable function with well-defined inputs, and then test it like code.

The core idea: make loot deterministic from an explicit seed

For any loot event (a chest, an enemy kill, a quest completion), define an event descriptor that uniquely identifies that roll. For example:

  • World/level ID
  • Location or entity ID
  • Event sequence number (kill #3, chest opened #1)
  • Optional modifiers (difficulty, player progression tier)

From that, compute a seed. The key is that the seed must not depend on frame timing or call count. Then generate results using a seeded RNG.

In practice, you’ll want two layers:

  • RNG for choosing the loot outcome (rarity tier, item category)
  • RNG for the internal rolls (which item within that tier, rolls for stats/affixes)

This makes your system resilient: changing the number of RNG calls for display text won’t shift the actual loot, because you can isolate streams.

Use loot tables as data, not branches

A lot of loot logic is “if rarity == legendary then… else…”. That’s hard to maintain and easy to break. Instead, model loot as tables:

  • Tier table: rarity tier weights per context
  • Category table: weapon/armour/trinket weights per tier
  • Item table: specific items and weights per context
  • Affix table: number of affixes + affix pool rules

Each table selection is just “given a set of weighted entries, pick one using RNG”. When balancing changes, you update data; you don’t rewrite control flow.

If you’re in Unity, a common pattern is to store loot tables as ScriptableObjects (data assets) while keeping the generator code as plain C# classes so you can test it outside the engine.

Engine-agnostic guidance like this still matters across Unity/Unreal/Godot: the tables are data, the roll function is code, and the only randomness is seeded.

Weighted selection without surprises

Weighted loot selection typically uses one of these approaches:

  • Accumulate weights and pick a threshold in [0, total)
  • Alias method for large tables and frequent rolls

For most indie games, the accumulate approach is enough. The most important detail is to keep the selection deterministic:

  • Fix iteration order of table entries (e.g., as stored in the asset)
  • Keep floating-point behaviour stable (avoid mixing different RNG sources)
  • Don’t “skip” entries during iteration based on incidental conditions

If you need to filter entries (e.g., “only items available in this biome”), do it once up front to produce a filtered list, then run selection on that stable list.

Split RNG streams to protect determinism

Even with seeded RNG, you can accidentally couple systems. For example, if you call the RNG to decide a visual effect and later call it to decide the loot, any change in effect logic shifts future drops.

The simplest mitigation: stream separation. Use separate RNG instances for different purposes:

  • RarityRng
  • ItemRng
  • AffixRng

Seed them deterministically from the event seed (e.g., by hashing with different labels/integers). That way, reordering calls inside the loot code won’t break reproducibility.

Make loot generation testable: property and regression tests

Don’t rely on “it seems random”. Add tests that enforce behaviour. Examples:

  • Repeatability test: same event descriptor + same seed => identical loot result.
  • Isolation test: changing visual RNG calls does not change loot (if you use stream separation).
  • Distribution sanity: over N rolls, observed frequencies are within a tolerance.
  • Boundary test: no entries in a filtered table should produce a defined fallback (or explicit error).
  • Save/load test: rolling loot after reload matches the original run.

In Unity, you can run these as pure C# tests (EditMode tests or test assemblies) if your generator doesn’t depend on engine objects. That gives you fast feedback without booting the scene.

Even better: snapshot a handful of known seeds and assert the resulting item IDs. When balancing changes, you’ll consciously update snapshots rather than discovering differences weeks later.

Persist only what you need

There are two common strategies:

  • Recompute on load from the event descriptor and seed. This is ideal when the event descriptor is stable and you don’t need to preserve intermediate RNG state.
  • Store generated outcomes (the actual rolled item IDs and stat rolls). This is ideal when events depend on mutable world state.

Recomputing is simpler, but it depends on having a stable event key. If enemies can spawn in different orders, you’ll need a robust unique ID per spawned entity, or switch to “store outcomes”.

Whatever you choose, the rule is: loading must not change the future for already-created loot events.

Prevent exploitation without making RNG unreadable

Seeded RNG can become predictable if players can infer the seed. You can address that without sacrificing determinism:

  • Use a per-run secret (a run seed) plus an event descriptor. The run seed is set at the start of a run and kept server-side in multiplayer, or at least hidden in single-player.
  • Hash seeds so small changes don’t reveal patterns.
  • Limit information leakage: don’t expose raw seed values in UI or logs.

For single-player, you don’t need cryptographic-grade protection. You just need to stop “open chest #1, then chest #2” from being a deterministic scriptable exploit.

A practical rollout plan for an existing project

If you already have loot logic in gameplay scripts, migrating to this approach can be done incrementally:

  1. Extract a loot generator that takes (event descriptor, context, RNG seed) and returns a loot result struct.
  2. Replace direct RNG calls inside gameplay with generator calls.
  3. Add a deterministic seed function from event descriptor + run seed.
  4. Write repeatability regression tests for a few representative event descriptors.
  5. Convert branches to tables one category at a time (rarity first is usually the biggest win).

You’ll know you’re done when QA can reproduce “bad luck” reports and your designers can tweak tables without fear of breaking the roll sequence.

Common pitfalls to avoid

  • Calling RNG in UI code (tooltips, animations, debug overlays) that accidentally changes outcomes.
  • Depending on spawn order instead of stable entity IDs.
  • Using unordered collections (e.g., hash maps) to build loot candidate lists.
  • Letting floating-point drift influence weighted selection thresholds.
  • Mixing RNG libraries (different RNG algorithms, different float-to-int conversions).

Further reading

Bottom line: treat loot rolls as data-driven, pure, and repeatable. Seeded RNG is the mechanism; event descriptors and tests are the guarantee.

← Back to blog