Skip to content

Healing that gets weaker the more you receive

Stack three healers on one target and the third one is barely worth casting. Stop healing for a few seconds and it is worth casting again.

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 healed, beside its health component. Both seams are collected with GetComponents from that same 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

IHealingModifier is a shipped extension point. The heal pipeline consumes it — HealthHealProcessor multiplies every heal by the product of the modifiers on the target, and so does PreviewHeal.

Nothing in Runtime, Integrations, Samples or Teaching implements one.

That combination — a seam with a consumer, no worked example, and a name that does not appear in any shipped class's base list — is the same signal that produced the pity counter. It is the best generator this Cookbook has found: which public interfaces does the framework consume and nobody implement?

It needs two seams, because neither half is enough

The modifier answers how much. The observer knows that it happened.

IHealingModifier exposes one property and is never told a heal occurred. IPostHealRule is told and can change nothing. One class implements both: the observer accumulates, the modifier reports.

This is the shape the pity counter used — implement a seam and consume the event from the same call — and it keeps coming up, because a mechanic with memory needs somewhere to put the memory.

There is a shipped rule that would nearly do this, and using it would be wrong

AntiHealRule has a public SetHealMultiplier. A post-heal observer could drive it from outside, and it would work.

That multiplier is one serialized field with one owner

Anything else that sets it silently wins, and neither party can tell. A curse, a difficulty setting and this mechanic all reaching for the same field is a bug that only shows up in the combination nobody tested.

IHealingModifier is the seam built for the job: the pipeline takes the product of every modifier on the target, so they compose instead of clobbering.

When two systems want to influence one number, look for the seam that multiplies rather than the field that assigns.

Only healing that landed counts

HealContext.FinalApplied is the amount that reached health. A heal absorbed by the clamp at maximum adds no fatigue, so being topped up while already full costs nothing.

That is free here, and worth contrasting: a healer who charges by the point had to measure the delta by hand, because TryHeal returns a bool and will not say what it did. The same information, two seams apart, and only one of them hands it over.

The guards in the observer are not defensive coding

IPostHealRule.OnHealApplied documents its own invocation policy as implementation-defined, including whether this observer runs for rejected or zero-apply heals. Today the processor skips both. Relying on that would be relying on something the interface explicitly declines to promise.

And the obvious justification for the <= 0 check is wrong, which is worth saying because it is the kind of comment that ages into a lie. A zero-apply heal is arithmetically a no-op here. The real work is the negative side: an applied amount below zero would credit tolerance and hand healing power back. Measured, not reasoned about — with the guard removed, feeding -50 leaves the actor less fatigued than before the heal.

Reading the multiplier costs nothing, and the honest reason is smaller than it looks

The property is read by PreviewHeal as well as by the real heal, and the interface warns that consumers may query it frequently. So decay is derived from the clock on demand and committed only where a heal lands.

Be clear about what that buys

Because the recovery curve here is linear, a version that decayed on read would produce identical numbers. This is a design property, not a bug avoided. It becomes load-bearing the moment the curve stops being linear, or anything else gets folded into the read.

The real preview hazard is one seam over and it was real: AntiHealRule's own remarks record that its preview used to consume a draw from the project-wide RNG. A read that costs something is the thing to look for. A read that merely computes is fine either way.

Smaller things worth knowing

Modifiers run after rules, on what the rules produced

LowHPHealBoostRule doubles a heal and then fatigue scales the doubled figure. That is the right order for this mechanic — a boost you earned should still be subject to the cap — but it decides how the two tune against each other.

A clock that goes backwards

Time.time resets on a scene reload. Without a guard, a negative elapsed time makes the decay run forwards: a probe measured fatigue jumping from 100 to 1350 across a reload, which reads to a player as healing suddenly not working. Three lines, and it is the kind of bug that only appears on the second level.

The namespace is a trap

IHealingModifier lives in Health.Rules.Abstractions. IPostHealRule and HealContext live in Health.Abstractions.Rules and Health.Abstractions.Contracts. The two families read almost identically and this recipe needs both. It cost one compile.

Fatigue is combat state, not saved state

It runs on Time.time, so it stops while the game is paused and it is gone on a reload. If it should survive one, it wants a save participant of its own — see a shop that remembers for the shape.

The floor is not decoration

A floor of zero lets stacked healers reach exactly no effect, which a player reads as the heal button being broken rather than as a mechanic. A quarter is a starting point, not a recommendation.

Drop it in

