The ability system is four things you already have¶
A cost, a gate, an effect and attribution. All four ship. What is missing is the twenty lines that put them in the right order — and the right order is not the one you would write first.
Recipe
Systems required: Health and Status Effects. Package: Health & Status Effects, or Complete. Shape: one file holding the ability and the effect it applies. Public API only. It assumes: a HealthSystem on the caster, and on the target a StatusEffectController and a HealthSystem on the same GameObject — see the wiring note below, because splitting those two is silent. 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¶
Pull an ability apart and there are four things in it:
| Where it already is | |
|---|---|
| A cost | Health, Currency or an Attribute — whichever your game charges in |
| A gate | The authority and immunity seams the status controller already consults |
| An effect | IStatusEffect, written by your game, handed to ApplyStatus |
| Attribution | StatusContext.FromAbility(abilityId, instigator, abilityDef, note) |
That last one is worth sitting with. StatusContext.FromAbility is public, documented — its summary reads "Creates ability-use attribution." — and it has shipped since launch with no real consumer anywhere in the framework. Its sibling FromItemUse is consumed by ItemUseSystem, a real system. Every call site of FromAbility in the repository is a synthetic one: StatusAuraZone passing the id "aura_zone_auto", a handful of performance smokes, and the teaching panels. Nothing in Integrations/, nothing shipped, no ability. (It was twelve call sites when this page was written. The count is not the point and nothing guards it — go and look, which is the habit this page is trying to build anyway.)
That is the framework having built the attribution half of an ability system and declined to define the other half — in public, deliberately, for four releases. What is missing is not a mechanism. It is a sequence, and the sequence is game policy because what an ability costs and when it costs it is the design of your game.
The ordering, which is the whole recipe¶
1. Ask whether the cost can be paid
2. Refuse cleanly if it cannot — nothing has happened, so nothing is undone
3. Apply the effect
4. Charge, exactly once, and only now
The version everyone writes first is charge, then apply, and it gives the goods away backwards: a target immune to curses eats the spell, the caster pays for it, and nothing happens. The design note for recipes 12–14 records the same finding from the other end — "a composite of pay, then take gives the goods away" — and an ability is where most readers meet it for the first time.
Step 4 is where it gets interesting, because of step 3.
Step 4 needs the apply to answer, and it does¶
Here is the entire entry point:
public StatusApplyResult ApplyStatus(IStatusEffect effect, StatusContext context)
{
if (!HasAuthority()) return Refuse(..., StatusApplyResult.NoAuthority);
if (effect == null) return Refuse(..., StatusApplyResult.NoEffect);
if (BlockedByImmunity(effect)) return Refuse(..., StatusApplyResult.Immune);
ApplyStatusCore(effect, context);
return StatusApplyResult.Applied;
}
Three ways to be declined, and the call tells you which. So step 4 is a comparison:
if (controller.ApplyStatus(effect, context) != StatusApplyResult.Applied)
return Refuse(HexRefusal.EffectRefused);
ChargeCost();
An ability that charges on the assumption it landed is wrong in exactly the cases that matter — the boss carrying an IStatusImmunity for curses, the cutscene that revoked authority on the target, anything a designer configured to be unaffected by precisely this. Each is a deliberate refusal by somebody's design, and each silently charged the player under the naive ordering. Now none of them has to.
This is new in 1.3.0, and this recipe asked for it
ApplyStatus and ApplyOrRefresh returned void until 1.3.0: three ways to decline, every one a bare return, and the caller told nothing at all. An earlier version of this page carried ~40 lines of workaround and closed with the observation that "a result-returning ApplyStatus is the only airtight closure" — so rather than build a base class to hide the workaround, the ask went to the framework. The two sections below are what that one comparison replaced. They are kept because the reasoning outlives the fix, not because you need the technique.
On 1.2.0 and earlier, you need it.
The tempting fix is wrong — and still is¶
The obvious repair, then and now, is to test for the status afterwards:
// Reads as watertight. Over-charges.
controller.ApplyStatus(effect, ctx);
if (controller.HasStatus(effectId))
ChargeCost();
A target that already carries this hex from another caster reports true whether this application landed or was refused outright. The immune boss that an ally cursed a second ago now charges every subsequent caster in the party, forever, for nothing.
This one is worth keeping in mind well beyond ApplyStatus, because it is a habit rather than a fact about one method: presence is a fact about the target, never about your call. When a mutator tells you nothing, the state it touched is not a substitute — you cannot tell your change from somebody else's. That is true of every seam in every framework that returns void, and the next one you meet will not have a release note.
What the one comparison replaced¶
Before the return value existed, the reliable signal was the controller's own events. OnStatusAppliedCtx and OnStatusRefreshedCtx are raised synchronously from inside ApplyStatus and carry the StatusContext that was accepted, so subscribing across the one call answered what the call itself could not:
bool landed = false;
controller.OnStatusAppliedCtx += OnApplied;
controller.OnStatusRefreshedCtx += OnRefreshed;
try { controller.ApplyStatus(effect, context); }
finally { controller.OnStatusAppliedCtx -= OnApplied; controller.OnStatusRefreshedCtx -= OnRefreshed; }
if (!landed) return Refuse(HexRefusal.EffectRefused);
Four details in there were load-bearing, and three of them generalise:
Both events, not one. A fresh application raises OnStatusAppliedCtx. A re-cast onto a target that already carries the hex raises OnStatusRefreshedCtx instead — and a refresh is a successful cast that should be paid for. This survives the fix: StatusApplyResult.Applied deliberately covers apply, refresh and stack alike, for the same reason.
The context is checked, not trusted. The events fire for every application on that controller, and game code runs inside the window: a listener reacting to the apply, an effect that applies a second effect. Comparing ctx.SourceId and ctx.Instigator against our own is what stopped somebody else's status being read as our spell landing. This is the shape of the whole problem: a broadcast channel tells you something happened, never that your call is what happened.
The unsubscribe is in a finally. The effect's own Apply is game code. The controller isolates a throw from it, but a subscriber left attached would go on answering for casts it had nothing to do with.
And the one that could not be fixed from this side. IStatusImmunity and IStatusAuthority are your code, and the controller calls them from inside ApplyStatus. One that casts this same ability re-enters TryCast underneath the outer call — and the outer call's handlers are still attached, because it has not reached its finally yet. So the inner application raised OnStatusAppliedCtx into a subscription belonging to a different cast.
The context check does not save you there. Both casts carry the same SourceId and the same Instigator, because they are the same component: ctx can tell another caster's application apart from ours, and cannot tell ours apart from ours. Left alone, the outer cast — the refused one — read the inner one's success, charged anyway, raised Cast, returned true and never raised Refused.
That is the half a return value closes for free. A returned value belongs to the call that asked for it; there is no window and nothing to attribute. The channel identified a source — who applied something, with what id — and never an application, and no amount of care at the call site could add a per-application identity that was not on the wire.
TryCast still refuses re-entry with HexRefusal.AlreadyCasting, for the reason that was always underneath the other one: a cast that re-enters itself recurses until the stack gives out, and the charge and the effect are a pair that nesting interleaves against one caster's health. The guard outlived its original justification, which is worth noticing — a guard that is right for two reasons does not stop being needed when one of them is repaired.
The event technique's synchronicity was test-pinned, not contracted
Everything in the section above rested on those two events being raised from inside the ApplyStatus call. That is how the controller behaves today, and it is asserted — by StatusEffectController_EventContextTruthTests in the framework's own suite.
But it was never written down as a guarantee: the Status Effects guarantees matrix has no event-timing entry, so nothing promised a future version could not defer them. An ability built that way would then charge for spells that had not landed yet. The return value has no such exposure, which is the quieter reason to prefer it: StatusApplyResult is in the guarantees matrix, and the event timing never was.
The framework's own damage-over-time credits nobody¶
This is why the recipe writes its own effect instead of reaching for PoisonStatus.
The shipped effects tick through HealthStatusHooks.ApplyStatusTick, and with no tick route installed that method ends here:
if (!target.TryGetComponent<IDamageable>(out var damageable))
return;
// No route installed, so the damage type cannot be expressed.
damageable.Damage(amount);
An amount, and nothing else. No attacker, no source id. A poison that kills credits nobody, and nothing warns you about it.
HexStatus therefore builds a DamageContext per tick carrying the caster and the ability id. The payoff lands one recipe over: when the hex delivers the killing tick, anything reading the death report — a kill-credit component, a killfeed, a bounty — sees the caster who cast it six seconds ago and has since walked away.
Put the gameplay in OnTick or Apply, never in an FX hook
TimedStatusEffect offers OnApplyFX, OnRefreshFX and OnRemoveFX. They are skipped entirely when a controller has global FX switched off — a setting its own tooltip recommends for server-authoritative objects.
An effect that puts its damage there does nothing on exactly the objects a dedicated server cares about, and does it silently. This is not hypothetical: it is the single systemic defect a StatusEffects-wide audit found, across three shipped effects.
Re-casting, and who gets the credit¶
HexStatus stacks with Refresh, so a second cast extends the curse rather than running two. That makes the attribution question real, and the controller has a specific answer:
if (!context.IsEmpty) _contexts[existing] = context;
A refresh carrying a non-empty context takes over the stored attribution. A refresh carrying StatusContext.None — a duration top-up from some system with nothing to say about who caused it — leaves the original in place.
That is not the same as taking over the kill, and the difference is the interesting part
Attribution here runs on two independent channels, and a refresh moves only one of them.
| Channel | What it is | Does a rival's refresh move it? |
|---|---|---|
The controller's stored StatusContext | What OnStatusRefreshedCtx, GetContext and per-source stacking report | Yes |
The DamageContext each tick carries | What lands in LastDamageReport — the death report, the killfeed, the bounty | No |
Refresh keeps the original effect instance. It takes the incoming effect's Duration, swaps the stored context, and discards the rest — the incoming instance's Apply never runs. And HexStatus builds its per-tick DamageContext from its own constructor-captured _caster.
So a rival who re-hexes your target pays the full health cost, changes how long the curse runs, becomes the answer to every status query — and still does not get the kill. Their damagePerSecond is discarded too: the retained instance keeps the original's tuning.
This is a real consequence of the framework's design rather than a defect in it: the status controller has no route into the damage pipeline, and it should not have one. But it means "who applied this status" and "who gets credited for what it does" are different questions, and a game that charges for an ability had better know which one it is answering.
If you want a takeover to move the kill, the effect has to be takeover-aware — a mutable caster field the controller's refresh path can reach, which the readonly fields below deliberately forbid, or a Replace stacking rule so the incoming instance is the one that ticks. Both are design choices with costs, and neither is the default.
Refresh also falls through to a fresh apply when nothing is currently active, so ApplyStatus always leaves the effect running and a caller never has to test for presence first.
Paying in blood¶
The charge borrows three fields wholesale from the blood ledger, because the reasoning is identical and there is no point rediscovering it:
DamageContext cost = DamageContext.CreateBasic(null, gameObject, healthCost);
cost.BypassShields = true;
cost.BypassRules = RuleBypass.Armor | RuleBypass.Affinity;
cost.SourceId = abilityId;
No attacker, so a team rule cannot cancel the charge as friendly fire and lifesteal cannot rebate it straight back. Shields bypassed, because temporary hit points are a ward and not blood. Armour and affinity bypassed, because a breastplate does not make blood cheaper — and those are the only two families RuleBypass offers.
The affordability check is re-asked, and it still cannot promise the outcome
Step 2's preflight is a statement about the past by the time step 4 runs, because the effect's own Apply ran in between and that is game code which can reach anything, the caster included. So the question is asked again immediately before charging.
Even then the floor is checked against the price, not the outcome. A victim-side multiplier on the caster — CritRule, or the damage-taken multiplier VulnerabilityStatus installs by itself — scales the charge after that check, and RuleBypass has no flag that opts a hit out of them. The recipe reports what actually landed through FinalApplied rather than pretending it was the price.
When the charge fails after the effect landed¶
Rare, and it has a named outcome rather than a shrug. The hex is withdrawn and the result says so.
The withdrawal has its own failure mode, and throwing it away would repeat the original mistake one level down: RemoveStatus also returns void. So it is confirmed with HasStatus, and the two outcomes are told apart — CostFailedAndEffectWithdrawn is a fizzle, CostFailedAndEffectStuck is a target carrying a hex nobody paid for, and those are different bug reports.
A stated limitation of the withdrawal
Removal is by id, so a hex this caster placed and one an ally placed a moment earlier are the same status to RemoveStatus. In the narrow window this runs in, withdrawing can take the ally's hex instead.
Making that impossible needs per-source removal, which is not in the public surface. Inventing a way around it inside a recipe would be worse than saying so.
Drop it in¶
using System;
using RevGaming.RevFramework.Health.Abstractions.Contracts;
using RevGaming.RevFramework.Health.UnityIntegration;
using RevGaming.RevFramework.StatusEffects.Abstractions;
using RevGaming.RevFramework.StatusEffects.Core;
using UnityEngine;
namespace RevGaming.RevFramework.Cookbook.AbilityInFourParts
{
/// <summary>
/// Why a use of the ability did not happen. Every one of these is a different line of feedback,
/// and three of them are reasons the framework cannot report on its own.
/// </summary>
public enum HexRefusal
{
/// <summary>It went through. Nothing was refused.</summary>
None,
/// <summary>No target, or a target with no status controller to receive the effect.</summary>
NoTarget,
/// <summary>The caster has no health component, so the cost cannot be expressed at all.</summary>
NoCaster,
/// <summary>
/// The caster cannot afford it — paying would take them <i>below</i> the floor. Landing exactly
/// on it is allowed, which is what <c>Current - healthCost >= minimumHealth</c> says: a floor
/// of 1 lets a cast leave the caster on 1. Checked before anything happens, so nothing did.
/// </summary>
CannotAfford,
/// <summary>
/// The effect was offered and the controller declined it: no authority, or an immunity that
/// blocks this id or its tags. Since 1.3.0 <c>ApplyStatus</c> reports which, as a
/// <see cref="StatusApplyResult"/>; this collapses them into one refusal because a caster does
/// not care why the spell failed, but the reason is right there if your feedback does.
/// </summary>
EffectRefused,
/// <summary>
/// The effect landed and the cost then could not be taken, so the effect was withdrawn again.
/// Rare and worth reporting rather than hiding: it means something hurt the caster between the
/// preflight and the charge.
/// </summary>
CostFailedAndEffectWithdrawn,
/// <summary>
/// The worst case, and the reason it has a name: the cost failed after the effect landed, and
/// the withdrawal did not take either. The target keeps a hex nobody paid for.
/// </summary>
CostFailedAndEffectStuck,
/// <summary>
/// This ability was asked to cast again from inside its own cast. Reachable only from customer
/// code the controller calls during <c>ApplyStatus</c> — an <c>IStatusImmunity</c> or
/// <c>IStatusAuthority</c> that casts — and refused rather than served. One cast at a time is
/// this ability's model of itself: the charge and the effect are a pair, and a nested cast
/// interleaves two of those pairs against one caster's health.
/// </summary>
AlreadyCasting
}
/// <summary>
/// One ability, concretely: pay health, curse a target with a damage-over-time that credits you
/// for the kill. Not an ability framework — the four parts underneath it all ship already.
/// </summary>
/// <remarks>
/// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
/// <b>Health</b> and <b>Status Effects</b>. Public API only.</para>
///
/// <para><b>An ability is four things, and RevFramework ships all four.</b> A <i>cost</i> — here
/// health, through the damage pipeline. A <i>gate</i> — the authority and immunity seams the status
/// controller already consults. An <i>effect</i> — an <c>IStatusEffect</c> written by the game and
/// handed to <c>ApplyStatus</c>. And <i>attribution</i> — <c>StatusContext.FromAbility</c>, which
/// exists in the public API for precisely this and, at the time this was written, had no real
/// consumer anywhere in the framework. What is missing is the thing that sequences them, and that
/// is not a framework: it is this method.</para>
///
/// <para><b>The lesson is the ordering, and it is not the obvious one.</b> "Charge, then apply" is
/// the version everybody writes first, and it gives the goods away backwards: a refused effect
/// leaves the caster charged for nothing. So the sequence here is <b>preflight, refuse, apply,
/// then charge</b> — the cost is taken exactly once, and only once something actually
/// happened.</para>
///
/// <para><b>Which needs the apply to answer, and since 1.3.0 it does.</b>
/// <c>ApplyStatus</c> returns a <see cref="StatusApplyResult"/>, so step 4 is a comparison:
/// the controller declined for no authority, a null effect, or an <c>IStatusImmunity</c> blocking
/// the id or its tags, and it says which. An ability that charges on the assumption it landed is
/// wrong in exactly the cases that matter — the boss immune to curses, the cutscene that revoked
/// authority — and now it need never assume.</para>
///
/// <para><b>The tempting fix is still wrong, and worth knowing about.</b> Testing
/// <c>HasStatus(id)</c> afterwards looks like the answer and over-charges: a target that already
/// carries this hex from another caster reports <c>true</c> whether this application landed or was
/// refused outright. Presence is a fact about the target, never about your call. That is a general
/// habit and it outlives this particular fix: when a mutator tells you nothing, the state it
/// touched is not a substitute, because you cannot tell your change from somebody else's.</para>
///
/// <para><b>What that one comparison replaced.</b> Until 1.3.0 the only reliable reading was the
/// controller's own events — subscribe to <c>OnStatusAppliedCtx</c> and <c>OnStatusRefreshedCtx</c>
/// across the single call, unsubscribe in a <c>finally</c>, and check the <see cref="StatusContext"/>
/// coming back was yours. Both events, because a re-cast onto a target already carrying the hex
/// refreshes rather than applies and is still a cast worth paying for. It worked, and it was a
/// reading rather than a guarantee: that channel identifies a <i>source</i>, never an
/// <i>application</i>, so two nested casts by this same component carry the same source id and the
/// same instigator and cannot be told apart. This file used to say the honest move was to ask for a
/// result-returning apply rather than to build a base class around ~40 lines of that. The ask was
/// made and the framework answered; the paragraph is kept because the reasoning applies to the next
/// seam that reports nothing, not because the workaround is still needed.</para>
///
/// <para><b>The framework's own damage-over-time effects credit nobody</b>, which is why this one
/// is written by hand rather than reaching for <c>PoisonStatus</c>. Their ticks run through
/// <c>HealthStatusHooks.ApplyStatusTick</c>, which — with no tick route installed — calls
/// <c>IDamageable.Damage(amount)</c>: an amount and nothing else, no attacker and no source id.
/// A hex that should credit its caster has to carry its own <see cref="DamageContext"/>, and
/// <see cref="HexStatus"/> does. The payoff is that when the hex lands the killing tick, anything
/// reading the death report sees the caster.</para>
///
/// <para><b>The cost is charged the way a blood price is charged</b>, and the three fields are
/// borrowed wholesale from the blood-ledger recipe because the reasoning is identical: no attacker,
/// so a team rule cannot cancel it as friendly fire and lifesteal cannot rebate it; shields
/// bypassed, because temporary hit points are a ward and not blood; armour and affinity bypassed,
/// because a breastplate does not make blood cheaper.</para>
///
/// <para><b>What is deliberately not here.</b> No cooldown — that is a timer your game already has,
/// and the framework's own is <c>[Obsolete]</c>. No targeting model: the caller passes the target,
/// because deciding who an ability hits is the game's entire combat design. No ability definition
/// asset, no registry, no base class. This is <i>an</i> ability.</para>
///
/// <para><b>And the cost of that, owned rather than waved past.</b> A second ability is a second
/// class that shares nothing with this one. That cost used to be dominated by the ~40 lines of
/// subscribe / call / unsubscribe / check-the-context that turned a <c>void ApplyStatus</c> into a
/// landing signal — the hardest code in the file, re-inlined by every ability. Asking for the
/// result-returning apply, rather than building a base class to hide the workaround, is what
/// removed it; what remains duplicated is the four-step ordering itself, which is short, and which
/// is the part that is genuinely your game's policy rather than the framework's.</para>
///
/// <para><b>Two omissions worth naming, because neither is obvious from the code.</b> The hex
/// does not go through <c>IAdjustableMagnitude</c>, so a target's defensive potency does not
/// scale it: a boss with curse-resistance mitigates every shipped damage-over-time and takes this
/// one at full strength. The "no scaling" beat elsewhere on the page is about the <i>caster's</i>
/// side and does not cover that. And an active hex does not survive a save — status effects are
/// runtime state here, so a load returns a target with no hex on it and no event to say one
/// left.</para>
/// </remarks>
[DisallowMultipleComponent]
[RequireComponent(typeof(HealthSystem))]
public sealed class BloodHex : MonoBehaviour
{
[Tooltip("Ability id. Rides in StatusContext.SourceId and on every damage tick the hex deals, " +
"so it is what a killfeed and a damage meter will read. Lowercase by convention.")]
[SerializeField] private string abilityId = "ability.blood_hex";
[Tooltip("Health the caster pays. Taken only after the effect has actually landed.")]
[SerializeField, Min(1)] private int healthCost = 15;
[Tooltip("Lowest health a cast may leave the caster on. Landing exactly on it is allowed; " +
"going below it is refused. A cast that would kill you is a death with no killer, " +
"which players report as a bug. NOTE: this bounds the PREFLIGHT, not the charge -- " +
"a victim-side multiplier on the caster scales the real debit after this check, so " +
"treat it as the intent rather than a guarantee.")]
[SerializeField, Min(1)] private int minimumHealth = 1;
[Tooltip("How long the hex runs, in seconds.")]
[SerializeField, Min(0.1f)] private float duration = 6f;
[Tooltip("Damage the hex deals per second to its target.")]
[SerializeField, Min(0.1f)] private float damagePerSecond = 4f;
/// <summary>Raised when a use went through: the target, and what it cost.</summary>
public event Action<GameObject, int> Cast;
/// <summary>Raised when a use did not go through, with the reason it did not.</summary>
public event Action<HexRefusal> Refused;
private HealthSystem _casterHealth;
// True for the length of one TryCast, including everything customer code does inside it.
private bool _casting;
/// <summary>The id this ability's effect carries. Yours to name; nothing registers it.</summary>
public StatusId EffectId => new StatusId(HexStatus.IdFor(abilityId));
private void Awake()
{
_casterHealth = GetComponent<HealthSystem>();
}
/// <summary>
/// Whether the caster could pay right now. A question with no side effects, for a HUD that
/// wants to grey the button out.
/// </summary>
/// <remarks>
/// Deliberately not trusted by <see cref="TryCast"/>, which re-asks at the moment it charges.
/// A preflight is a statement about the past by the time anything acts on it.
/// </remarks>
public bool CanAfford()
{
if (!_casterHealth)
_casterHealth = GetComponent<HealthSystem>();
return _casterHealth && !_casterHealth.IsDead && CanAffordNow();
}
private bool CanAffordNow() => _casterHealth.Current - healthCost >= minimumHealth;
/// <summary>
/// Uses the ability on a target. The whole recipe.
/// </summary>
/// <returns>True when the hex landed and the cost was taken.</returns>
/// <remarks>
/// <para>Four steps in order, and the order is the point: ask whether the cost can be paid,
/// refuse cleanly if it cannot, apply the effect, and charge only once the effect is known to
/// have landed. Nothing is taken from a caster whose spell was refused.</para>
/// </remarks>
public bool TryCast(GameObject target)
{
// Re-entrancy is refused, not served. Customer code the controller calls inside
// ApplyStatus -- an IStatusImmunity or IStatusAuthority that casts -- can re-enter here,
// and a cast that re-enters itself recurses until the stack gives out. It used to be
// refused for a second reason as well: the landing signal was an event subscription held
// across ApplyStatus, and two nested casts of one component raise events neither can
// attribute. The result-returning apply closed that half -- each call now gets its own
// answer back -- and the first half stands on its own, because the charge and the effect
// are a pair and nesting interleaves two of them against one caster's health.
if (_casting)
return Refuse(HexRefusal.AlreadyCasting);
_casting = true;
try
{
return CastOnce(target);
}
finally
{
_casting = false;
}
}
/// <summary>
/// One cast, with the re-entrancy question already settled by <see cref="TryCast"/>.
/// </summary>
private bool CastOnce(GameObject target)
{
// ---- 1. Can it happen at all -------------------------------------------------------
if (!_casterHealth)
_casterHealth = GetComponent<HealthSystem>();
if (!_casterHealth || _casterHealth.IsDead)
return Refuse(HexRefusal.NoCaster);
if (!target)
return Refuse(HexRefusal.NoTarget);
var controller = target.GetComponentInParent<StatusEffectController>();
if (!controller)
return Refuse(HexRefusal.NoTarget);
// ---- 2. Can the cost be paid, and refuse cleanly if not ----------------------------
// Asked before anything happens, so a refusal here costs the caster nothing at all.
if (!CanAffordNow())
return Refuse(HexRefusal.CannotAfford);
// ---- 3. Apply the effect, and find out whether it landed ---------------------------
var context = StatusContext.FromAbility(
abilityId,
instigator: gameObject,
abilityDef: null, // no ability asset: see the remarks on this class
note: "cast");
var effect = new HexStatus(
id: HexStatus.IdFor(abilityId),
duration: duration,
damagePerSecond: damagePerSecond,
caster: gameObject,
sourceId: abilityId);
// The answer to "did MY application land" comes back from the call that asked it. One
// comparison, and it is airtight in a way the event subscription this replaced never was:
// a return value belongs to its call, so nothing another cast does inside this window can
// be mistaken for this one's success. StatusApplyResult.Applied covers a refresh too --
// re-casting onto a target already carrying the hex refreshes rather than applies, and
// that is a successful cast that should be paid for.
StatusApplyResult applied = controller.ApplyStatus(effect, context);
if (applied != StatusApplyResult.Applied)
return Refuse(HexRefusal.EffectRefused);
// ---- 4. Charge, exactly once, now that something has happened ----------------------
// Re-asked rather than trusting step 2: the effect's Apply ran in between, and it is game
// code that can reach anything -- including the caster.
if (!CanAffordNow())
return WithdrawAfterFailedCharge(controller);
DamageResult charge = ChargeCost();
if (!charge.Applied)
return WithdrawAfterFailedCharge(controller);
// What landed, not what was asked for. The pipeline decides the real debit -- a
// victim-side multiplier on the caster scales it after the affordability check, and
// RuleBypass has no flag that opts out of those.
Cast?.Invoke(target, charge.FinalApplied);
return true;
}
/// <summary>
/// Takes the health price, the way a blood price is taken.
/// </summary>
private DamageResult ChargeCost()
{
// Attacker null, shields and mitigation bypassed. The reasoning is the blood ledger's and
// is quoted in this class's remarks; the short version is that a price is not a hit.
DamageContext cost = DamageContext.CreateBasic(null, gameObject, healthCost);
cost.BypassShields = true;
cost.BypassRules = RuleBypass.Armor | RuleBypass.Affinity;
cost.SourceId = abilityId;
return _casterHealth.ApplyDamage(in cost);
}
/// <summary>
/// Undoes a hex that landed but could not be paid for, and reports honestly when it cannot.
/// </summary>
/// <remarks>
/// <para>The compensating action has its own failure mode, and throwing its result away would
/// be the same mistake one layer down: <c>RemoveStatus</c> also returns <c>void</c>. So the
/// withdrawal is confirmed with <c>HasStatus</c> and the two outcomes are told apart, because
/// "the target kept a hex nobody paid for" is a different bug report from "the cast fizzled".</para>
///
/// <para><b>A stated limitation, and it is not a race.</b> Removal is by id, so a hex this
/// caster placed and one an ally placed a moment earlier are the same status to
/// <c>RemoveStatus</c> — which removes every instance of that id, not this application.</para>
///
/// <para>Because the effect stacks by <c>Refresh</c>, that is deterministic rather than
/// unlucky. A cast onto a target that already carries the hex does not add a second one: it
/// refreshes the existing instance. So if the charge then fails, the withdrawal deletes a hex
/// that <i>predates this cast</i> — the ally's, or this caster's own earlier, fully paid one —
/// every single time, not in a narrow window. What the caster gets back is the price of this
/// cast; what the target loses is a curse somebody had already paid for.</para>
///
/// <para>Making that impossible needs per-source removal, which is not in the public surface —
/// and inventing a way around it here would be worse than saying so. If it matters in your
/// game, the honest options are to refuse the cast when the target already carries the id, or
/// to accept the loss and say so in the feedback.</para>
/// </remarks>
private bool WithdrawAfterFailedCharge(StatusEffectController controller)
{
controller.RemoveStatus(EffectId);
return Refuse(controller.HasStatus(EffectId)
? HexRefusal.CostFailedAndEffectStuck
: HexRefusal.CostFailedAndEffectWithdrawn);
}
/// <summary>
private bool Refuse(HexRefusal reason)
{
Refused?.Invoke(reason);
return false;
}
}
/// <summary>
/// The effect half: a timed curse whose damage is attributed to whoever cast it.
/// </summary>
/// <remarks>
/// <para><b>Written by hand for one reason.</b> The shipped damage-over-time effects tick through
/// <c>HealthStatusHooks.ApplyStatusTick</c>, which with no tick route installed reaches
/// <c>IDamageable.Damage(amount)</c> — an amount and nothing else. There is no attacker on it and
/// no source id, so a poison that kills credits nobody. This one builds a
/// <see cref="DamageContext"/> per tick carrying the caster and the ability id, which is what makes
/// the kill readable afterwards.</para>
///
/// <para><b>The gameplay is in <c>OnTick</c>, not in an FX hook</b>, and that is deliberate. FX
/// hooks are skipped entirely when a controller has global FX switched off — a setting its own
/// tooltip recommends for server-authoritative objects — so an effect that put its damage there
/// would silently do nothing on exactly the objects a dedicated server cares about.</para>
///
/// <para><b>Stacking is <c>Refresh</c></b>, so a re-cast extends the curse rather than running two.
/// The controller replaces the stored <see cref="StatusContext"/> on refresh <i>only when the
/// incoming one is not empty</i>, so a re-cast by a second caster takes over the stored
/// attribution while a top-up carrying <c>StatusContext.None</c> leaves it alone.</para>
///
/// <para><b>That is not the same as taking over the kill.</b> Refresh keeps the ORIGINAL instance:
/// it consumes the incoming effect's <c>Duration</c>, swaps the stored context, and discards the
/// rest — the incoming instance's <c>Apply</c> never runs. The damage below is built from this
/// instance's own constructor-captured <c>_caster</c>, and the tick path never consults the stored
/// context. So a rival's refresh moves every status query and none of the damage: they pay the
/// cost, change the duration, and the kill still credits whoever cast it first. Their
/// <c>damagePerSecond</c> is discarded for the same reason.</para>
///
/// <para>Fractional damage is carried between ticks rather than rounded away, so four damage per
/// second is four damage per second at any frame rate rather than zero at sixty.</para>
/// </remarks>
public sealed class HexStatus : TimedStatusEffect, IStatusMetadata, IDispellable
{
private readonly StatusId _id;
private readonly float _damagePerSecond;
private readonly GameObject _caster;
private readonly string _sourceId;
private float _carried;
private bool _warnedNoHealth;
/// <summary>The status id an ability of this name produces. Lowercase, ordinal, yours.</summary>
public static string IdFor(string abilityId) => $"hex.{abilityId}";
/// <summary>Creates a hex.</summary>
public HexStatus(string id, float duration, float damagePerSecond, GameObject caster, string sourceId)
: base(duration)
{
_id = new StatusId(id);
_damagePerSecond = Mathf.Max(0f, damagePerSecond);
_caster = caster;
_sourceId = sourceId;
}
/// <inheritdoc />
public override StatusId Id => _id;
/// <inheritdoc />
public override StatusStackingRule Stacking => StatusStackingRule.Refresh;
/// <inheritdoc />
public StatusTag Tags => StatusTag.Debuff | StatusTag.Magic;
/// <inheritdoc />
public DispelType Dispel => DispelType.Magic;
/// <inheritdoc />
public int DispelTier => 1;
/// <summary>Damage banked but not yet whole. Exposed so a test can prove it is carried.</summary>
public float Carried => _carried;
/// <inheritdoc />
protected override void OnTick(GameObject target, float dt)
{
if (!target || dt <= 0f || _damagePerSecond <= 0f)
return;
_carried += _damagePerSecond * dt;
int whole = Mathf.FloorToInt(_carried);
if (whole <= 0)
return;
_carried -= whole;
// Colocation, and it is worth being loud about. The controller ticks its effects against
// its OWN GameObject, so this is the controller's object -- not whatever was passed to
// TryCast, which may have been a child of it. Wire the controller above the HealthSystem
// and the cast still succeeds, HasStatus still answers true, the caster still pays, and
// every tick from then on quietly does nothing. That is a bad afternoon to debug from
// silence, so say it once and stop.
if (!target.TryGetComponent(out HealthSystem health))
{
if (!_warnedNoHealth)
{
_warnedNoHealth = true;
Debug.LogWarning(
$"[{nameof(HexStatus)}] '{target.name}' carries the StatusEffectController but no " +
$"{nameof(HealthSystem)}, so '{_id}' can never deal its damage. The two must be " +
"on the same GameObject: an effect ticks against the controller's object, whichever " +
"object was targeted.", target);
}
return;
}
// The attribution, which is the whole reason this class exists rather than PoisonStatus.
// A destroyed caster is left as null rather than passed on: the reference is fake-null, so
// handing it to the pipeline would put a dead object where an attacker belongs.
DamageContext tick = DamageContext.CreateBasic(_caster ? _caster : null, target, whole);
tick.SourceId = _sourceId;
health.ApplyDamage(in tick);
}
}
}
Wiring it up¶
- Put
BloodHexon the caster. It needs theHealthSystemthat is already there. -
Make sure the target has a
StatusEffectController, on it or on a parent — and that theHealthSystemis on that same object, not on a child of it.The controller and the health must be colocated
TryCastfinds the controller withGetComponentInParent, so you may aim at a child and have the hex land on the parent that holds the controller. The tick does not walk: a controller ticks its effects against its own GameObject, so that is whereHexStatuslooks for theHealthSystem.Split them and nothing announces it — the cast succeeds,
HasStatusanswerstrue, the caster pays the blood price, and every tick from then on does nothing.HexStatuslogs one warning the first time it happens rather than leaving you to find it, but the wiring is the real fix.It does not walk down on purpose.
GetComponentInChildrenwould pick an arbitrary health in a rig that has several, and choosing which one a curse damages is a game's decision, not a recipe's. The framework's ownHealthStatusHooks.ApplyStatusTickis colocated for the same reason.- Call it from whatever your game already uses for input, AI or a hotbar:
if (!hex.TryCast(currentTarget))
{
// The refusal reason is the useful half -- each one is a different line of feedback.
}
- Subscribe for feedback:
hex.Cast += (target, paid) => Hud.Flash($"-{paid} health");
hex.Refused += reason => Hud.Say(reason switch
{
HexRefusal.CannotAfford => "Not enough blood.",
HexRefusal.EffectRefused => "They are unaffected.",
HexRefusal.NoTarget => "No target.",
_ => "Nothing happened.",
});
- Grey the button out with
CanAfford(), which is the same question with no side effects.
What it deliberately does not do¶
No cooldown. A cooldown is a timestamp and a comparison, your game already has a clock, and the framework's own cooldown component is [Obsolete]. Adding one here would be adding a field, not a capability. If it has to survive a reload, that is a small flat fact about the world — one store, many facts is where it goes.
No targeting model. The caller passes the target. Cones, chains, smart-target, ground-target and self-cast are combat design, and a recipe that picked one would be picking your game's.
No ability definition asset. No ScriptableObject per ability. That is the content model arriving, and it is the exact thing this category exists to demonstrate you do not need. StatusContext.FromAbility accepts an abilityDef if your game has one — this recipe passes null, and nothing suffers for it.
No registry, no base class, no IAbility. This is an ability. A second one is a second class that shares nothing with this. Two abilities with an interface between them is a framework, and the moment this file needed a sibling it would stop being a recipe.
No scaling. IStatusPotency and IAttributeSource are the seams for "this hex is stronger because the caster is smarter", and they are already documented where they live. Wiring them in here would double the page to demonstrate something that is not about abilities.
Related¶
- Who killed it — what reads the attribution this ability writes. A hex that lands the killing tick credits the caster, six seconds after they walked away.
- The same shop with a wallet that is a body — where the three cost fields come from, and the fuller argument for each.
- A bench that charges health — the same refuse-then-charge ordering at a crafting station, with the transaction one layer deeper.
- Two 1.5x curses make you take 50% more — what happens when several effects of this shape land on one target.
- Status Effects — the controller, the stacking table, the authority and immunity seams this ability is gated by.