Skip to content

Loot — Mental Model

Three ideas. Everything else in the system is a consequence of one of them.

1. Rolling and granting are different verbs

A roll answers what did you win. A grant answers where did it go. They are separate calls, separate failure modes, and separate concepts — and keeping them apart is what makes pickups possible at all.

A pickup rolls its award when it drops, then carries that result around the world until somebody walks into it, and grants it on collect. Those two moments can be minutes apart. If rolling and granting were one operation, a pickup would have to either roll at collect time (so the thing on the ground is not yet the thing you get) or hold a live reference to the table forever.

LootResult won = service.Roll(table, owner);   // decided now
// … later, elsewhere …
service.Grant(won, collector);                 // delivered then

Grant never spawns pickups, whatever the service is configured to do. A spawned pickup granting through the normal path would spawn another pickup on collect, and that one would spawn another, for ever. That is not a special case bolted on; it falls out of taking the two verbs seriously.

2. The roller knows nothing about your game

LootRoller is static, takes an IRandomProvider, and returns a value. It cannot reach an inventory, a wallet, a scene or an owner, because it holds none of them.

This is the same shape as Crafting.Core and for the same reason: the part that decides is dependency-free, and the part that delivers is gated. RevFramework.Loot references neither Inventory nor Currency. Delete either and Loot still compiles; its awards for the missing system simply stop being delivered.

The practical consequence is that odds are testable as arithmetic. There is no "spin up a scene, grant a thousand times, count the inventory" test in this system, because there does not need to be.

3. An award is never half-delivered — it is somewhere, or it is reported

Three destinations, no fourth:

Outcome Where it went How you find out
Delivered The owner's container or wallet Granted
Spawned The world, at the owner's feet Spawned — and Granted later, if they collect it
Undelivered Nowhere the service can reach Undelivered

"Undelivered" is not a synonym for "lost", though it usually amounts to one. The service is reporting that it could not place the award, which normally does mean the player will never see it. The exception ships in the box: a full-bag collect of a LootPickupPayload raises Undelivered, and the pickup keeps its award and stays in the world, collectable again once there is room. Read the event as "this did not arrive", and check whether the caller still holds it before mailing or re-granting.

The rule that makes this trustworthy: Granted carries what actually landed, not what was rolled. An earlier version fired it with the whole roll, so a "you received" toast announced items a full bag had silently eaten. If you drive UI from one event, drive it from this one.

Undelivered fires only when there is something to report, and only after both direct delivery and spawning have failed. Nothing is retried automatically, because where an undeliverable award should go — mailed, queued, converted to currency, dropped on the floor of the shop — is a design decision this component has no basis to make.

Weights are relative, and only within one table

A weight has no absolute meaning. 1 against 3 is identical to 25 against 75. This trips people who expect a weight to be a percentage, and it is why the debugger samples rather than reporting the authored numbers back at you.

Nested tables do not share a weight pool. A nested entry is chosen against its parent's weights, and then the nested table rolls its own — so an entry deep in a chain is not "weight 5 out of the total of everything". Reasoning about end-to-end odds by hand across nesting is exactly the thing sampling is for.

Modifiers adjust the result, not the odds

ILootModifier runs on the owner's parent chain, after the roll, against what was won. A modifier can double a currency award or swap an item; it cannot make a rare item more likely.

This is deliberate, and it is a real constraint. A table whose printed weights do not describe its behaviour is very hard to reason about — if a modifier could quietly re-weight entries, the asset would stop being the truth about the drop. The cost is that luck stats, magic find, difficulty scaling, level gating and pity counters are all odds features and none of them can be expressed today.

Know which of those you need before designing around this system.

The identity question, which decides what Loot can name

Loot refers to items by GUID string and currencies by id string, and validates neither.

That is what buys the decoupling — the roller does not need the Inventory assembly to hold a GUID. The price is that a typo'd id is not a compile error, not a warning, and not a runtime exception.

And it is not a table that drops nothing, either. A typo'd id is a perfectly normal award all the way through rolling: LootRoller rejects only a blank id, so a wrong one is rolled at its authored rate, counted by the debugger, carried in the LootResult, and spawned as a pickup if a spawner takes it — the spawner does not resolve ids either. The first thing in the chain that consults a database is delivery. LootInventoryAdapter asks for the GUID, gets nothing, and returns false per award, so the award falls through to the spawner or into Undelivered.

Currency is worse, because it succeeds. A non-blank currency id credits a wallet key that is created on demand, so a typo'd currency id delivers, raises Granted, and puts money in a currency nothing in your game displays. The same shape as a mistyped container name, which is also created rather than refused.

What sampling can and cannot tell you

The debugger samples through LootRoller and prints raw id strings. It holds no ItemDatabase reference and resolves nothing, so a typo'd id is present in a sample of any size — and the contrapositive a reader reaches for, "it showed up, so the id is fine", is exactly backwards.

Fault Shows up in a sample?
Blank itemGuid or currencyId ✔ Yes — the entry is missing, and the console warns
Zero weight, or zero chance ✔ Yes — the entry is missing
Table entry with no nested table ✔ Yes — the entry is missing, and the console warns
Nesting past MaxNestingDepth, or a cycle ✔ Yes — the deeper entries are missing, and the console warns
A typo'd but non-blank id ❌ No. It appears, at the right rate, looking healthy

So sampling checks the table, and delivery checks the ids. To check an id, grant the table to a real owner in Play Mode with an inventory adapter bound, and watch Granted against Undelivered — an entry that appears in every roll and never in the container is the one whose id does not resolve. That is what the sample scene's LootDeliveryPanel puts on screen.

The mental checklist before you ship a table

  • Is every quantity what you meant? The roller treats anything below one as one, so a zeroed row still drops a single item rather than going quiet.
  • If it is weighted, does it have a Nothing entry, or is it meant to always drop?
  • Have you sampled it in the debugger, rather than read the weights? That checks the table — every entry you authored appears, at a rate you recognise.
  • Have you granted it once in Play Mode, with your real adapters bound? That is the only thing that checks the ids, because nothing before delivery resolves one.
  • Did the awards land in the container your UI actually reads? A mistyped container name is created rather than refused, so Granted fires either way.
  • If the recipient's bag can be full, is a spawner bound, or are you handling Undelivered?