HealingFatigue.cs
using RevGaming.RevFramework.Health.Abstractions.Contracts;
using RevGaming.RevFramework.Health.Abstractions.Rules;
using RevGaming.RevFramework.Health.Rules.Abstractions;
using RevGaming.RevFramework.Health.Rules.Builtins.Hubs;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.HealingFatigue
{
    /// <summary>
    /// Healing that gets weaker the more of it you have just received, and recovers when you stop —
    /// the anti-stacking rule most competitive games ship, built from two seams on one pipeline.
    /// </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 healed, beside its health
    /// component — both seams are collected with <c>GetComponents</c> from that same object, so a
    /// child will not do.</para>
    ///
    /// <para><b>It is the framework's first implementation of <see cref="IHealingModifier"/>.</b> The
    /// heal pipeline consumes it — <c>HealthHealProcessor</c> multiplies every heal by the product of
    /// the modifiers on the target — and nothing in Runtime, Integrations, Samples or Teaching
    /// implements one. That combination, a shipped extension point with a consumer and no worked
    /// example, is the same signal that produced the pity counter.</para>
    ///
    /// <para><b>Two seams against one call, and it needs both.</b> A modifier can only answer <i>how
    /// much weaker</i>; it is never told that a heal happened. <see cref="IPostHealRule"/> is told,
    /// and cannot change anything. One class implements both: the observer accumulates, the modifier
    /// reports. Neither half is useful alone, which is why the interesting recipes keep landing on
    /// pairs.</para>
    ///
    /// <para><b>There is a shipped rule that would nearly do this, and using it would be wrong.</b>
    /// <c>AntiHealRule</c> has a public <c>SetHealMultiplier</c>, so a post-heal observer could drive
    /// it from outside. But that multiplier is <i>one serialized field with one owner</i> — anything
    /// else that sets it silently wins, and neither party can tell. <see cref="IHealingModifier"/> is
    /// the seam built for this: the pipeline takes the <b>product</b> of every modifier on the target,
    /// so fatigue, a curse and a difficulty setting compose instead of clobbering. <b>When two systems
    /// want to influence one number, look for the seam that multiplies rather than the field that
    /// assigns.</b></para>
    ///
    /// <para><b><see cref="HealMultiplier"/> writes nothing, and the honest version of why is worth
    /// more than the tidy one.</b> It is read by <c>HealthSystem.PreviewHeal</c> as well as by the real
    /// heal, and the interface warns that consumers may query it frequently. Decay is therefore
    /// derived from the clock on demand and committed only where a heal lands. <b>Be clear about what
    /// that does and does not buy:</b> because the recovery here is linear, a version that decayed on
    /// read would produce identical numbers — this is a design property, not a bug avoided. It becomes
    /// load-bearing the moment the curve stops being linear, or anything else is folded into the read.
    /// The real preview hazard is one seam over and it was real: <c>AntiHealRule</c>'s own remarks
    /// record that its preview used to consume a draw from the project-wide RNG. A read that costs
    /// something is the thing to look for; a read that merely computes is fine either way.</para>
    ///
    /// <para><b>Only healing that landed counts.</b> <see cref="HealContext.FinalApplied"/> is the
    /// amount that reached health, so a heal absorbed by the clamp at maximum adds no fatigue — you
    /// are not punished for being topped up while full. That is free here, and it is the same
    /// discipline <c>PaidHealing</c> had to measure by hand because <c>TryHeal</c> would not report
    /// it.</para>
    ///
    /// <para><b>The guards in the observer are not defensive.</b>
    /// <see cref="IPostHealRule.OnHealApplied"/> documents its own invocation policy as
    /// <i>implementation-defined, including whether this observer runs for rejected or zero-apply
    /// heals</i>. Today the processor skips it for both. A recipe that relied on that would be relying
    /// on something the interface explicitly declines to promise, so the preview and zero-apply checks
    /// are here rather than assumed.</para>
    ///
    /// <para><b>Modifiers run after rules, on what the rules produced.</b> So <c>LowHPHealBoostRule</c>
    /// doubles a heal and then fatigue scales the doubled figure. That is the right order for this
    /// mechanic — a boost you have earned should still be subject to the cap — but it is worth knowing
    /// before tuning either.</para>
    ///
    /// <para><b>Fatigue is combat state, not saved state.</b> It runs on <c>Time.time</c>, so it stops
    /// while the game is paused, and it is gone on a reload. If it should survive one, it wants a save
    /// participant of its own rather than a field on this class.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    [RequireComponent(typeof(HealRuleHub))]
    public sealed class HealingFatigue : MonoBehaviour, IHealingModifier, IPostHealRule
    {
        [Tooltip("How much weaker each point of recently-received healing makes the next heal. " +
                 "0.004 means 100 points of recent healing costs you 40% effectiveness.")]
        [SerializeField, Min(0f)] private float reductionPerPoint = 0.004f;

        [Tooltip("Points of tolerance recovered per second once healing stops. 0 means fatigue never " +
                 "recovers, which is a legitimate setting for a single fight and a trap everywhere else.")]
        [SerializeField, Min(0f)] private float recoveryPerSecond = 25f;

        [Tooltip("Healing is never scaled below this. A floor of 0 lets stacked healers reach exactly " +
                 "zero effect, which reads to a player as the heal button being broken.")]
        [SerializeField, Range(0f, 1f)] private float minimumMultiplier = 0.25f;

        private float fatigue;
        private float stamp;

        /// <summary>
        /// Points of recent healing currently counting against this actor, after recovery.
        /// </summary>
        /// <remarks>Read-only and side-effect free, so a debug overlay can show it every frame.</remarks>
        public float CurrentFatigue => FatigueAt(Time.time);

        /// <summary>
        /// Multiplier the heal pipeline applies to incoming healing on this actor.
        /// </summary>
        /// <remarks>
        /// <para>Read by both the real heal and <c>PreviewHeal</c>, and mutates nothing: the decay is
        /// derived from the clock rather than from being asked, so reading it twice in a frame and
        /// reading it not at all produce the same future.</para>
        /// <para>The pipeline clamps whatever comes back to 0..10 and substitutes 1 for a non-finite
        /// value, so a mis-set field degrades rather than corrupting a heal.</para>
        /// </remarks>
        public float HealMultiplier
        {
            get
            {
                float reduction = FatigueAt(Time.time) * reductionPerPoint;
                return Mathf.Clamp(1f - reduction, minimumMultiplier, 1f);
            }
        }

        /// <summary>
        /// Accumulates the healing that actually landed.
        /// </summary>
        /// <remarks>
        /// <para>The preview refusal is load-bearing on its own: without it, looking at a heal makes
        /// the next one weaker.</para>
        /// <para><b>The <c>&lt;= 0</c> refusal is worth being precise about, because the obvious
        /// justification is wrong.</b> A zero-apply heal is arithmetically a no-op here — adding zero
        /// and re-basing the clock to now produce exactly the state the decay would have reached
        /// anyway — so "standing at full health costs you tolerance" is not the reason. The reason is
        /// the <i>negative</i> side: an applied amount below zero would <b>credit</b> tolerance and
        /// hand back healing power, and <see cref="IPostHealRule"/> documents its invocation policy as
        /// implementation-defined, so what reaches this method is not something to assume. Measured
        /// rather than reasoned about: with the guard removed, a probe feeding -50 leaves the actor
        /// less fatigued than before the heal.</para>
        /// </remarks>
        /// <param name="ctx">Heal context, carrying the applied amount.</param>
        public void OnHealApplied(in HealContext ctx)
        {
            if (ctx.IsPreview) return;
            if (ctx.FinalApplied <= 0) return;

            // The one place state moves. Decay is settled first so the new points are added to a
            // tolerance that is current, rather than to a stale figure from the last heal.
            float now = Time.time;
            fatigue = FatigueAt(now) + ctx.FinalApplied;
            stamp = now;
        }

        /// <summary>
        /// Clears the accumulated fatigue.
        /// </summary>
        /// <remarks>
        /// Public because the framework cannot know when your game considers the slate clean. A revive
        /// is the obvious caller — a resurrected actor being unable to accept healing is rarely what
        /// anyone intended — and so is leaving combat. It is left as a call rather than wired to a
        /// death handler here, because that would be this recipe deciding a design question that
        /// belongs to the project.
        /// </remarks>
        public void Clear()
        {
            fatigue = 0f;
            stamp = Time.time;
        }

        /// <summary>
        /// Fatigue remaining at <paramref name="now"/>, after linear recovery since the last heal.
        /// </summary>
        /// <remarks>
        /// Pure. A recovery rate of zero means no recovery at all, which is the documented meaning of
        /// the field rather than a division waiting to happen.
        /// </remarks>
        private float FatigueAt(float now)
        {
            if (fatigue <= 0f) return 0f;
            if (recoveryPerSecond <= 0f) return fatigue;

            float elapsed = now - stamp;
            if (elapsed <= 0f) return fatigue;

            float remaining = fatigue - (elapsed * recoveryPerSecond);
            return remaining > 0f ? remaining : 0f;
        }
    }
}

