Skip to content

A pity counter, built on the seam that cannot do pity counters

After enough rolls that gave you nothing you wanted, the next thing you win is swapped for something from a table you author. The odds never move.

Recipe

Systems required: Loot. Package: Inventory, Pickups & Crafting, or Complete. Shape: one class you drop into a project that already exists. No scene, 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

This composition is written down as blocked, and it was — in the form it was first imagined.

A pity counter usually means "raise the odds after a dry spell", and ILootModifier cannot do that. Modifiers run against the rolled result, not against the table. The interface says so, and gives the reason:

a table whose printed weights do not describe its behaviour is very hard to reason about

That is a design position, not an omission. So the seam is not going to grow an odds hook, and any recipe that needs one is genuinely stuck.

So change what you won, not what could have dropped

The player cannot tell the difference. They stop going home empty-handed after a bad run, which is the entire point of a pity counter — and the numbers printed on the table still mean exactly what they say.

A blocked composition is often only blocked in the shape you first reached for. Ask what the player is supposed to experience, then check whether some other seam produces that experience. This one was on the blocked list from the very first day of this cookbook.

This is the first implementation of ILootModifier anywhere

Runtime, Integrations, Samples and Teaching contain none. It is a shipped extension point that has never had a worked example — the only other implementation in the repository is a test.

Wiring is a component: the service collects modifiers with GetComponentsInParent, so adding one to the roll's owner — the actor the awards are attributed to — or any parent of it is the whole step. No registration.

A modifier is told about awards; a pity counter needs rolls

There is no "a roll started" or "a roll finished" callback. ModifyItem is called once per award, so a roll that produced three items looks exactly like three rolls that produced one — and a streak counted in that method would be counting items, not kills.

LootService.Rolled closes the gap, and the ordering is what makes it work

The service applies every modifier first and raises Rolled afterwards, with the finished result. So the awards get observed on the way past, and the streak is settled once, at the end.

That ordering is not incidental — it is what lets a modifier both participate in a roll and react to the whole of it.

Rolling again from inside a modifier is supported

And the framework says so in its own source. The loot service's reentrancy comment names a pity counter sampling another drop as the reason it allocates fresh buffers for a nested roll. That is what lets the consolation prize come from a real authored table instead of a single item field.

But roll it for nobody

Passing null keeps the consolation prize out of reach of the luck modifiers that just failed to save the run — and the service skips the modifier gather altogether for a null owner, so the roll never comes back round.

The _rollingPity flag is what makes the tempting edit — "just pass the owner so the pity drop gets the player's luck too" — merely wrong rather than fatal. With an owner passed, the service gathers this component again and re-enters ModifyItem, which the flag turns back at the door. Take the flag away as well and the re-entered modifier rolls again, and again, until the stack goes.

The nested roll is a real roll either way, and the service announces it like one: Rolled fires for it with a null owner, carrying a result nobody is granted. This component ignores its own, but anything else in your project subscribed to Rolled will see it and has to tolerate that null.

Two service behaviours worth relying on

Unticking the component switches it off; deactivating its GameObject does not

The service gathers with includeInactive: true and then drops any modifier whose enabled box is clear. A component on an object that has not been activated yet was switched off by nobody, so it still runs. That is deliberate on the framework's side.

It applies to taking part in a roll, not to counting one. This recipe's Rolled subscription is made in OnEnable and dropped in OnDisable, which follow activation as well as the tick — so a deactivated GameObject still lets the modifier substitute an award while the streak quietly stops advancing. Half the component is gated on enabled and half on activation, and that is worth knowing before you bench an actor mid-run.

Ties run in Inspector order, on purpose

Modifiers are sorted by Priority with a stable insertion sort rather than List.Sort, precisely so two modifiers left at the same priority run top to bottom as they sit on the object. You can rely on that ordering rather than setting priorities defensively.

Drop it in

