Skip to content

Loot — System Guarantees Matrix

The behavioural contract

This page defines the behavioural contract of the loot system.

No marketing. No implication. Just guarantees — and explicit non-guarantees.


Quick Navigation

  • Table
  • Rolling
  • Weighted Mode
  • Independent Chance Mode
  • Nesting
  • Randomness
  • Service
  • Delivery
  • Events
  • Modifiers
  • Adapters
  • Non-Guarantees
  • Final Summary

1. Table

LootTable

Aspect Guarantee
Type ScriptableObject — an authored asset
Runtime mutation ❌ Not supported; the surface is read-only
Entries IReadOnlyList<LootEntry> — and LootEntry is a struct, so you receive copies
Entry ids Never validated — a bad GUID or currency id drops nothing, silently and for ever
Weight meaning Relative within one table. 1:3 behaves identically to 25:75
TotalWeight ✔ Sum across entries — for display, ⚠ not a denominator across nesting
Quantity default ✔ A new entry starts at 0, which the roller treats as one — an unset quantity awards one, not nothing
Quantity of zero ✔ Treated as one at roll time; a quantity cannot express "drops nothing"
Empty table ✔ Produces an empty result; does not throw

A table is never validated against your item database

LootEntry.itemGuid and .currencyId are raw strings and nothing checks them — not at author time, not at edit time, not at runtime. This is the price of the decoupling that lets RevFramework.Loot compile with Inventory deleted.

The failure mode is the quiet one: a typo'd id is not an error, it is an entry that never appears. Sample the table in the debugger and check every authored entry shows up. An entry missing from a large sample is telling you its id does not resolve.


2. Rolling

LootRoller

Aspect Guarantee
Type static — no instance, no state
Side effects None. Grants nothing, touches no scene, no owner, no other system
Dependencies IRandomProvider only — no Inventory, no Currency
Determinism ✔ A pure function of the table and the RNG sequence
Null table or null RNG ✔ Produces an empty result; does not throw
Buffer overload Roll(table, rng, items, currency) reuses caller lists — for sampling loops
Play Mode ❌ Not required. Rolling is testable as arithmetic

This is the decision the rest of the system follows from

Because rolling is pure, drop rates are unit-testable with no scene, the assembly compiles with Inventory absent, and a teaching panel can demonstrate odds with nothing else installed.

Granting is the part that needs the world, and it lives entirely in define-gated integration assemblies. That separation is why deleting a system changes where awards go and never changes what a table produces.


3. Weighted Mode

Aspect Guarantee
Selection ✔ One entry per roll, chosen by relative weight
Roll count ✔ Between RollsMin and RollsMax inclusive
Inverted bounds (min > max) ✔ Rolls the minimum — does not throw
Zero-weight entry Never picked
All weights zero ✔ Produces nothing
AllowDuplicates off ✔ An entry is not repeated within one pull, and the pull stops when exhausted
Always yields something ⚠ Yes, unless every weight is zero — express "sometimes nothing" with a Nothing entry
RNG returning exactly 1.0 ✔ Still selects a valid entry — see note

The upper-bound case is load-bearing, and it is not theoretical

IRandomProvider.Value01() is documented as inclusive at both ends, and UnityEngine.Random.value really does return 1.0. Scaled onto a weight total that lands exactly on the end of the last bucket.

Handled explicitly and pinned by Weighted_RandomAtUpperBoundStillPicksAnEntry. Crafting never met this because its seeded provider returns [0,1) — so this class of bug has no analogue elsewhere in the framework and would not be found by comparison with it.


4. Independent Chance Mode

Aspect Guarantee
Selection ✔ Every entry tested separately against its own chance01
Chance of 0 Never awards
Chance of 1 Always awards
Values outside 0..1 ✔ Clamped
May yield nothing ✔ Yes — every entry can decline
Roll count ❌ Not used; every entry is tested once

Both bounds are handled explicitly, not by comparison

A plain roll <= chance awards at chance 0. A plain roll < chance occasionally refuses at chance 1. Because Value01() is inclusive at both ends, neither comparison alone is correct, and the code special-cases both. Pinned by Independent_ChanceZeroNeverAwardsAndChanceOneAlwaysDoes.


5. Nesting

Aspect Guarantee
Nested awards ✔ Folded into the parent result
Maximum depth LootRoller.MaxNestingDepth = 8
Exceeding depth ✔ Warns and stops — does not throw, does not hang
Cycle detection Path-based — a table reaching itself is caught
Same table in two branches ✔ Legal — that is not a cycle
Weight pool Not shared. A nested table rolls its own weights independently

End-to-end odds across nesting are not the product of the printed numbers

A nested entry competes at its parent's level; the nested table then rolls its own weights. The two are independent, so an entry three levels down is not "weight 5 out of the grand total".

There is no arithmetic on this page that will give you the real number. Sample it.


6. Randomness

Aspect Guarantee
UseUnityRng() ✔ Unity's Random — the default
UseDeterministicRng(seed) ✔ Repeatable sequence within a build
Same seed, same table, same build ✔ Same roll
Same seed, changed table ❌ Different roll — adding, reordering or re-ranging changes output
Same seed across framework versions ❌ Not guaranteed
Custom IRandomProvider ✔ Supported — pass it to LootRoller directly

Never store a loot seed as a record of what the player got

A seed is reproducible against a fixed table, not against a table you will keep editing. Storing "seed 4711 gave them the sword" means the sword changes the next time a designer adds a row.

Save the result, not the seed.


7. Service

LootService

