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;
LootDropOnDeathis 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.