PityUpgrade.cs
using System;

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

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.PityUpgrade
{
    /// <summary>
    /// A pity counter — after enough rolls that gave you nothing you wanted, the next award you win is
    /// swapped for one rolled from a table you author.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
    /// <b>Loot</b>. Public API only.</para>
    ///
    /// <para><b>This composition was written down as blocked, and it was — in the form it was first
    /// imagined.</b> A pity counter usually means "raise the odds after a dry spell", and
    /// <see cref="ILootModifier"/> cannot do that: modifiers run against the rolled result, not against
    /// the table. The interface says so and gives the reason, which is a real design position rather
    /// than an omission — <i>a table whose printed weights do not describe its behaviour is very hard
    /// to reason about.</i></para>
    ///
    /// <para><b>So this changes what you won instead of what could have dropped, and the player cannot
    /// tell the difference.</b> They stop going home empty-handed after a bad run, which is the entire
    /// point of a pity counter. The odds on the table still mean exactly what they say.
    /// <b>Worth generalising: a blocked composition is often only blocked in the shape you first
    /// reached for.</b> Ask what the player is supposed to experience, then check whether some other
    /// seam produces that experience.</para>
    ///
    /// <para><b>A modifier is told about awards; a pity counter needs rolls.</b> There is no callback
    /// for "a roll started" or "a roll finished" — <see cref="ModifyItem"/> is simply called once per
    /// award, and a roll that produced three items looks exactly like three rolls that produced one.
    /// <see cref="LootService.Rolled"/> closes the gap, and the ordering is the part that makes it
    /// work: the service applies every modifier <i>first</i> and raises <c>Rolled</c> afterwards with
    /// the finished result. So the awards are observed on the way past and the streak is settled once,
    /// at the end.</para>
    ///
    /// <para><b>Rolling again from inside a modifier is supported, and the framework says so in its own
    /// source</b> — the loot service's reentrancy comment names a pity counter as the reason it
    /// allocates fresh buffers for a nested roll. That is what lets the pity award come from a real
    /// authored table rather than a single item field.</para>
    ///
    /// <para><b>But roll it for nobody.</b> Passing <c>null</c> keeps the consolation prize out of reach
    /// of the luck modifiers that just failed to save the run, and the service skips the modifier gather
    /// altogether for a null owner. The <c>_rollingPity</c> flag is what makes the tempting edit — "just
    /// pass the owner so the pity drop gets the player's luck too" — merely wrong rather than fatal:
    /// with an owner passed, the service gathers this component again and re-enters
    /// <see cref="ModifyItem"/>, which the flag turns back at the door. Take the flag away as well and
    /// the re-entered modifier rolls again, and again, until the stack goes.</para>
    ///
    /// <para><b>The nested roll is a real roll, and the service announces it like one.</b>
    /// <see cref="LootService.Rolled"/> is raised for it too, with a null owner, carrying a result
    /// nobody is granted. This component ignores its own; anything else in the project subscribed to
    /// <c>Rolled</c> will see it and has to tolerate that null.</para>
    ///
    /// <para><b>Unticking the component switches it off; deactivating its GameObject does not.</b> The
    /// service gathers modifiers with <c>includeInactive: true</c> and then drops any whose
    /// <c>enabled</c> box is clear. A component on an object that has not been activated yet was
    /// switched off by nobody, so it still runs. That is deliberate on the framework's side and it is
    /// the behaviour to rely on — but it applies to taking part in a roll, not to counting one. The
    /// <c>Rolled</c> subscription is made in <c>OnEnable</c> and dropped in <c>OnDisable</c>, so a
    /// deactivated GameObject still lets this modifier substitute an award while the streak stops
    /// advancing. Half of it is gated on <c>enabled</c> and half on activation.</para>
    ///
    /// <para><b>Ties run in Inspector order, on purpose.</b> The service sorts modifiers by
    /// <see cref="Priority"/> with a stable insertion sort rather than <c>List.Sort</c>, precisely so
    /// that two modifiers left at the same priority run top to bottom as they sit on the object.</para>
    ///
    /// <para><b>It watches the owner, not the roller.</b> The service gathers modifiers by walking up
    /// from the GameObject passed as <c>owner</c> to <c>Roll</c>/<c>RollAndGrant</c> — the actor the
    /// awards are attributed to. Put this component there, or on any parent of it. With
    /// <c>LootDropOnDeath</c> that is whatever is set in <c>recipient</c>; leave <c>recipient</c> empty
    /// and the owner is the dying object, which is not what a counter meant to accumulate wants.</para>
    ///
    /// <para><b>Anything not listed in <c>jackpotIds</c> can be taken away.</b> The list does two jobs:
    /// it decides which rolls are lucky, and it is the only thing that protects an award from being
    /// substituted. The swap lands on the first item award of the roll whatever it was worth, so a rare
    /// drop that is not on the list can be replaced by the consolation prize. List everything you would
    /// not want taken away, not only what you are chasing.</para>
    ///
    /// <para><b>The streak belongs to this component, not to an actor.</b> Put it above several actors
    /// and they share one counter — which is either a party-wide pity pool or a bug, depending on what
    /// you meant. One per actor is the usual answer. It is session-only, too: the streak and the armed
    /// payout live in plain fields, so a save, a scene load or a domain reload starts it again from
    /// zero. Persisting it is a different recipe.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    public sealed class PityUpgrade : MonoBehaviour, ILootModifier
    {
        [Header("Bindings")]
        [Tooltip("Loot service performing the rolls you want counted. Auto-resolution picks an arbitrary " +
                 "one and is only safe when the scene has exactly one.")]
        [SerializeField] private LootService lootService;

        [Tooltip("Table rolled to produce the consolation award. Its first item award replaces the one " +
                 "that was won. Author it so it always produces an item: currency entries here are " +
                 "ignored, and a roll that produces none leaves the counter armed to try again.")]
        [SerializeField] private LootTable pityTable;

        [Header("Rules")]
        [Tooltip("Item ids you are actually chasing. A roll containing any of these is a lucky roll and " +
                 "clears the streak. It is also the only protection from substitution, so list " +
                 "everything you would not want swapped away. Leave empty and every roll counts as " +
                 "unlucky.")]
        [SerializeField] private string[] jackpotIds = Array.Empty<string>();

        [Tooltip("Unlucky rolls needed before the next award is upgraded.")]
        [SerializeField, Min(1)] private int unluckyRollsBeforePity = 10;

        [Tooltip("Order this modifier runs in. Lower runs first; ties run in component order.")]
        [SerializeField] private int priority;

        private int _streak;
        private bool _armed;
        private bool _sawJackpot;
        private bool _rollingPity;
        private bool _warnedNoService;
        private bool _warnedNoTable;

        /// <inheritdoc />
        public int Priority => priority;

        /// <summary>
        /// Unlucky rolls counted since the last lucky one.
        /// </summary>
        /// <remarks>
        /// Exposed for a UI readout — a visible pity counter is most of why players tolerate one.
        /// </remarks>
        public int UnluckyStreak => _streak;

        /// <summary>True once the streak has tripped and the next item award will be upgraded.</summary>
        public bool Armed => _armed;

        private void OnEnable()
        {
            // A jackpot can be seen while the GameObject is deactivated -- the service still gathers this
            // modifier -- but no roll boundary arrives to settle it. Cleared here so a stale one cannot
            // reach across the gap and reset the streak on the first roll counted after reactivation.
            _sawJackpot = false;

            if (!pityTable && !_warnedNoTable)
            {
                // The other half of the silent-no-op guard below, and the likelier half: this slot cannot
                // auto-resolve, so an unassigned one arms the counter and then never pays it out.
                _warnedNoTable = true;
                Debug.LogWarning(
                    $"[Cookbook] '{name}' has no pity table assigned, so the counter will arm and then " +
                    "never pay out. Assign a table that always produces at least one item award.", this);
            }

            if (!lootService)
                lootService = FindAnyObjectByType<LootService>(FindObjectsInactive.Include);

            if (!lootService)
            {
                // Warned once and only here. Without the service there are no roll boundaries, so the
                // streak never advances and the component silently does nothing at all -- the worst
                // possible failure for a mechanic whose whole job is to eventually fire.
                if (!_warnedNoService)
                {
                    _warnedNoService = true;
                    Debug.LogWarning(
                        $"[Cookbook] '{name}' found no LootService, so rolls cannot be counted and the " +
                        "pity counter will never trip. Assign one.", this);
                }

                return;
            }

            lootService.Rolled += OnRolled;
        }

        private void OnDisable()
        {
            if (lootService)
                lootService.Rolled -= OnRolled;
        }

        /// <summary>
        /// Replaces the award with a pity roll once the streak has tripped.
        /// </summary>
        /// <returns>
        /// The award unchanged in the ordinary case, and always for an award an earlier modifier zeroed.
        /// A consolation award when the counter has tripped and the pity table actually produced an item.
        /// </returns>
        public LootItemGrant ModifyItem(GameObject owner, in LootItemGrant grant)
        {
            // Rolled quantities are always at least one, so a zero is an earlier modifier having removed
            // the award -- the framework's supported way to do that. Left alone on both counts: reviving
            // it would reverse someone else's decision, and it is not a win, so it is not a jackpot.
            if (grant.quantity <= 0)
                return grant;

            // Noted on the way past rather than counted here: this runs once per award, so incrementing
            // anything in this method would count items rather than rolls.
            if (IsJackpot(grant.itemGuid))
            {
                _sawJackpot = true;
                return grant;
            }

            if (!_armed || _rollingPity || !lootService || !pityTable)
                return grant;

            LootItemGrant replacement;

            _rollingPity = true;
            try
            {
                // Owner is null on purpose -- see the class remarks. It stops the service gathering this
                // very component again, and keeps the consolation prize out of reach of luck modifiers
                // that would otherwise compound with the mechanic meant to rescue a bad run.
                LootResult pity = lootService.Roll(pityTable, null);

                if (pity == null || pity.Items == null || pity.Items.Count == 0)
                    return grant;  // Nothing to give. Stay armed rather than spending the streak on air.

                replacement = pity.Items[0];
            }
            finally
            {
                _rollingPity = false;
            }

            _armed = false;

            // A pity table usually hands over the very thing being chased, and the incoming grant is the
            // only award IsJackpot was asked about. Without this, the roll that finally delivered a chase
            // item settles as unlucky and advances the streak it just reset.
            if (IsJackpot(replacement.itemGuid))
                _sawJackpot = true;

            return replacement;
        }

        /// <summary>
        /// Passes currency awards through untouched.
        /// </summary>
        /// <remarks>
        /// Deliberate. The consolation prize is an item, and topping up the gold as well would pay the
        /// streak out twice for one dry spell. A currency-only pity counter is a different component
        /// and a different decision.
        /// </remarks>
        public LootCurrencyGrant ModifyCurrency(GameObject owner, in LootCurrencyGrant grant) => grant;

        /// <summary>
        /// Settles the streak at the end of a roll.
        /// </summary>
        /// <remarks>
        /// Fires after every modifier has run, with the finished result — which is why the awards can be
        /// observed in <see cref="ModifyItem"/> and the decision taken here.
        /// </remarks>
        private void OnRolled(GameObject owner, LootResult result)
        {
            // The nested pity roll raises this too, with a null owner. Ignored on both counts: it is not
            // a roll the player made, and settling the streak from inside a roll that is still finishing
            // would count one kill twice.
            if (_rollingPity || !Owns(owner))
                return;

            bool lucky = _sawJackpot;
            _sawJackpot = false;

            // A roll with no items at all is not a dry spell, it is a roll this mechanic has no opinion
            // about -- there was nothing for a pity upgrade to replace even if it had fired.
            if (result == null || result.Items == null || result.Items.Count == 0)
                return;

            if (lucky)
            {
                _streak = 0;
                return;
            }

            _streak++;

            if (_streak < unluckyRollsBeforePity)
                return;

            // Reset on arming rather than on payout. The counter has done its job the moment it trips,
            // and leaving it high would let a roll that produced no items -- which this method ignores --
            // arm it a second time before the first upgrade was ever handed over.
            _streak = 0;
            _armed = true;
        }

        /// <summary>
        /// Whether a roll made for this owner is one this component should count.
        /// </summary>
        /// <remarks>
        /// The service raises <c>Rolled</c> for every roll it makes, not only for the actors below this
        /// object, so an unfiltered handler would count other people's kills. Modifiers are gathered
        /// with <c>GetComponentsInParent</c>, so the rolls this component actually modified are exactly
        /// those whose owner sits at or below it.
        /// </remarks>
        private bool Owns(GameObject owner)
            => owner && owner.transform.IsChildOf(transform);

        private bool IsJackpot(string itemGuid)
        {
            if (jackpotIds == null || jackpotIds.Length == 0 || string.IsNullOrEmpty(itemGuid))
                return false;

            for (int i = 0; i < jackpotIds.Length; i++)
            {
                // Ordinal, because these are ids rather than words.
                if (string.Equals(jackpotIds[i], itemGuid, StringComparison.Ordinal))
                    return true;
            }

            return false;
        }
    }
}

