Skip to content

Only your worst debuff counts

Three curses land on the same player. Multiplied together that is a 3.4x hit, which is not what anyone tuning the first curse had in mind. Here the strongest one wins and the other two sit there looking menacing — while the resistance the player is wearing still applies on top.

Recipe

Systems required: Health. Package: Health & Status Effects, or Complete. Shape: one class you drop into a project that already exists. No prefab, no bespoke scene. Public API only. It assumes: the component sits on the actor being hit, on the same object as its health component and its DamageRuleHub. Both halves are found from that one object, so a child will not do. 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

IDamageTakenModifier is a two-method interface in Core.Abstractions that nothing in the framework implements. It is not abandoned surface — it is a standing offer, and VulnerabilityStatus makes it in the first four lines of its Apply:

// An explicit sink on the target wins. A project that implements IDamageTakenModifier has
// said where it wants incoming-damage scaling to live, and routing past it to Health would
// apply the debuff twice on any object that has both.
if (target.TryGetComponent<IDamageTakenModifier>(out var s))
{
    sink = s;
    sink.AddMultiplier(appliedMultiplier);
    return;
}

Implementing it is a routing decision, not just an implementation

That return is the whole point. Put one of these on an actor and every status-driven damage-taken multiplier stops going to Health's DamageTakenMultiplierRule and comes to you instead — including the retune when a magnitude changes, and the withdrawal when the effect expires.

You are not decorating the pipeline. You are being handed the question.

Two seams against one hit, because neither half is enough

IDamageTakenModifier is where contributions arrive; it has no idea a hit is happening. IDamageRule is where a hit is decided; it is never told a debuff was applied.

One class implements both, which is what lets it answer the only interesting question — how do these combine? — rather than forwarding it. This is the fourth recipe to land on that shape, after the pity counter, looted once and healing fatigue, and the reason is always the same: a mechanic with a policy needs somewhere to put the policy.

The rule it installs, stated exactly

Amplifiers compete. Reducers still multiply.

Anything above 1x is an amplifier, and only the largest one applies. Anything at or below 1x is a reducer, and they all multiply as before.

So two 1.5x curses are 1.5x, not 2.25x — and a 0.5x ward on top of them is 0.75x, exactly as it would have been. Buffs and resistances are untouched; only the pile-on stops.

That distinction is easy to miss and it is the difference between a mechanic and a bug. "Strongest wins" applied to the whole stack would quietly discard every resistance the player is wearing, since a 0.5x ward would lose to a 1.5x curse and vanish.

The cap is the mechanic

Once amplifiers stop stacking there is nothing left to clamp on that side — the ceiling is whatever the biggest single debuff is, which is the number somebody already tuned. The maxFactor field is only there for a single absurd contribution, and the minFactor floor is the one direction the design genuinely leaves open, because reducers multiply without limit.

Zero means immune, not "almost nothing"

A contribution of zero cancels the hit outright rather than being clamped up to the floor. Clamping would let five percent through, and something that pushed a zero was saying immune.

The empty ledger has to be a special case, and that is not obvious

A component holding no contributions must not change the hit. That sounds free and it is not: combining nothing gives 1x, and clamping 1x between a floor and a ceiling only gives back 1x while the floor is at or below 1. Set a floor above 1 and an actor carrying no debuffs at all starts taking more damage.

Measured rather than argued: with the early-out removed and the floor at 2, a probe's 100-point hit lands for 200 against an actor with nothing on it.

Smaller things worth knowing

A withdrawal takes one contribution, never all of them

Matching is by value because the interface gives you no other handle — there is no ticket — so two effects contributing 1.5x are interchangeable. Removing every match would hand back somebody else's debuff.

The framework says this about its own push/pop pair, in VulnerabilityStatus: an unmatched pop removes somebody else's and an unmatched push leaves the target permanently more fragile. The same discipline applies on this side of the seam.

An unmatched withdrawal warns, and only in the Editor

It means some caller's accounting is already wrong, and there is nothing to recover. The diagnostic is editor-only because the behaviour is identical in a player — that is the line worth holding: gating a warning is fine, gating a decision is not. See a shield that spends money for the case where a framework warning was gated and a real defect went silent in release builds.

It does not clear itself when disabled, and the shipped rule does

DamageRuleHub.WillRun already skips a rule whose component is unticked or whose object is inactive, so clearing buys no stand-down — it only throws away bookkeeping something else still believes in. A status that pushed a contribution cannot know you unticked a box, so re-enabling with an emptied ledger would silently drop live debuffs.

The cost of that choice is pooling: an actor put back in the pool while cursed comes out of it cursed. Clear() is public for exactly that, in the same spirit as healing fatigue's.