Aspect Guarantee
Type MonoBehaviour — one per scene, assigned in the inspector
Adapter binding ✔ Re-bound at the head of every public call, so runtime assignment is picked up
Missing adapter ✔ Not an error — awards of that kind are simply not delivered
Roll ✔ Rolls only; grants nothing
RollAndGrant ⚠ Returns what was rolled, not what was delivered
Grant ✔ Returns true only when at least one award actually reached the owner
Grant spawning Never spawns, whatever SpawnPickups is set to
DefaultContainer "Backpack" unless configured
Container names ✔ Canonicalised — casing never selects a different container
Threading ❌ Main thread only

RollAndGrant's return value cannot tell you whether anything was delivered

It returns what the table produced whether or not every award could be delivered. Reading it tells you a roll happened and nothing about where the awards went.

Grant returns a bool that does mean delivery; RollAndGrant has no equivalent. Use the events for delivery outcomes. Driving a "you received" message from RollAndGrant's return value is the exact mistake the Granted event exists to prevent.


8. Delivery

Aspect Guarantee
Destinations Exactly three: delivered, spawned, or reported. Never a silent fourth
Per-award ✔ Delivery is decided award by award, not per roll
Direct delivery refused ✔ Falls back to spawning where a spawner is bound
Spawn fallback with Spawn Pickups off ✔ Still happens — spawning is a fallback, not only a mode
Both refused ✔ Reported through Undelivered
Retry ❌ Never. Nothing is queued, mailed or converted
Partial delivery ✔ Supported and reported — Granted and Undelivered can both fire

Why spawning is a fallback rather than a mode

An award the inventory refuses is dropped at the owner's feet where a spawner is bound. That is what most games do with a full bag, and it is recoverable by the player — they can make space and pick it up.

Only when spawning also fails is the award genuinely lost, and that is the case worth an event.


9. Events

Event Carries Fires
Rolled ⚠ The full roll After rolling, before anything is granted
Granted Only what the owner received After delivery, when at least one award reached them
Spawned ✔ Only what went to the world When an award was dropped instead of handed over
Undelivered ✔ Only what was lost When an award reached neither the owner nor the world
Aspect Guarantee
Subscriber isolation ✔ One throwing subscriber does not stop the others
Granted for spawned awards ❌ Does not fire — the award reached the world, not the owner. It fires on collect instead, so one award is announced once
Both events for one call ✔ Possible, and correct, when delivery is partial

Drive player-facing UI from Granted, never from Rolled

Rolled fires with everything the table produced, before delivery has been attempted. A toast driven from it announces items a full bag will silently eat.

Granted carries what actually landed, which is what makes it safe. This distinction was a real defect once — the service used to fire Granted with the whole roll — and the nine delivery tests exist to keep it fixed.


10. Modifiers

ILootModifier

Aspect Guarantee
Discovery ✔ The owner's parent chain
Order ✔ By Priority, lowest first
Runs against ⚠ The rolled result — not the table, not the odds
Can change ✔ Item id, quantity, currency id, amount
Can change odds No. Luck, magic find, difficulty scaling, level gating, pity counters
Granting from inside ❌ Unsupported — re-enters the service mid-delivery

This is the system's real ceiling

Modifiers adjust what was won, never what was likely. The reasoning is sound — a table whose printed weights do not describe its behaviour is very hard to reason about — but the consequence is that an entire category of common loot feature cannot be expressed without editing LootRoller.

If your design needs odds-shaping, establish that before building on this system.


11. Adapters

Adapter Provides Absent means
ILootInventoryAdapter Item awards into a container Item awards not delivered
ILootCurrencyAdapter Currency awards into a wallet Currency awards not delivered
ILootPickupSpawner Awards into the world No spawn fallback
LootDropOnDeath (Health) Rolls a table on Died No death drops
Aspect Guarantee
Shipped adapter types internal — added from the component menu, not referenced in code
LootDropOnDeath double-drop ✔ Guarded — Died can fire more than once across a revive
Pooled enemies ResetDropGuard() re-arms it deliberately
LootPickupPayload placement Must be on the prefab — see note

The payload must be on the prefab, not added after Instantiate

TriggerRelay3D resolves its IPickupTriggerReceiver during Awake, which has already run by the time Instantiate returns. A payload component added to the instance afterwards is never found, and the pickup silently does nothing on collect.

This is the single most likely mistake when writing a custom spawner.


Non-Guarantees

Stated plainly, so none of them is a surprise:

  • Ids are never validated. A typo is an entry that never drops.
  • Odds cannot be modified at runtime. No luck, magic find, pity or scaling.
  • Nothing is persisted. Loot has no save participant; uncollected pickups do not survive a load.
  • Seeds are not stable across table edits or framework versions.
  • Undeliverable awards are never retried.
  • Loot cannot see item properties — rarity, level, type and tags live in ItemDefinition, in a system it does not reference.
  • Nothing decides when to drop. Your game calls the service; LootDropOnDeath is the one shipped trigger.
  • A table is not checked for sensibility. Zero weights and useless tables are authored content; a below-one quantity is the one thing the roller corrects, to one.

Final Summary

Question Answer
Are the odds the authored odds? ✔ Yes — pinned by convergence tests over large samples
Can an award vanish silently? ✔ No — delivered, spawned, or reported
Does Granted mean the player has it? ✔ Yes
Does RollAndGrant's return value mean that? ❌ No — it is the roll
Can a full bag destroy a drop? ✔ No, where a spawner is bound
Can a collected pickup spawn another? ✔ No — Grant never spawns
Does it compile without Inventory or Currency? ✔ Yes
Will a typo'd item id tell me? ❌ No — sample the table
Can I add magic find? ❌ Not without editing the roller

System Philosophy

Rolling is a decision; granting is a consequence. Keeping them apart is what makes the odds testable without a scene, the assembly compilable without Inventory, and pickups possible at all.

An award is somewhere, or it is reported. The system will not tell you the player received something they did not, and it will not lose something without saying so. Everything else here is negotiable; those two are the contract.