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