Wiring it up

  1. Put the component on the actor that should suffer fatigue — usually a player, or anything a party can focus healing onto. It brings a HealRuleHub with it; without one the post-heal notification never runs and the mechanic silently does nothing.
  2. Tune the three numbers together, because they only make sense as a set. The defaults mean: 100 points of recent healing costs 40% effectiveness, you shed 25 points of that per second, and it never drops below a quarter.
  3. Call Clear() on revive, and on leaving combat if your game has that idea. It is left as a call rather than wired to a death handler because when the slate is clean is a design question, not a framework one.
  4. Read CurrentFatigue for a debug overlay or a UI pip. It is side-effect free, so reading it every frame is fine.

What it deliberately does not do

It does not care who healed. Fatigue belongs to the target, so three healers share one pool — which is the point. Per-healer diminishing returns needs an instigator, and HealContext does not carry one.

It does not block healing. Even at the floor, healing still lands. Cancelling a heal outright is IHealRule's job and AntiHealRule already does it.

It does not touch damage. A "reduced healing" debuff that also weakened the target would be two mechanics in one component.

It does not decay non-linearly. A curve would be more interesting to tune and would make the purity of the read genuinely load-bearing rather than incidental. Linear is easier to explain and easier to predict at the table.

It does not survive a save. See above — that is a participant, not a field.