Wiring it up

  1. Put the component on the GameObject you pass as owner to Roll/RollAndGrant — the actor the awards are attributed to, not the thing that died — or on any parent of it. With LootDropOnDeath that is whatever is set in recipient; leave recipient empty and the owner is the dying object, which is not what you want here.
  2. Assign the loot service that performs the rolls you want counted. Auto-resolution picks an arbitrary one and is only safe when the scene has exactly one.
  3. Assign a pity table. Its first item award is what replaces the win, so author it to always produce at least one item: currency entries in this table are ignored, and a roll that produces no item leaves the counter armed to try again on the next award.
  4. List the item ids you are actually chasing — and anything else you would not want swapped away, because that list is also what protects an award from substitution. Then pick how many unlucky rolls it takes.
  5. Nothing else — the service finds the modifier by walking up from the owner.

What it deliberately does not do

It does not touch the odds. That is the whole premise. Anything that reads the table — a drop-rate display, a wiki — stays honest. A player counting drops is a different matter: over enough rolls the floor shows up in the observed distribution, because that is the mechanic. A pity counter is something you advertise, not something you hide.

It does not compare what it is replacing. The swap takes the first item award of the roll whatever it was worth, and jackpotIds is the only thing that makes an award immune. A rare drop you forgot to list can be taken away and replaced by the consolation prize, with nothing reported anywhere.

It does not top up currency. ModifyCurrency passes through untouched. The consolation prize is an item, and inflating the gold as well would pay one dry spell out twice. A currency pity counter is a different component and a different decision.

It does not count rolls that produced no items. There would have been nothing for the upgrade to replace even if it had fired, so a currency-only roll is a roll this mechanic has no opinion about rather than a dry spell.

It does not spend the streak on an empty table. If the pity table rolls no item, the award is returned unchanged and the counter stays armed for the next one. It does spend the streak the moment the substitution is made, though, which is before delivery — a pity award the player has no room for is reported through the service's Spawned/Undelivered events, and this component does not watch them.

It does not give each actor its own streak. The counter belongs to the component. Put it above several actors and they share one, which is either a party-wide pity pool or a bug depending on what you meant — one per actor is the usual answer.

It does not survive a save. The streak and the armed payout live in plain fields on the component, so a save and reload, a scene load or a domain reload starts it again from zero — including a payout the player had already earned. Persisting it is the pattern in A chest that stays looted.