Skip to content

Loot odds that shift with the player's condition

Give the player better odds when they are nearly dead, or while a buff is running. Not a bigger pile of the same drops — genuinely different odds, decided at the moment of the roll, from the state of whoever is receiving them.

Recipe

Systems required: Loot, Health, StatusEffects. 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.

The part that is not obvious

The seam that looks right is the wrong one. ILootModifier sits in the roll pipeline and is the natural place to reach for — but it runs after the table has already chosen, so it can only adjust what was won. Doubling a reward is not the same as making a rare reward likelier, and no arrangement of modifiers will move the odds themselves.

The odds live in the table, so shifting them means rolling a different table. That is what LootTable.Create is for: a public, runtime-only table built from entries you supply. Read the authored table, copy its entries with the rare ones weighted up, roll the copy, throw it away. The authored asset is never touched, and every drop rate you tuned in the inspector still means what it said.

The copy is a ScriptableObject, and nothing will collect it for you

LootTable.Create returns a real ScriptableObject instance flagged HideFlags.DontSave, and DontSave includes DontUnloadUnusedAsset. So it is not garbage collected, UnloadUnusedAssets skips it, and a scene load does not reclaim it either — a table derived on every kill leaks an object per kill for the lifetime of the process. Only an explicit Destroy releases one. The recipe destroys its copy in a finally, which is the whole of the cleanup: a LootResult holds ids rather than table references, and any nested table the copy pointed at is an authored asset it never owned.

Drop it in

ConditionWeightedLootDrop.cs
using System.Collections.Generic;