Do not destroy this component while effects are holding it

VulnerabilityStatus caches the sink in an interface-typed field and tests it with != null. That is an ordinary reference comparison — Unity's destroyed-object check does not apply to interface references — so a destroyed sink is not detected and the withdrawal throws. Put it on the actor and leave it there for the actor's lifetime.

Reading the factor costs nothing

Apply runs under PreviewDamage as well as under the real hit, so a rule that advanced state there would make forecasting a hit change the hit. The combination is settled when a contribution arrives, which also means a hit never walks the list however many debuffs are running.

Status Effects is not required

AddMultiplier and RemoveMultiplier are public. A stance, a difficulty setting or a scripted encounter can push into this with no status system anywhere — Status Effects installed simply means Vulnerability finds it without being told.

Drop it in

WorstDebuffWins.cs
using System.Collections.Generic;

using RevGaming.RevFramework.Core.Abstractions.Combat;

using RevGaming.RevFramework.Health.Abstractions.Contracts;
using RevGaming.RevFramework.Health.Abstractions.Rules;
using RevGaming.RevFramework.Health.Rules.Builtins.Hubs;
using RevGaming.RevFramework.Health.Rules.Builtins.Ordering;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.WorstDebuffWins
{
    /// <summary>
    /// Two curses that each make you take 50% more damage make you take 50% more damage, not 125%
    /// more — while a resistance still applies on top of whichever curse won.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
    /// <b>Health</b> only. Public API only. It goes on the actor being hit, on the same object as its
    /// health component and its <see cref="DamageRuleHub"/> — both halves are found from that one
    /// object, so a child will not do.</para>
    ///
    /// <para><b>It is the framework's first implementation of <see cref="IDamageTakenModifier"/>, and
    /// that interface is not decoration.</b> <c>VulnerabilityStatus.Apply</c> looks for one on its
    /// target <i>before</i> anything else and returns the moment it finds one, with the reason stated
    /// in its own source: a project that implements this has said where it wants incoming-damage
    /// scaling to live, and routing past it would apply the debuff twice. Nothing in Runtime,
    /// Integrations, Samples or Teaching implements it. So the seam is a standing invitation that has
    /// never been accepted.</para>
    ///
    /// <para><b>Two seams against one hit, and it needs both.</b> <see cref="IDamageTakenModifier"/>
    /// is where contributions <i>arrive</i>; it has no idea a hit is happening. <see cref="IDamageRule"/>
    /// is where a hit is <i>decided</i>; it is never told a debuff was applied. One class implements
    /// both, which is what lets it answer the only question that matters here — <i>how do these
    /// combine?</i> — instead of forwarding it.</para>
    ///
    /// <para><b>What it changes.</b> Health ships <c>DamageTakenMultiplierRule</c>, which multiplies
    /// every contribution together; three stacked 1.5x debuffs make a hit 3.375x. That is a defensible
    /// default and it is not the rule most games want. Here the amplifiers do not stack — only the
    /// largest counts — and everything at or below 1x still multiplies, so resistances, wards and
    /// difficulty scaling compose exactly as before. <b>The cap is the mechanic, so there is nothing
    /// left to clamp on the amplifier side.</b></para>
    ///
    /// <para><b>Status Effects is not required, and that is worth knowing before you wire it.</b>
    /// <see cref="AddMultiplier"/> and <see cref="RemoveMultiplier"/> are public, so a stance, a
    /// difficulty setting or a scripted encounter can push into this with no status system anywhere.
    /// Status Effects installed simply means <c>Vulnerability</c> finds it on its own.</para>
    ///
    /// <para><b>It does not clear itself when disabled, and the shipped rule does.</b>
    /// <see cref="DamageRuleHub.WillRun"/> already skips a rule whose component is unticked or whose
    /// object is inactive, so clearing buys no stand-down — it only throws away bookkeeping that
    /// something else still believes in. A status that pushed a contribution cannot know you unticked
    /// a box, so re-enabling with an emptied stack would silently drop live debuffs. Standing down and
    /// keeping the ledger is the honest behaviour; <see cref="Clear"/> is public for the one case that
    /// genuinely wants a wipe.</para>
    ///
    /// <para><b><see cref="Apply"/> writes nothing.</b> It is run by <c>PreviewDamage</c> as well as by
    /// the real hit, so a rule that advanced state here would make forecasting a hit change the hit.
    /// The combined factor is settled when a contribution arrives, which also means the hit path never
    /// walks the list.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    [RequireComponent(typeof(DamageRuleHub))]
    public sealed class WorstDebuffWins : MonoBehaviour, IDamageTakenModifier, IDamageRule
    {
        [Tooltip("Damage is never scaled below this. Reducers multiply without limit, so this is the " +
                 "one direction the design leaves open. Matches the shipped rule's default.")]
        [SerializeField, Min(0.01f)] private float minFactor = 0.05f;

        [Tooltip("Damage is never scaled above this. Stacking cannot reach it on its own — only a " +
                 "single very large contribution can. Matches the shipped rule's default.")]
        [SerializeField, Min(0.01f)] private float maxFactor = 10f;

        // One entry per live contribution, in arrival order. A list rather than a running number
        // because RemoveMultiplier has to withdraw ONE matching entry: two effects pushing 1.5x are
        // separate contributions, and the first to expire must not take the second one with it.
        private readonly List<float> contributions = new(4);

        private float combined = 1f;

        /// <summary>
        /// Runs at the same point in the PRE chain as the rule it replaces.
        /// </summary>
        /// <remarks>
        /// After armour and affinity, before the safety clamp. Using the shipped constant rather than a
        /// number of its own means a project swapping one component for the other does not also move
        /// where victim-side scaling happens.
        /// </remarks>
        public int Priority => DamageRulePriority.DamageTakenMultiplier;

        /// <summary>
        /// How many live contributions this actor is carrying.
        /// </summary>
        /// <remarks>Read-only and side-effect free, so a debug overlay can show it every frame.</remarks>
        public int Count => contributions.Count;

        /// <summary>
        /// The multiplier the next hit will be scaled by, after combination and clamping.
        /// </summary>
        /// <remarks>
        /// <para>Zero means the next hit is cancelled outright rather than scaled to almost nothing —
        /// a contribution of zero is somebody saying <i>immune</i>, and clamping that up to
        /// <c>minFactor</c> would let five percent through.</para>
        /// <para>This is the readout the shipped rule has no equivalent of: a UI can say <i>you are
        /// taking 50% more damage</i> and name a number the pipeline will actually use.</para>
        /// </remarks>
        public float Factor
        {
            get
            {
                if (contributions.Count == 0) return 1f;
                if (combined == 0f) return 0f;

                float lower = Mathf.Max(0f, minFactor);
                float upper = Mathf.Max(lower, maxFactor);
                return Mathf.Clamp(combined, lower, upper);
            }
        }

        /// <summary>
        /// Records a contribution to how much damage this actor takes.
        /// </summary>
        /// <remarks>
        /// <para>Above 1 amplifies and competes with the other amplifiers; at or below 1 it reduces and
        /// multiplies with the other reducers. Negative values are treated as zero, which is what the
        /// shipped rule does and what a caller passing one almost certainly meant.</para>
        /// <para>Every call must be matched by exactly one <see cref="RemoveMultiplier"/> with the same
        /// value. An unmatched push leaves the actor permanently fragile — the framework's own source
        /// says so beside the code that pairs them.</para>
        /// </remarks>
        /// <param name="multiplier">The factor to contribute.</param>
        public void AddMultiplier(float multiplier)
        {
            contributions.Add(Mathf.Max(0f, multiplier));
            Recompute();
        }

        /// <summary>
        /// Withdraws one contribution matching <paramref name="multiplier"/>.
        /// </summary>
        /// <remarks>
        /// <para><b>One, not all.</b> Matching is by value because that is the only handle the interface
        /// gives — there is no ticket — so two effects contributing the same factor are
        /// interchangeable. Removing every match would hand back somebody else's debuff, which is the
        /// documented hazard on Health's own push/pop pair.</para>
        /// <para>The scan runs from the end so the most recent matching contribution goes first, which
        /// keeps the common apply-then-expire case in step and matches the shipped rule.</para>
        /// <para>An unmatched withdrawal changes nothing and says so in the Editor. It is not a
        /// recoverable condition — it means some caller's accounting is already wrong — and the
        /// diagnostic is editor-only because the <i>behaviour</i> is identical in a player. That is the
        /// distinction worth holding on to: gating a warning is fine, gating a decision is not.</para>
        /// </remarks>
        /// <param name="multiplier">The factor to withdraw. Matched approximately.</param>
        public void RemoveMultiplier(float multiplier)
        {
            float m = Mathf.Max(0f, multiplier);

            for (int i = contributions.Count - 1; i >= 0; i--)
            {
                if (!Mathf.Approximately(contributions[i], m)) continue;

                contributions.RemoveAt(i);
                Recompute();
                return;
            }

#if UNITY_EDITOR
            Debug.LogWarning(
                $"[WorstDebuffWins] Nothing to withdraw for {m}. Something removed a contribution it " +
                "never added, or added one and changed the value before removing it.", this);
#endif
        }

        /// <summary>
        /// Drops every contribution.
        /// </summary>
        /// <remarks>
        /// Public because the framework cannot know when your game considers the slate clean. Spawning
        /// a pooled actor is the case that needs it most: the component keeps its ledger across a
        /// disable, deliberately, so an actor put back in the pool while cursed comes out of it cursed.
        /// A revive is the other obvious caller.
        /// </remarks>
        public void Clear()
        {
            contributions.Clear();
            combined = 1f;
        }

        /// <summary>
        /// Scales the incoming hit by the combined factor.
        /// </summary>
        /// <remarks>
        /// <para>Reads only. The list is not walked here — the combination is settled when a
        /// contribution arrives — so a hit costs one comparison and one multiply however many debuffs
        /// are running.</para>
        /// <para>Carrying nothing returns without touching the context at all, rather than multiplying
        /// by a clamped 1. A component holding no contributions has no opinion about the hit, and a
        /// mis-set <see cref="minFactor"/> above 1 would otherwise let it amplify one.</para>
        /// </remarks>
        /// <param name="ctx">The mutable damage context.</param>
        /// <returns><see langword="false"/> only when a zero contribution cancels the hit.</returns>
        public bool Apply(ref DamageContext ctx)
        {
            if (ctx.Cancelled) return true;
            if (contributions.Count == 0) return true;

            if (combined == 0f)
            {
                ctx.Cancelled = true;
                return false;
            }

            float lower = Mathf.Max(0f, minFactor);
            float upper = Mathf.Max(lower, maxFactor);

            ctx.Multiplier *= Mathf.Clamp(combined, lower, upper);
            return true;
        }

        /// <summary>
        /// Settles the combined factor from the live contributions.
        /// </summary>
        /// <remarks>
        /// The whole recipe is these eight lines. Amplifiers compete and the largest wins; reducers
        /// multiply. An actor carrying nothing combines to 1, which is why a lone reducer of 1x and an
        /// empty ledger are the same answer.
        /// </remarks>
        private void Recompute()
        {
            float amplifier = 1f;
            float reducers = 1f;

            for (int i = 0; i < contributions.Count; i++)
            {
                float m = contributions[i];

                if (m > 1f)
                {
                    if (m > amplifier) amplifier = m;
                }
                else
                {
                    reducers *= m;
                }
            }

            combined = amplifier * reducers;
        }

#if UNITY_EDITOR
        private void OnValidate()
        {
            minFactor = Mathf.Max(0.01f, minFactor);
            maxFactor = Mathf.Max(minFactor, maxFactor);
        }
#endif
    }
}

