Last stand¶
Survive one otherwise-lethal hit, left on a sliver of health, then go on cooldown — and only spend the save when the blow really would have killed you.
Recipe
Systems required: Health. Package: Health & Status Effects, or Complete. 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¶
A rule that both changes a hit and spends a resource cannot do both in Apply. At that moment nothing has happened yet: rules after this one may cut the damage further, shields absorb after the PRE stage finishes, and the hit can still be cancelled outright. A charge spent in PRE is a charge spent on a hit that might never land.
So the work splits across the two stages, and that split is the pattern worth taking away even if you never ship a last stand:
- PRE decides provisionally. Reduce the damage, and remember what was done.
- POST commits.
FinalAppliedsays what actually landed, so this is the first moment anything can ask "did that save matter?" — and only then is the cooldown started.
If the requirement is simply that they do not die, the framework already ships that
ExtraLifeTotemHandler implements IBeforeDeathHandler: it intercepts the death pipeline and revives the target, in one component. It also catches routes a damage rule cannot see — a direct health write, or Kill() once its ignoreKillCommands is off. Reach for it when the requirement is survival.
This recipe reshapes the hit instead, so the damage number the player reads, reflect, lifesteal and the death flow all agree the blow was survived — and the death pipeline is never entered at all. That is the trade: narrower coverage, and a world that stays consistent about what happened.
Preview runs your rule, and must not change anything
PreviewDamage runs the live rule components — that is exactly what makes a preview agree with the hit it predicts. So the arithmetic still has to run for a preview, or the HUD promises a killing blow this rule is about to survive. Every piece of state must be left alone: a HUD that previews each frame would otherwise burn the charge without a single real hit landing. The shipped CritRule had this bug with its random draw, which is why DamageContext.IsPreview exists and why its remarks say what they say.
PRE and POST are not a guaranteed pair
IPostDamageRule states that invocation for early-rejected attempts is implementation-defined, so a PRE that armed something may never see its POST. Anything held across the two stages has to be safe when the second half never runs. Here the armed record is stamped with a token, re-stamped at the head of every PRE, and cleared the moment it is used — so an abandoned arm leaves nothing a later hit can mistake for its own.
Lethality is measured against what reaches health, not against health alone
A shield sits in front of health and absorbs after the PRE stage finishes. Test the estimate against Current on its own and the charge is spent on hits the shield would have swallowed by itself — a save the player never needed, and a cooldown they now cannot use.
So the estimate asks the shield what would be left. It can only find a shield that is a component on the victim: the framework resolves the real one through an internal seam, so a shield handed to HealthSystem.SetShield, one on a child object, or one behind a disabled component stays invisible to this rule. What the estimate cannot see, POST still sees in the landed damage — which is the second reason the decision is committed there rather than in PRE.
Ordering¶
Priority is DamageRulePriority.Clamp + 1: after the general clamp, so nothing reshapes the number afterwards, and before the reflect and lifesteal rules, so those see the damage that was actually survived rather than the one that was prevented.
Copying the pipeline's arithmetic
Working out whether a hit is lethal means computing what the hit is currently worth, and the framework's own helper for that is internal — so a rule outside the Health assembly has to restate it. Keep Mathf.RoundToInt: it rounds halves to even, and a cast or Math.Round's default would put your rule one point away from the pipeline on exactly the hits where one point decides whether someone lives.
Drop it in¶
using System;
using RevGaming.RevFramework.Health.Abstractions;
using RevGaming.RevFramework.Health.Abstractions.Contracts;
using RevGaming.RevFramework.Health.Abstractions.Rules;
using RevGaming.RevFramework.Health.Abstractions.Shields;
using RevGaming.RevFramework.Health.Rules.Builtins.Hubs;
using RevGaming.RevFramework.Health.Rules.Builtins.Ordering;
using UnityEngine;
namespace RevGaming.RevFramework.Cookbook.LastStand
{
/// <summary>
/// Survive one otherwise-lethal hit, leaving a sliver of health, then go on cooldown — but only
/// spend the save when the hit really would have killed you. A shield absorbs after this rule
/// runs, so the owner can end above <c>survivingHealth</c> — never below it.
/// </summary>
/// <remarks>
/// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
/// <b>Health</b> only. Public API only.</para>
///
/// <para><b>The composition is the point: decide in PRE, commit in POST.</b> A rule that both
/// changes a hit and spends a resource cannot do both in <see cref="IDamageRule.Apply"/>, because
/// at that moment nothing has happened yet. Rules after this one may reduce the damage further,
/// shields absorb after the PRE stage finishes, and the whole hit may still be cancelled — so a
/// charge spent in PRE is a charge spent on a hit that might never land. This rule therefore
/// adjusts the damage in PRE, remembers what it did, and decides in
/// <see cref="IPostDamageRule.OnDamageApplied"/> — where <see cref="DamageContext.FinalApplied"/>
/// says what actually happened — whether the save was needed at all. Worth knowing what that
/// second stage is really for: when nothing sits between the rules and health, the POST test is
/// the PRE test again and cannot decline. It earns its place on the cases where something did
/// come between them — a cancel, a shield the estimate below could not see, or a rule of your
/// own that cut the number further.</para>
///
/// <para><b>Lethality has to be measured against what reaches health.</b> A shield in front of
/// health absorbs after the PRE stage, so a test made on <see cref="IHealthReadonly.Current"/>
/// alone spends the charge on hits the shield would have swallowed by itself. The estimate
/// therefore asks the shield what would be left, through
/// <see cref="IShieldPreview.PreviewRemainder"/> — but it can only find one that is a component
/// on the victim: the framework resolves the real shield through an internal seam, so a shield
/// handed to <c>HealthSystem.SetShield</c>, one on a child object, or one behind a disabled
/// component stays invisible. What the estimate cannot see, POST still sees in the landed
/// damage.</para>
///
/// <para><b>Preview is the trap, and it is a documented one.</b>
/// <c>PreviewDamage</c> runs the live rule components, which is exactly what makes a preview agree
/// with the hit it predicts. So the arithmetic below must still run for a preview — otherwise the
/// HUD would promise a killing blow this rule is about to survive — while every piece of
/// <i>state</i> must be left alone, including the armed record: a HUD that previews inside a real
/// hit would otherwise throw that hit's pending save away. The shipped <c>CritRule</c> had this
/// bug with its random draw; <see cref="DamageContext.IsPreview"/> exists because of it.</para>
///
/// <para><b>PRE and POST are not a guaranteed pair.</b> <see cref="IPostDamageRule"/> states that
/// invocation for early-rejected attempts is implementation-defined, so a PRE that armed something
/// may never see its POST. Anything held across the two stages therefore has to be safe when the
/// second half never runs — here the armed record is cleared at the head of every real PRE and
/// again the moment POST looks at it, so an abandoned arm cannot be picked up by a later hit. The
/// hit that abandoned it keeps its reduction and never spends the charge: disable this component
/// or deactivate the object between the two stages — a stagger handler reacting to
/// <c>Damaged</c> will do it — and the owner gets that one save for free. That is the safe
/// direction to be wrong in, and cheaper than the machinery that would close it.</para>
///
/// <para><b>Ordering.</b> This runs at <see cref="DamageRulePriority.Clamp"/> + 1: after the
/// general clamp, so the estimate already reflects any configured cap and nothing else reshapes
/// the number afterwards. Priority orders PRE rules and nothing else — the shipped reflect and
/// lifesteal components do their work in POST, where the hub runs observers in component order,
/// so this value does not order this rule against them. It does not need to: they read
/// <see cref="DamageContext.FinalApplied"/>, which is fixed before any POST observer runs, so
/// they see the damage that was actually survived either way.</para>
///
/// <para><b>Why a rule rather than the death seam.</b> The framework already ships a
/// one-component version of this: <c>ExtraLifeTotemHandler</c> implements
/// <c>IBeforeDeathHandler</c>, intercepts the death pipeline and revives the target — and it
/// catches routes a damage rule cannot see, such as a direct health write, or <c>Kill()</c> once
/// its <c>ignoreKillCommands</c> is turned off. Reach for that when what you want is "do not
/// die". This recipe reshapes the hit instead, so the damage number the player reads, reflect,
/// lifesteal and the death flow all agree that the blow was survived, and the death pipeline is
/// never entered at all.</para>
///
/// <para><b>The cooldown lives in memory only.</b> A save/load, a scene reload or a pooled
/// respawn hands back a fresh component with the charge available; persist <c>_readyAt</c>
/// yourself if that matters to your game.</para>
/// </remarks>
[DisallowMultipleComponent]
[RequireComponent(typeof(DamageRuleHub))]
public sealed class LastStandRule : MonoBehaviour, IDamageRule, IPostDamageRule
{
[Tooltip("Health left when a lethal hit is survived. 1 is the classic 'one hit point left'. " +
"A shield absorbing part of the reduced hit leaves the owner higher than this.")]
[SerializeField, Min(1)] private int survivingHealth = 1;
[Tooltip("Seconds before this can save the owner again. 0 means every lethal hit is survived.")]
[SerializeField, Min(0f)] private float cooldownSeconds = 60f;
[Tooltip("Unscaled time ignores Time.timeScale, so a slow-motion death sequence does not " +
"stretch the cooldown with it. Choose it before play: switching while a cooldown " +
"is running compares a stamp taken from one clock against the other.")]
[SerializeField] private bool useUnscaledTime = true;
private bool _armed;
private long _armedReduction;
private int _armedHealthBefore;
private float _readyAt;
/// <summary>Raised when a lethal hit was survived, with the health left behind.</summary>
public event Action<int> Survived;
/// <summary>
/// Whether the cooldown has elapsed. Not a promise of survival: a hit is only survived while
/// current health is above <c>survivingHealth</c>.
/// </summary>
public bool IsReady => Now >= _readyAt;
/// <summary>Seconds until this can save the owner again. Zero when it is ready.</summary>
public float CooldownRemaining => Mathf.Max(0f, _readyAt - Now);
/// <inheritdoc />
public int Priority => DamageRulePriority.Clamp + 1;
private float Now => useUnscaledTime ? Time.unscaledTime : Time.time;
/// <summary>
/// Reduces a lethal hit to a survivable one, without yet deciding that the save was spent.
/// </summary>
public bool Apply(ref DamageContext ctx)
{
// Cleared on every real evaluation, so an armed record left behind by a PRE whose POST
// never ran cannot be picked up by a later hit. A preview must not clear it: previews run
// the live rule components, so a HUD refreshing inside a real hit would otherwise discard
// that hit's pending save.
if (!ctx.IsPreview)
_armed = false;
if (ctx.Cancelled || !IsReady)
return true;
if (!ctx.Victim || !ctx.Victim.TryGetComponent<IHealthReadonly>(out var health))
return true;
int current = health.Current;
if (current <= survivingHealth)
return true;
long estimate = Estimate(in ctx);
if (RemainderAfterShields(in ctx, estimate) < current)
return true;
// Reduced to what health can survive, not to what the shield would pass through. A
// shield's absorption is proportional in the general case, so crediting it twice --
// once in the test above and again here -- would over-reduce the reduction itself and
// could leave the hit lethal after all. Under-reducing is the safe direction: whatever
// the shield takes off afterwards can only leave the owner higher.
long reduction = estimate - (current - survivingHealth);
// Expressed through FlatDelta rather than by overwriting Multiplier or RawAmount, so the
// adjustment composes with whatever the earlier rules decided instead of erasing it. This
// is the same convention the shipped clamp rule follows -- including computing in long
// and bounding only on the write-back, because FlatDelta is the int that has to hold it.
int flatBefore = ctx.FlatDelta;
ctx.FlatDelta = (int)Math.Clamp(flatBefore - reduction, int.MinValue, int.MaxValue);
// Preview must see the reduced number -- that is what makes a preview agree with the hit
// it predicts -- but must arm nothing, because nothing is going to be committed.
if (!ctx.IsPreview)
{
_armed = true;
// What was actually taken off, which is what POST has to reason about: the clamp
// above can hold back part of an extreme reduction, and a reduction POST believes in
// but the hit never received would spend the charge on a save that did not happen.
_armedReduction = flatBefore - (long)ctx.FlatDelta;
_armedHealthBefore = current;
}
return true;
}
/// <summary>
/// Spends the save, but only if the hit that landed really would have been lethal without it.
/// </summary>
public void OnDamageApplied(in DamageContext ctx)
{
if (ctx.IsPreview || !_armed)
return;
// Cleared before the work, not after: whatever this decides, this armed record is spent.
_armed = false;
if (ctx.Cancelled)
return;
// Nothing reached health, so there is nothing to call a survival -- something stopped
// the hit outright, either a shield this rule could not see or one whose remaining
// capacity happened to swallow the reduced number. The first case would spend the charge
// on an owner the shield had already saved; the second gives away a save that was real.
// POST cannot tell them apart, so it keeps the charge: a free save is a better failure
// than a "you survived" flash over an owner nothing touched.
if (ctx.FinalApplied <= 0)
return;
// And nothing to celebrate over a corpse. This rule's reduction cannot itself be what
// killed the owner, but something else in the same hit can, and the event says a lethal
// hit was survived.
if (!ctx.Victim || !ctx.Victim.TryGetComponent<IHealthReadonly>(out var health) ||
health.Current <= 0)
return;
// The question is not "did we change the number" but "did that change matter". Shields
// absorb after the PRE stage, and a later rule may have cut the damage on its own, so a
// hit this rule adjusted can still turn out to have been survivable anyway. Only the
// arithmetic on what actually landed can tell the difference.
long withoutSave = (long)ctx.FinalApplied + _armedReduction;
if (withoutSave < _armedHealthBefore)
return;
_readyAt = Now + cooldownSeconds;
int healthLeft = _armedHealthBefore - ctx.FinalApplied;
// A listener that throws inside the damage pipeline would otherwise take the rest of the
// hit down with it -- including any rule that has not been notified yet.
try
{
Survived?.Invoke(healthLeft);
}
catch (Exception e)
{
Debug.LogException(e, this);
}
}
/// <summary>
/// The damage this hit is currently worth, in the pipeline's own terms.
/// </summary>
/// <remarks>
/// Reimplemented rather than called: the framework's own helper for this is internal, so any
/// rule outside the assembly has to restate the formula. Keep <see cref="Mathf.RoundToInt"/> —
/// it rounds halves to even, and swapping in a cast or <c>Math.Round</c>'s default would put
/// this rule one point out from the pipeline on exactly the hits that decide life or death.
/// The result is a <see cref="long"/> for the same reason the framework's helper returns one:
/// the sum genuinely can leave <see cref="int"/> range, and bounding it here would shrink the
/// reduction by exactly the excess that overflowed — enough to kill the owner this rule had
/// already decided to save.
/// </remarks>
private static long Estimate(in DamageContext ctx)
{
float scaled = ctx.RawAmount * Mathf.Max(0f, ctx.Multiplier);
if (float.IsNaN(scaled))
return ctx.FlatDelta;
long rounded = scaled >= int.MaxValue ? int.MaxValue
: scaled <= int.MinValue ? int.MinValue
: Mathf.RoundToInt(scaled);
return rounded + ctx.FlatDelta;
}
/// <summary>
/// What that estimate would leave for health once the victim's shield has taken its share.
/// </summary>
/// <remarks>
/// Only a shield that is a component on the victim can be found, and only while it is active:
/// the resolved shield itself is reachable only through an internal seam. When none is found
/// the full estimate is returned, which is the safe way to be wrong — it can cost a charge
/// that was not needed, never a life. The other way round is possible but takes an odd
/// setup: point the health component's shield field at a shield somewhere else while
/// leaving one on the victim, and the component found here is not the one that will absorb.
/// Crediting it can then talk this rule out of a save — the owner is no worse off than with
/// no rule attached, but the charge stays showing on the HUD. Keep the shield the health
/// component actually uses on the victim.
/// </remarks>
private static long RemainderAfterShields(in DamageContext ctx, long estimate)
{
if (ctx.BypassShields || estimate <= 0)
return estimate;
if (!ctx.Victim.TryGetComponent<IShieldPreview>(out var shield))
return estimate;
if (shield is Behaviour b && !b.isActiveAndEnabled)
return estimate;
// PreviewRemainder deals in int, so an estimate above int range is offered the largest
// value it can express. What comes back is still absorption this hit would suffer, and
// clamping it keeps a third-party shield from reporting a remainder larger than the hit.
int probe = (int)Math.Min(estimate, int.MaxValue);
return estimate - (probe - Mathf.Clamp(shield.PreviewRemainder(probe), 0, probe));
}
}
}
Wiring it up¶
- Add the component to anything that already has a
HealthSystem. TheDamageRuleHubit needs comes with it. - Set
survivingHealthandcooldownSecondsto taste. - Subscribe to
Survivedfor the slow-motion, the screen flash, the sound.
Tuning¶
| Field | What it does |
|---|---|
survivingHealth | Health left after a survived hit. 1 is the classic. |
cooldownSeconds | Time before it can save again. 0 survives every lethal hit. |
useUnscaledTime | On by default, so a slow-motion death sequence does not stretch the cooldown with it. |
IsReady and CooldownRemaining are public for the HUD.
What it deliberately does not do¶
It does not persist its cooldown. _readyAt lives in memory only, so a save and load, a scene reload or a pooled respawn all hand back a fresh component with the charge available. Persist it yourself if a last stand is supposed to stay spent across a load.
It does not prevent death from a hit it did not see. Damage applied outside the rule pipeline bypasses every rule, this one included.
It does not stack with a second copy. [DisallowMultipleComponent], because two last stands on one character raise a question about ordering and charges that your game should answer explicitly rather than inherit from component order.
It spends nothing on a hit it did not need to save. If a later rule or a shield would have left the victim alive anyway, the POST check sees that and keeps the charge — which is the whole reason the decision is made there.
Related¶
- Health — the rule pipeline, the PRE and POST stages, and the built-in rules this one orders itself against.