using RevGaming.RevFramework.Health.Abstractions;
using RevGaming.RevFramework.Loot.Core;
using RevGaming.RevFramework.Loot.UnityIntegration;
using RevGaming.RevFramework.StatusEffects.Abstractions;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.LootOddsFromCondition
{
    /// <summary>
    /// Rolls a loot table whose odds shift with the recipient's condition — the closer to death they
    /// are, and the presence of a nominated status, make the rare entries more likely.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
    /// <b>Loot</b>, <b>Health</b>, <b>StatusEffects</b>. Public API only — nothing here reaches into the
    /// framework's internals, so it will keep working across releases on the same terms as your own
    /// code.</para>
    ///
    /// <para><b>The composition.</b> <see cref="ILootModifier"/> looks like the seam for this and is
    /// not: it adjusts what was <i>won</i>, after the roll has already chosen. Loot withholds
    /// odds-rewriting from that seam on purpose — a table whose printed weights do not describe its
    /// behaviour is hard to reason about — and a derived copy reintroduces exactly that for the instant
    /// of the roll. That is the trade being made here, and it is worth making only for odds that move
    /// continuously; for two or three fixed states, two authored tables and a choice between them are
    /// simpler and stay designer-visible. Shifting the odds themselves means changing the table the
    /// roller sees, and the seam that allows it is <see cref="LootTable.Create"/> — a public,
    /// runtime-only table built from entries you supply. So this reads the authored table, derives a
    /// copy with the rare entries weighted up, rolls that, and throws the copy away. The authored asset
    /// is never touched.</para>
    ///
    /// <para><b>"Rare" is inferred, not authored:</b> an entry counts as rare when its odds — its
    /// weight, or its chance in an independent-chance table — fall below the mean of the <i>prize</i>
    /// entries. Prizes only, deliberately: a <see cref="LootEntryKind.Nothing"/> entry is not something
    /// that can be won, and counting its weight would put the bar above every real prize on the usual
    /// "mostly nothing" table, boost them all equally, and produce a bigger pile of the same mix — the
    /// one outcome this is not for. When the prizes all carry the same odds there is no rare tier to
    /// find and every prize is boosted instead: the mix is unchanged, but the drop gets likelier, which
    /// is as much as odds that state no rarity can say. (A weighted table with no
    /// <see cref="LootEntryKind.Nothing"/> entry already drops on every roll, so scaling every prize
    /// there changes nothing at all.) If you want the tiers stated explicitly, replace
    /// <see cref="IsRare"/> with a list of item GUIDs — bearing in mind that same fallback, so a table
    /// none of your listed ids appear in boosts every prize.</para>
    ///
    /// <para><b>Both table modes are handled,</b> because they express odds differently.
    /// <see cref="LootTableMode.Weighted"/> picks one entry per roll by relative weight, so scaling a
    /// weight is enough. <see cref="LootTableMode.IndependentChance"/> tests every entry against its own
    /// chance, where weight is not consulted at all — there the boost has to move
    /// <see cref="LootEntry.chance01"/>, and it saturates at 1.</para>
    ///
    /// <para><b>Nested tables still work.</b> Entries are copied wholesale, so a
    /// <see cref="LootEntryKind.Table"/> entry keeps pointing at its child table and the roller
    /// descends into it as usual. The child's own odds are not rewritten — only the chance of reaching
    /// it. Rewriting a whole tree would mean deriving a copy per node, which is a different recipe and a
    /// much less drop-in one. One caveat rides along with the copy: the roller's cycle guard compares
    /// table identity, and a copy is never identical to its source, so a table that references
    /// <i>itself</i> is stopped one descent later on a boosted roll than on the authored asset — it
    /// still terminates, but it awards that extra descent. A self-referencing table is an authoring
    /// mistake the roller already warns about; do not pair one with this recipe.</para>
    /// </remarks>
    /// <example>
    /// Wire it to whatever already decides that a drop happens — a death handler, a chest, a quest
    /// reward:
    /// <code>
    /// [SerializeField] private ConditionWeightedLootDrop drop;
    ///
    /// private void OnEnemyKilled(GameObject killer) => drop.RollAndGrant(killer);
    /// </code>
    /// </example>
    public sealed class ConditionWeightedLootDrop : MonoBehaviour
    {
        [Tooltip("The service that rolls and delivers. Leave empty to find one in the scene on first use.")]
        [SerializeField] private LootService loot;

        [Tooltip("The authored table. It is read, never modified.")]
        [SerializeField] private LootTable sourceTable;

        [Tooltip("Extra weight the rare entries get at zero health, as a multiple. 2 means triple weight " +
                 "on death's door, unchanged at full health, and scaled linearly in between.")]
        [SerializeField, Min(0f)] private float maxLowHealthBonus = 2f;

        [Tooltip("Optional status that also boosts the rare entries while it is active — a 'lucky' buff. " +
                 "Leave empty to ignore status entirely. Ids are the ones you apply, e.g. \"haste\" — " +
                 "compared ordinally, and every id RevFramework ships is lowercase.")]
        [SerializeField] private string luckyStatusId = "";

        [Tooltip("Multiplier applied on top while the status above is active. 1 leaves the odds as they " +
                 "were; clear the id above to switch the status half off. It will not go below 1 — a " +
                 "value under 1 scales the rare entries down, and 0 strips them out of the table " +
                 "altogether, so a 'lucky' buff would make drops impossible.")]
        [SerializeField, Min(1f)] private float luckyMultiplier = 1.5f;

        /// <summary>
        /// Rolls the table with the recipient's condition folded into the odds, and grants the result.
        /// </summary>
        /// <param name="recipient">
        /// Whose condition shifts the odds, and who receives the award. A recipient with neither health
        /// nor statuses is not an error — it simply rolls the authored odds.
        /// </param>
        /// <param name="container">Container for item awards, or null for the service's default.</param>
        /// <param name="spawnAt">Where pickups spawn, or null for the recipient's position.</param>
        /// <returns>
        /// What the roll produced, whether or not every award could be delivered, or
        /// <see cref="LootResult.Empty"/> if nothing was won. A full container, a missing adapter or an
        /// unresolvable item GUID all mean an award was won and not received;
        /// <see cref="LootService.Granted"/>, <see cref="LootService.Spawned"/> and
        /// <see cref="LootService.Undelivered"/> are what report where it actually went.
        /// </returns>
        public LootResult RollAndGrant(GameObject recipient, string container = null, Vector3? spawnAt = null)
        {
            if (recipient == null)
                return LootResult.Empty;

            if (sourceTable == null)
            {
                Debug.LogWarning($"[{nameof(ConditionWeightedLootDrop)}] '{name}' has no source table " +
                                 "assigned, so nothing was rolled.", this);
                return LootResult.Empty;
            }

            // Not cached in Awake on purpose: this component is usable from a prefab that was never in
            // a scene when the service appeared, and a stale reference is worse than a lookup. Inactive
            // ones count — a service parked on a bootstrap object that happens to be switched off at
            // this instant is still the service, and the no-argument overload would not see it.
            if (loot == null)
                loot = FindAnyObjectByType<LootService>(FindObjectsInactive.Include);

            if (loot == null)
            {
                // Returning quietly would be indistinguishable from bad luck: LootResult.Empty is the
                // same value a roll that awarded nothing returns, so a scene with no service looks
                // exactly like a run of unlucky kills. The authoring mistake has to say so itself.
                Debug.LogWarning($"[{nameof(ConditionWeightedLootDrop)}] '{name}' found no " +
                                 $"{nameof(LootService)} in the scene, so nothing dropped.", this);
                return LootResult.Empty;
            }

            float boost = BoostFor(recipient);

            // A boost of 1 is the authored table. Rolling the original avoids allocating a copy for the
            // common case of a healthy recipient with no buff.
            if (Mathf.Approximately(boost, 1f))
                return loot.RollAndGrant(sourceTable, recipient, container, spawnAt);

            LootTable derived = Derive(sourceTable, boost);

            try
            {
                return loot.RollAndGrant(derived, recipient, container, spawnAt);
            }
            finally
            {
                // LootTable.Create returns a ScriptableObject 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,
                // and a table derived per kill would otherwise leak an object per kill for the lifetime
                // of the process. Destroying it here is the whole of the cleanup: the result holds ids,
                // not table references, and any nested table it pointed at is an authored asset this
                // never owned.
                //
                // The isPlaying branch is not defensiveness. Destroy defers to the end of the frame and
                // refuses to run outside play mode, so an edit-mode test calling this would log an error
                // and leak the very object this exists to release.
                if (Application.isPlaying)
                    Destroy(derived);
                else
                    DestroyImmediate(derived);
            }
        }

        /// <summary>
        /// The multiplier the rare entries get for this recipient. 1 means "roll the table as authored".
        /// </summary>
        /// <remarks>
        /// Public so the number can be shown in a HUD or asserted in a test without rolling anything —
        /// odds you cannot inspect are odds you cannot tune.
        /// </remarks>
        public float BoostFor(GameObject recipient)
        {
            if (recipient == null)
                return 1f;

            float boost = 1f;

            // Interface, not HealthSystem: anything that reports health this way drives the odds,
            // including a custom implementation of your own.
            if (recipient.TryGetComponent<IHealthReadonly>(out var health) && health.Max > 0)
            {
                float hurt = 1f - Mathf.Clamp01(health.Normalized01);
                boost += hurt * maxLowHealthBonus;
            }

            // Trimmed because this is the one identifier the recipe takes as free inspector text, and
            // StatusId equality is ordinal with no normalisation: "haste " would match nothing, and a
            // status that never matches looks exactly like a status that is not active.
            if (!string.IsNullOrWhiteSpace(luckyStatusId) &&
                recipient.TryGetComponent<IStatusEffectController>(out var status) &&
                status.HasStatus(new StatusId(luckyStatusId.Trim())))
            {
                boost *= luckyMultiplier;
            }

            return boost;
        }

        /// <summary>
        /// Builds the runtime table this roll actually uses.
        /// </summary>
        private static LootTable Derive(LootTable source, float boost)
        {
            IReadOnlyList<LootEntry> authored = source.Entries;
            var entries = new LootEntry[authored.Count];

            bool weighted = source.Mode == LootTableMode.Weighted;
            float mean = MeanOdds(authored, weighted);

            // No prize sits strictly below the mean exactly when every prize carries the same odds,
            // and one prize beside a Nothing entry — the commonest weighted table there is — is that
            // case: the single prize *is* the mean. Boosting every prize there is the honest reading,
            // since there is no tier to favour but the drop can still get likelier. Without it the
            // recipe would allocate a copy per kill to reproduce the authored table exactly.
            bool anyRare = false;

            for (int i = 0; i < authored.Count && !anyRare; i++)
                anyRare = IsRare(authored[i], mean, weighted);

            for (int i = 0; i < authored.Count; i++)
            {
                // LootEntry is a struct, so this is already a copy — mutating it cannot reach the asset.
                LootEntry entry = authored[i];

                if (anyRare ? IsRare(entry, mean, weighted) : IsPrize(entry, weighted))
                {
                    if (weighted)
                        entry.weight *= boost;
                    else
                        entry.chance01 = Mathf.Clamp01(entry.chance01 * boost);
                }

                entries[i] = entry;
            }

            LootTable derived = LootTable.Create(
                source.Mode, source.RollsMin, source.RollsMax, source.AllowDuplicates, entries);

            // Create leaves the instance unnamed, and every authoring warning the roller raises is keyed
            // on the table's name. Unnamed, a mis-authored row reports nothing identifiable on exactly
            // the rolls a hurt recipient triggers — diagnosable at full health and not otherwise, which
            // is worse than never.
            derived.name = source.name + " (boosted)";

            return derived;
        }

        /// <summary>
        /// Mean of whichever field decides this table's odds, across the prize entries. Zero when the
        /// table has none.
        /// </summary>
        /// <remarks>
        /// Which field that is depends on the mode, and reading the wrong one fails quietly rather than
        /// loudly: an <see cref="LootTableMode.IndependentChance"/> table is authored through
        /// <see cref="LootEntry.chance01"/> and may well leave every weight at zero, so measuring
        /// rarity by weight there would find no rare entries and boost nothing, with no error to notice.
        /// </remarks>
        private static float MeanOdds(IReadOnlyList<LootEntry> entries, bool weighted)
        {
            float total = 0f;
            int count = 0;

            for (int i = 0; i < entries.Count; i++)
            {
                if (!IsPrize(entries[i], weighted))
                    continue;

                total += Odds(entries[i], weighted);
                count++;
            }

            return count == 0 ? 0f : total / count;
        }

        /// <summary>
        /// Whether an entry is something the recipient can actually win, and so counts as a prize.
        /// </summary>
        /// <remarks>
        /// A "Nothing" entry never is, however it is weighted: it is the one entry kind where low odds
        /// mean "this rarely happens" rather than "this is a good prize", and it is what the boost
        /// exists to win against — so it neither takes the boost nor votes on what "rare" means. An
        /// entry with no odds at all cannot be picked, so it is not a prize either. The mean and the
        /// rare test share this one definition on purpose: measuring rarity against a bar that counts
        /// non-prizes is how a boost ends up applying to every prize equally, which changes the drop
        /// rate and leaves the mix exactly as authored.
        /// </remarks>
        private static bool IsPrize(in LootEntry entry, bool weighted)
            => entry.kind != LootEntryKind.Nothing && Odds(entry, weighted) > 0f;

        /// <summary>
        /// Whether an entry is one of the rare ones, and so a candidate for the boost.
        /// </summary>
        /// <remarks>
        /// Replace this if your tables state rarity explicitly.
        /// </remarks>
        private static bool IsRare(in LootEntry entry, float meanOdds, bool weighted)
            => IsPrize(entry, weighted) && meanOdds > 0f && Odds(entry, weighted) < meanOdds;

        /// <summary>The field that decides this entry's odds, given the table's mode.</summary>
        private static float Odds(in LootEntry entry, bool weighted) => weighted ? entry.weight : entry.chance01;
    }
}

