Death drops¶
On death, scatter some of what the actor was actually carrying — a drop table built from the corpse's own inventory instead of one authored in advance.
Recipe
Systems required: Health, Inventory, Loot. Package: Complete only — the systems above ship in different packages, so no single-system package can run this. Shape: one class you drop into a project that already exists. No scene, no prefab, no setup ritual. Public API only. Once you change it, it is your code. Copying and editing is the intended path — so a modified recipe is yours to maintain and debug. Support covers the framework's behaviour, not a copy of this class.
Pickups are not referenced and are not needed. If the Loot/Pickups integration is present the awards spawn as pickups where the body fell; if it is not, the same code delivers them the other way.
Why build a table at all¶
A loop and a coin flip per item would drop things too. The reason to go through a LootTable and LootService is that the rest of your game is already listening there:
- Your
ILootModifiers apply — the doubling event, the luck buff, the difficulty scaler. Rolled,SpawnedandUndeliveredfire, so the HUD, the audio and the analytics see a death drop exactly as they see any other award.- Pickup spawning, the container fallback and the guarantee matrix all behave as they do for authored loot.
A hand-rolled Random.value < 0.4 gets none of that, and every one of those behaviours is one somebody eventually asks for.
The part that is not obvious¶
The ordering is forced. The sequence you would write first — roll, then remove what was won from the corpse — cannot be expressed through this API. RollAndGrant delivers in the same call that rolls, and Grant deliberately never spawns pickups, so there is no window between the two halves to take the items in.
So this takes the inventory first, builds the table out of what it actually got, and rolls that. The corpse ends up empty either way; what the roll decides is how much of it reaches the ground.
The forced order is also the safe one
Spawning first and removing afterwards means a refused removal has already put a copy in the world — the duplication bug that every "drop on death" implementation eventually ships. Here nothing can be dropped that was not first successfully taken, because the take is what builds the table. Every removal is checked; a slot the container refuses to give up simply never becomes an entry.
It trades a duplication risk for a loss risk, and you should choose knowingly
Because the items leave the inventory before the roll, anything the roll rejects is gone with the body — that is the intent, and it is what dropChancePerStack controls. But it also means a delivery that fails entirely, with no spawner bound and no container to fall back to, loses the lot. LootService raises Undelivered for exactly that case; subscribe to it if your game cannot afford the loss.
The table is a ScriptableObject nothing will collect for you
LootTable.Create returns a real instance flagged HideFlags.DontSave, and DontSave includes DontUnloadUnusedAsset — so Resources.UnloadUnusedAssets skips it and a scene load does not reclaim it either. Nothing but an explicit Destroy releases one, so a table built per death leaks an object per death for the lifetime of the process. It is destroyed in a finally, the same as in the condition-weighted loot recipe.
Death handlers are cached
HealthSystem collects its IHealthDeathHandler components once. A component added at runtime after that will not be called until you invoke RefreshOptionalComponents() on the health system. Adding it in the editor, or on a prefab, needs nothing.
Drop it in¶
using System.Collections.Generic;
using RevGaming.RevFramework.Health.Abstractions;
using RevGaming.RevFramework.Health.Abstractions.Lifecycle;
using RevGaming.RevFramework.Inventory.Abstractions;
using RevGaming.RevFramework.Inventory.Data;
using RevGaming.RevFramework.Inventory.UnityIntegration;
using RevGaming.RevFramework.Loot.Core;
using RevGaming.RevFramework.Loot.UnityIntegration;
using UnityEngine;
namespace RevGaming.RevFramework.Cookbook.DeathDrops
{
/// <summary>
/// On death, scatters some of what the actor was actually carrying — a drop table built from the
/// corpse's own inventory rather than authored in advance.
/// </summary>
/// <remarks>
/// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
/// <b>Health</b>, <b>Inventory</b>, <b>Loot</b>. Public API only.</para>
///
/// <para><b>Where the awards end up is a property of the service, not of this class.</b> With a
/// <c>LootPickupSpawner</c> bound and <c>SpawnPickups</c> on they spawn as pickups where the body
/// fell. Without one they are delivered into a container: the <c>recipient</c>'s if you name one,
/// and <b>the corpse's own if you do not</b> — the roll's winners handed straight back to the body
/// that is about to be destroyed. Bind a spawner, or name a recipient. Leaving the corpse without
/// an inventory is not a third option: the service creates the container on demand.</para>
///
/// <para><b>Why build a table at all,</b> when a loop and a coin flip per item would drop things
/// too: because a table goes through <see cref="LootService"/>, and that is where the rest of your
/// game is already listening. Modifiers apply — those on the <i>owner's</i> own parent chain,
/// which is the corpse, or the recipient when you name one, and not the killer's. <c>Rolled</c>
/// fires on every roll; then <c>Spawned</c> for awards a spawner took, <c>Granted</c> for awards a
/// container took, and <c>Undelivered</c> only for awards neither would have. That last case is
/// rarer than it reads: with an inventory adapter bound the recipient's container is created on
/// demand and absorbs almost anything the spawner refuses, so watch <c>Granted</c> and the owner it
/// reports rather than relying on <c>Undelivered</c> to tell you loot went astray. Pickup spawning,
/// the container fallback and the guarantee matrix all behave exactly as they do for authored loot.
/// A hand-rolled <c>Random.value < 0.4</c> gets none of that, and every one of those behaviours
/// is one somebody eventually asks for.</para>
///
/// <para><b>The ordering is forced once the awards have to reach the ground,</b> and that is the
/// interesting part. The obvious sequence — roll, then remove what was won from the corpse — is
/// expressible: <see cref="LootService.Roll"/> rolls without delivering anything, and
/// <see cref="LootService.Grant"/> delivers a result you have already inspected. Take that route if
/// your game delivers into a container, because it loses nothing the roll rejected. It cannot put
/// anything on the floor, though: <c>Grant</c> deliberately never spawns pickups, and
/// <see cref="LootService.RollAndGrant"/>, which does, delivers in the same call that rolls and
/// leaves no window between the two halves. So this takes the inventory <i>first</i>, builds the
/// table from what it actually got, and rolls that. With <c>dropWholeStack</c> on, the corpse is
/// emptied and the roll decides how much of what was taken reaches the ground; with it off, one
/// item per stack is taken and the rest stays on the body. What the roll rejects is destroyed
/// either way — that is the price of this ordering, and the reason to prefer roll-first when you
/// can.</para>
///
/// <para><b>That ordering is also the safe one.</b> Spawning first and removing afterwards means a
/// refused removal has already put a copy in the world — the duplication bug that every "drop on
/// death" implementation eventually ships. Here nothing can be dropped that was not first
/// successfully taken, because the take is what builds the table.</para>
///
/// <para><b>What does not survive the round trip.</b> A loot entry carries an item GUID and a
/// quantity and nothing else, so durability and per-stack metadata are lost: a sword at 12% drops
/// as a pristine one. Every item a corpse can carry must also be registered in the
/// <c>ItemDatabase</c> the loot service's inventory adapter resolved, because the drop travels as a
/// GUID — an unregistered item is removed from the corpse and then delivered nowhere.</para>
///
/// <para><b>The table is a ScriptableObject and is destroyed after the roll,</b> for the same
/// reason the condition-weighted loot recipe destroys its own: Unity does not collect an
/// unreferenced one until a scene load, so one built per death leaks one object per death.</para>
/// </remarks>
[DisallowMultipleComponent]
public sealed class DeathDropsFromInventory : MonoBehaviour, IHealthDeathHandler
{
[Tooltip("Service the carried items are taken through. Leave empty to find one in the scene on first use.")]
[SerializeField] private SceneInventoryService inventoryService;
[Tooltip("Container emptied on death, and the container the awards are delivered into. " +
"Match it to the CharacterInventory's mirrored container if you renamed that.")]
[SerializeField] private string container = "Backpack";
[Tooltip("The service that rolls and delivers. Leave empty to find one in the scene on first use.")]
[SerializeField] private LootService loot;
[Tooltip("Who receives the awards — usually the killer or the player. Leave empty and this " +
"object receives them, which reaches the ground only if a pickup spawner is bound.")]
[SerializeField] private GameObject recipient;
[Tooltip("Chance each carried stack has of reaching the ground, 0-1. The rest is lost with the body.")]
[SerializeField, Range(0f, 1f)] private float dropChancePerStack = 0.5f;
[Tooltip("Drop the whole stack when it is chosen. Off drops a single item from it.")]
[SerializeField] private bool dropWholeStack = true;
private readonly List<LootEntry> _entries = new();
private bool _dropped;
private void OnEnable() => _dropped = false;
/// <summary>
/// Clears the once-per-life guard so this actor can drop again.
/// </summary>
/// <remarks>
/// Cleared automatically on enable. Exposed for the routes that restart a life without one:
/// <c>HealthSystem.Revive</c>, and a save load restoring a dead actor to alive in place. Both
/// clear the health system's own death guard, so the next death re-runs this handler — and
/// without this call it would find the drop already spent.
/// </remarks>
public void ResetDropGuard() => _dropped = false;
/// <summary>
/// Empties the corpse into a one-off drop table and rolls it.
/// </summary>
/// <remarks>
/// Called by <c>HealthSystem</c> for every <see cref="IHealthDeathHandler"/> on this object.
/// Note that the handler list is cached: a component added at runtime after the health system
/// woke up will not be called until <c>RefreshOptionalComponents()</c> is invoked on it.
/// </remarks>
public void HandleDeath(IHealthReadonly health)
{
// The seam does not check this for us -- HealthSystem collects handlers with GetComponents
// and filters only destroyed ones -- so without this line unticking the component would not
// turn the drop off, which is the one gesture a designer expects to work.
if (!isActiveAndEnabled)
return;
// The health system already guards a death being finalised twice. This is not that guard
// duplicated -- it covers the other route, a project that calls the handler itself. It is
// per life, not per component: OnEnable and ResetDropGuard clear it, because a revived or
// pooled actor must be able to drop again.
if (_dropped)
return;
// Both lookups include the inactive ones: a service parked on a bootstrap object that is
// switched off at this instant is still the service, and Unity's default overload would
// not see it.
if (!inventoryService)
inventoryService = FindAnyObjectByType<SceneInventoryService>(FindObjectsInactive.Include);
if (!loot)
loot = FindAnyObjectByType<LootService>(FindObjectsInactive.Include);
if (!inventoryService || !loot)
{
Debug.LogWarning($"[{nameof(DeathDropsFromInventory)}] '{name}' died with no " +
$"{nameof(SceneInventoryService)} or no {nameof(LootService)} in the " +
"scene, so nothing dropped.", this);
return;
}
// Set only once the resolve succeeded. Set first, a failed lookup burns the one shot and the
// actor can never drop again even after the service turns up.
_dropped = true;
// Captured before the take. Every removal raises the container's change event synchronously,
// and a subscriber is free to move, pool or destroy this object before the roll is
// delivered; so is a loot modifier, which runs inside the roll and would otherwise get to
// move the object before the service read its transform. Later death handlers and Died
// listeners cannot affect it -- every one of them runs after this handler has returned.
Vector3 where = transform.position;
TakeCarriedItems();
if (_entries.Count == 0)
return;
LootTable table = LootTable.Create(
LootTableMode.IndependentChance,
rollsMin: 0,
rollsMax: 0,
allowDuplicates: true,
_entries.ToArray());
try
{
loot.RollAndGrant(table, recipient ? recipient : gameObject, container, spawnAt: where);
}
finally
{
if (Application.isPlaying)
Destroy(table);
else
DestroyImmediate(table);
}
}
/// <summary>
/// Removes what the corpse was carrying and turns each successful removal into a table entry.
/// </summary>
/// <remarks>
/// The take goes through <see cref="IInventoryService"/> rather than the container or a
/// <c>CharacterInventory</c>, because that is the only route that consults
/// <c>IInventoryAuthority</c>. A networked or server-authoritative project that refuses the
/// mutation gets a refusal here, instead of an inventory emptied for a delivery that is then
/// refused on the way back in. Slot indices are stable across a removal — the container writes
/// an empty stack in place — so the direction of the walk does not matter for correctness; it
/// runs backwards because a change subscriber shrinking the container mid-walk shrinks it from
/// the end. <b>Every removal is checked.</b> An entry is added only for a removal that reported
/// success, so a slot the container refuses to give up cannot also be dropped on the floor —
/// which is the same item existing twice, and it is the failure this whole ordering exists to
/// prevent.
/// </remarks>
private void TakeCarriedItems()
{
_entries.Clear();
ContainerId id = new(container);
IReadOnlyInventoryContainer bag = inventoryService.Get(gameObject, id);
if (bag == null)
return;
for (int i = bag.Capacity - 1; i >= 0; i--)
{
ItemStack stack = bag.Peek(i);
if (stack.IsEmpty || !stack.def || string.IsNullOrEmpty(stack.def.guid))
continue;
int wanted = dropWholeStack ? stack.quantity : 1;
if (wanted <= 0)
continue;
InvOpResult removed = inventoryService.RemoveFromSlot(gameObject, id, i, wanted);
if (!removed.Success)
continue;
_entries.Add(new LootEntry
{
kind = LootEntryKind.Item,
itemGuid = stack.def.guid,
quantityMin = wanted,
quantityMax = wanted,
chance01 = dropChancePerStack,
});
}
}
}
}
Wiring it up¶
- Put the component on anything that has a
HealthSystem. The death hook is implemented, not subscribed, so there is no registration and nothing to unregister. - Leave
inventoryServiceandlootempty and they are found in the scene on first use, or assign them. Setcontainerto whatever container the corpse's items live in — it defaults toBackpack, and it is also where awards are delivered. - Set
dropChancePerStackanddropWholeStackto taste.
Step 4 is the one that decides whether anything reaches the floor
Set recipient to whoever should receive the drops — usually the killer or the player.
Leave it empty and the corpse receives its own loot, which reaches the ground only if a LootService pickup spawner is bound and the Loot/Pickups integration is present. Without a spawner the awards are granted straight back into the dead actor's own container, which looks exactly like nothing having happened. This is the single most common way to wire this recipe and see no drops at all.
Tuning¶
| Field | What it does |
|---|---|
dropChancePerStack | Chance each carried stack has of reaching the ground. The rest is lost with the body. |
dropWholeStack | On drops the whole stack when it is chosen; off drops a single item from it. |
What it deliberately does not do¶
It does not drop equipped items. What is worn sits in CharacterEquipment, not the container this reads. Adding it is a second loop over the equipment layout — and a decision about whether a killer should be able to strip a corpse of the sword that just killed them.
It does not weight rarity. Every carried stack gets the same chance. If your items carry a rarity or a tag, that is the obvious first change: read it and vary chance01 per entry.
It does not survive being looted twice. One drop per component, guarded by a flag — the health system already prevents a death being finalised twice, and this covers the other route, a project that calls the handler itself.
Related¶
- Loot odds that shift with the player's condition — the other recipe that builds a table at runtime, and where the ScriptableObject lifetime trap is explained in full.
- Inventory — containers, slots and the result types every removal returns.
- Loot — tables, modes, adapters and the delivery guarantees.