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 — only a blank id is refused; any other string is awarded and resolved at delivery, or not at all
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, and sampling will not catch it

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

The failure mode is not an entry that never appears. LootRoller refuses only a blank id, so a typo'd one is rolled at its authored rate, tallied by the debugger — which prints raw id strings and resolves nothing — and spawned as a pickup if a spawner is bound. The first component in the chain that consults a database is the inventory adapter, at delivery.

So an entry present in a sample of any size proves nothing about its id. Grant the table once in Play Mode with your real adapters bound, and watch Granted against Undelivered: an entry that rolls every time and never reaches the container is the one whose id is wrong. A typo'd currency id does not even fail — it credits a wallet key created on demand, so Granted fires for money no UI is reading.


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

Which determinism, exactly. "Deterministic" answers three different questions and the answers differ. Stated once here so nothing above has to be read hopefully:

Question Answer
Same process, same run, replayed from one seed ✔ Yes, for a flow you drive explicitly — roll, deliver, tick. ❌ No for a session running through Update, where frame timing enters the outcome
Two processes, same build, same machine ❌ Not guaranteed. Nothing reconciles wall-clock or frame-count state between them, and a crafting save carries a fresh transaction GUID
Two platforms, or two CPU architectures ❌ Not guaranteed wherever a transcendental is involved. The pure roll paths are integer and float arithmetic only, and are bit-identical across Mono/IL2CPP and x86/ARM
Deterministic at all without opting in ❌ No. The default RNG is UnityEngine.Random, the engine-wide shared stream

The first row is what a drop-rate check and a bug repro need, and it is the one covered by tests. The second and third are what a lockstep or rollback netcode needs, and this framework does not provide them — see Authority for what it does provide.

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
An unknown container name ⚠ Created, not refused. Delivery succeeds into a container no UI is reading
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
Undelivered means lost ⚠ Usually, not always — a caller still holding the award keeps it. See note

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 does the award reach Undelivered, and that is the case worth an event.

Undelivered reports what the service could not place, which is not always a loss

The service is saying it found nowhere to put the award. That normally means the player has lost it — but not when the caller kept hold of the award itself, and the shipped pickup flow does exactly that: a collector with a full bag walking over a LootPickupPayload fires Undelivered, Grant returns false, and the pickup is not consumed. It stays in the world carrying its award and can be collected again once there is room.

Pinned by OnEnter_WhenDeliveryFails_LeavesThePickupCollectable. If you mail, queue or re-grant from this event, check first whether the award still exists somewhere — otherwise a full-bag collect hands the player the same drop twice.


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 the service could not place 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 delivery suite in LootServiceDeliveryTests exists to keep it fixed.


10. Modifiers

ILootModifier

Aspect Guarantee
Discovery ✔ The owner's parent chain
Order ✔ By Priority, lowest first — ties keep component order
Disabled modifier ❌ Does not run. An unticked enabled box switches it off
Modifier on an inactive GameObject ✔ Still runs — nobody switched that component off
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
Revived enemies ✔ The guard clears itself on Revived; a re-killed enemy drops again with no call
Pooled enemies ✔ ResetDropGuard() re-arms it, for reuse that neither disables nor revives the object
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 drops exactly as authored and fails at delivery — or, for a currency id or a container name, succeeds into a wallet key or a container nothing displays.
  • ❌ 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
Does Undelivered mean the award is gone? ⚠ Usually — not when the caller kept it, as a loot pickup does
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 — and sampling will not either. Grant it and watch Undelivered
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.