Wiring it up

  1. Put the component anywhere convenient — the spawner, the enemy prefab, a manager object.
  2. Assign the authored Loot Table. Leave Loot empty and it finds the LootService in the scene on first use.
  3. Call it from whatever already decides a drop happens:

    private void OnEnemyKilled(GameObject killer) => drop.RollAndGrant(killer);
    

That is the entire integration. Everything else is tuning.

Tuning

Field What it does
maxLowHealthBonus Extra weight the rare entries get at zero health, as a multiple. 2 means triple weight on death's door, unchanged at full health, linear in between. 0 disables the health half.
luckyStatusId Optional status that boosts the rare entries while active. Empty ignores status entirely.
luckyMultiplier Applied on top while that status is on the recipient.

BoostFor(recipient) returns the multiplier without rolling anything, so you can put it on a debug HUD or assert it in a test. Odds you cannot inspect are odds you cannot tune.

What it deliberately does not do

Rarity is inferred, not authored. An entry counts as rare when its odds are below the mean of the entries that can be won. That is what keeps this drop-in — there is no second list to maintain alongside the table — but a table whose entries all carry the same weight has no rare tier, and nothing to boost. If your tables state rarity explicitly, replace IsRare with a lookup against your own list; nothing else in the class changes.

Nested tables keep their own odds. Entries are copied wholesale, so a Table entry still points at its child and the roller still descends into it. What shifts is the chance of reaching the child, not the odds inside it. Rewriting a whole tree means deriving a copy per node, which is a different recipe and a much less drop-in one.

Both table modes are handled, and they are not interchangeable. Weighted picks one entry per roll by relative weight; IndependentChance tests every entry against its own chance01 and never consults weight at all. Measuring rarity by weight in an independent-chance table would find nothing rare and boost nothing, silently — so the recipe reads whichever field that table's mode actually uses.

  • Loot — the system guide: tables, modes, adapters, and what a roll does end to end.
  • HealthIHealthReadonly is the only thing this recipe needs from it, so any implementation of your own drives the odds just as well.
  • Status Effects — for the ids you can name in luckyStatusId.