Wiring it up

  1. Put the component on the actor that should stop stacking debuffs — usually the player, and any boss whose fight involves several sources of vulnerability. It brings a DamageRuleHub with it.
  2. Remove DamageTakenMultiplierRule from that object if it has one. Leaving it does no harm — nothing will push into it any more — but two components that look like they do the same job, one of them permanently idle, is how the next person loses an afternoon.
  3. Leave the two clamps alone unless you have a reason. They are the shipped rule's defaults, so swapping one component for the other does not also move the safety envelope.
  4. Call Clear() when your game considers the slate clean — on spawn for a pooled actor, and on revive. It is left as a call rather than wired to a death handler because when that is belongs to the project.
  5. Read Factor and Count for a debug overlay or a status bar. Both are side-effect free, so reading them every frame is fine, and Factor is the number the pipeline will actually use.

What it deliberately does not do

It does not know what pushed. The interface passes a float and nothing else, so "the strongest curse" and "the strongest anything" are the same sentence here. Per-source rules — diminishing returns per school, immunity to one caster — need a richer contribution than this seam carries.

It does not touch the attacker side. IAttackerDamageModifier and AttackerDamageMultiplierRule are a separate pipeline stage with their own stacking question. Answering both in one component would be two mechanics wearing one name.

It does not diminish repeats over time. The second application of the same debuff counts for nothing while the first is running, and counts fully once it has expired. Crowd-control style diminishing returns — where the second stun is shorter because there was a first — needs memory of what has expired, which is a different mechanic and a bigger one.

It does not survive a save. Contributions belong to whatever pushed them, and those effects have their own persistence. Restoring a ledger whose owners had not been restored yet would be the wrong half of the problem.

  • Health — the damage pipeline, rule priorities, and where victim-side scaling sits in it.
  • Status EffectsVulnerability, and the integration that gives it teeth when this component is absent.
  • Healing that gets weaker the more you receive — the other side of the same pipeline, and the other recipe built from two seams against one call.
  • A shield that spends money — what happens after the rules have run, and the other recipe that had to reason about an editor-only warning.