Skip to content

A curse that bleeds gold

While it runs, the victim loses currency every second — through the ordinary status effect pipeline, without Currency needing a concept of curses.

Recipe

Systems required: Status Effects, Currency. Package: Complete only — the systems above ship in different packages, so no single-system package can run this. Shape: one class you drop into a project that already exists. It is a plain C# class, not a component and not an asset. 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

You can write your own status effect, and it can do anything.

Every other recipe here hangs off a component or a ScriptableObject. This one is a status effect. IStatusEffect is a nine-member interface with no registration ceremony — so an effect can reach into a system Status Effects has never heard of.

Poison drains health because somebody wrote an effect that drains health, not because the framework has a concept of poison. Once that lands, the interesting question stops being "what effects are there" and becomes "what should this one do".

There is a base class, and this recipe does not use it

TimedStatusEffect implements the timer half of the interface — Duration, TimeRemaining, IsExpired, Refresh — and every effect the framework ships derives from it. Implementing IStatusEffect directly is what makes the contract visible, which is the point of this page, but it is not the default and you take on two things by doing it.

The controller drives the StatusFx spawn/pulse/despawn bridge only for TimedStatusEffect, so a raw effect draws nothing on its own. And you own Refresh — which is not called only on a re-apply. See below.

Tick is per-frame, and your drain is per-second

The interface hands you deltaTime. Take a fixed amount inside Tick and the curse costs more on a fast machine than a slow one — severity by frame rate, which is a bug report from whoever has the better GPU.

The accumulator is four lines and it is the whole difference.

Keep the fraction, don't round it away

Money is long, so a drain of 2.5 per second has to spend whole coins and carry the remainder to the next tick. At 60fps that is ~0.042 per tick: round each tick and you either charge nothing forever or charge sixty times too much.

A victim who cannot pay is a design decision

When the wallet cannot cover the charge, the curse can stop, keep trying, or run its full duration doing nothing. There is no right answer, which is exactly why it is a flag rather than an accident of where the return went.

The default keeps the debt: whatever the wallet could not cover stays in the accumulator and is taken as money arrives, so a curse cannot be dodged by spending everything.

Debit is all-or-nothing — ask for what they have

No ICurrencyService.Debit takes part of a charge. Ask for more than the balance and it returns InsufficientFunds having moved nothing, so a curse that always charges the full arrears is one a broke victim never pays: the debt grows at the drain rate while the income arrives a few coins at a time, so the amount asked for is never once small enough to succeed.

Measured over 30 seconds at 2.5/second, a victim broke at apply who then earns 1 gold a second loses nothing at all while visibly holding money. Read the balance, debit the smaller of it and what is due, and carry the shortfall.

Success is not proof the coins moved

A CapMode.Clamp policy takes less than was asked for and still returns Ok. The recipe subtracts the difference between the balance it read before and after, not the amount it asked for, so the ledger stays honest whatever the wallet is wrapped in.

The debit passes reason: and no sourceId. The reason names the curse in the audit trail; a sourceId carrying a request id would let the idempotency decorator read a per-second stream of identical charges as one replay and report success without moving anything.

A non-finite duration is coerced to zero

An effect that stores an infinite duration reports an infinite TimeRemaining and a NaN normalised readout — including the fill on a status icon, which then renders as nothing at all — which is why the constructor guards it.

Know what the guard costs. A zero, negative or non-finite duration produces a curse that is applied, expires on the very next update and charges nothing, with no diagnostic. A curse that should not expire on its own wants a very large finite duration.

One instance per application. An effect carries its own timer and its own carried remainder, so handing the same instance to two victims makes them share both. Build a new one each time — the rule every effect here follows, and the one most easily broken by caching "the curse" in a field.

The id is what the framework keys on, so this one carries the currency: cursedpurse:gold. Stacking, HasStatus, RemoveStatus, dispels and the save participant all match on Id — give two curses one id and the second application is silently dropped as a refresh of the first, on the wrong instance.

It is dispellable. StatusTag.Debuff | StatusTag.Magic, DispelType.Curse, tier 1 — three properties from IStatusMetadata, which every shipped effect implements. Leave them off and an effect sits in the None bucket with no tags, where Dispel(DispelType.Curse) and CleanseByTag cannot reach it at all: only RemoveStatus by id or ClearAll ends it early.

Refresh is called on the first apply too

This is the member with the non-obvious contract, and owning it is the price of not deriving from TimedStatusEffect.

Set the remaining time, don't take the max of it

The controller calls Refresh on the first apply as well as on a re-apply, handing it the duration already scaled by any IStatusResistance on the target. That scale is clamped to 0..1, so on a fresh apply the incoming value is never the larger one.

Write Mathf.Max(_remaining, extend) — which reads like a safe "never shorten a running effect" — and every resistance is silently discarded, including a provider returning 0 for blocked. A sanctuary zone that halves debuff durations has no effect on it; a target with total immunity takes it at full duration. TimedStatusEffect.Refresh sets, and so does this.

The carried fraction survives a refresh, on purpose: re-applying should extend the curse, not hand the victim back the part-second they had already been charged for.

Drop it in

CursedPurse.cs
using RevGaming.RevFramework.Currency;
using RevGaming.RevFramework.Currency.Abstractions;
using RevGaming.RevFramework.StatusEffects.Abstractions;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.CursedPurse
{
    /// <summary>
    /// A curse that bleeds gold: while it runs, the victim loses currency every second, through the
    /// ordinary status effect pipeline and without Currency needing a concept of curses.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
    /// <b>Status Effects</b>, <b>Currency</b> — different packages, so <b>Complete only</b>. Public
    /// API only.</para>
    ///
    /// <para><b>The composition is that you can write your own effect.</b> Every other recipe here
    /// hangs off a component or an asset; this one <i>is</i> a status effect, so it can reach into a
    /// system Status Effects has never heard of. Poison drains health because somebody wrote an effect
    /// that drains health, not because the framework has a concept of poison.</para>
    ///
    /// <para><b>There is a base class, and this deliberately does not use it.</b>
    /// <c>TimedStatusEffect</c> implements the timer half and every shipped effect derives from it;
    /// implementing <see cref="IStatusEffect"/> directly is what makes the contract visible. Two things
    /// you take on: the controller drives the StatusFx spawn/pulse/despawn bridge only for
    /// <c>TimedStatusEffect</c>, so a raw effect draws nothing on its own, and you own
    /// <see cref="Refresh"/> — read its remarks, because it is not called only on a re-apply.</para>
    ///
    /// <para><b><see cref="Tick"/> is per-frame and the drain is per-second.</b> The interface hands
    /// you <c>deltaTime</c>, so a fixed amount taken per tick costs more on a fast machine — a curse
    /// whose severity depends on frame rate. Money is <c>long</c> as well, so the accumulator also
    /// carries the fraction: rounding each tick loses it every time and quietly halves a slow
    /// curse.</para>
    ///
    /// <para><b>A victim who cannot pay is a design decision, made explicit.</b> A debit is
    /// all-or-nothing, so the curse asks for what the wallet can cover and keeps owing the rest.
    /// Charge the whole amount instead and a victim whose income arrives in smaller instalments than
    /// the debt pays nothing at all — the arrears outgrow the drip, the sum asked for is never small
    /// enough to succeed, and staying broke becomes a free exit. When the wallet is short,
    /// <c>expireWhenBroke</c> picks whether the curse stops or keeps trying; there is no right answer,
    /// which is why it should not be an accident of where the <c>return</c> went.</para>
    ///
    /// <para><b>The id is what the framework keys on</b>, so it carries the currency. Stacking,
    /// <c>HasStatus</c>, <c>RemoveStatus</c>, dispels and the save participant all match on
    /// <see cref="Id"/>: two curses sharing one id collapse onto each other, and the second
    /// application is silently dropped as a refresh of the first.</para>
    ///
    /// <para><b>A non-finite duration is coerced to zero,</b> because an infinite one reports an
    /// infinite <c>TimeRemaining</c> and a <c>NaN</c> normalised readout — including a status icon
    /// fill, which then renders as nothing. Know what the guard costs: a zero, negative or non-finite
    /// duration produces a curse that is applied, expires on the next update and charges nothing. One
    /// that should not expire wants a very large finite duration.</para>
    ///
    /// <para><b>One instance per application.</b> An effect carries its own timer and its own carried
    /// remainder, so handing the same instance to two victims makes them share both — the rule every
    /// effect here follows, and the one most easily broken by caching "the curse" in a field.</para>
    /// </remarks>
    public sealed class CursedPurse : IStatusEffect, IStatusMetadata, IDispellable
    {
        private readonly StatusId _id;
        private readonly CurrencyId _currency;
        private readonly float _perSecond;
        private readonly bool _expireWhenBroke;

        private float _remaining;
        private float _carried;
        private bool _brokeOff;

        /// <summary>
        /// Builds a curse that drains one currency for a fixed duration.
        /// </summary>
        /// <param name="currencyId">What it drains. An unusable id is reported and the curse charges nothing.</param>
        /// <param name="perSecond">How much per second. Fractions are carried between ticks.</param>
        /// <param name="duration">Seconds it runs. Must be finite — see the type remarks.</param>
        /// <param name="expireWhenBroke">Whether a wallet that cannot cover the charge ends the curse or lets it keep trying.</param>
        public CursedPurse(string currencyId, float perSecond, float duration, bool expireWhenBroke = false)
        {
            _currency = new CurrencyId(currencyId);
            _id = new StatusId("cursedpurse:" + _currency.value);

            if (!_currency.IsValid)
                Debug.LogError($"[CursedPurse] \"{currencyId}\" is not a usable currency id, so this curse will charge nothing.");

            // Finiteness is tested, not clamped: Mathf.Max(0f, NaN) returns NaN, because `0 > NaN` is
            // false. A NaN rate slips past the `<= 0f` guard in Tick and poisons the accumulator.
            _perSecond = _currency.IsValid && float.IsFinite(perSecond) ? Mathf.Max(0f, perSecond) : 0f;
            Duration = float.IsFinite(duration) ? Mathf.Max(0f, duration) : 0f;

            _expireWhenBroke = expireWhenBroke;
            _remaining = Duration;
        }

        /// <inheritdoc />
        /// <remarks>
        /// Scoped to the currency, and built once into a field rather than per property read — the
        /// controller reads this inside loops, and <c>StatusId</c> caches its ordinal hash on
        /// construction.
        /// </remarks>
        public StatusId Id => _id;

        /// <summary>Tags this curse for tag-filtered cleanses, resistances and UI.</summary>
        public StatusTag Tags => StatusTag.Debuff | StatusTag.Magic;

        /// <summary>The bucket a cleanse names: <c>controller.Dispel(DispelType.Curse)</c> lifts it.</summary>
        public DispelType Dispel => DispelType.Curse;

        /// <summary>Dispel tier. Tier 1 is lifted by an ordinary curse cleanse.</summary>
        public int DispelTier => 1;

        /// <inheritdoc />
        public float Duration { get; }

        /// <inheritdoc />
        public float TimeRemaining => _remaining;

        /// <inheritdoc />
        public bool IsExpired => _remaining <= 0f || _brokeOff;

        /// <inheritdoc />
        /// <remarks>
        /// Refresh rather than Stack: two curses running at once would drain at double rate and read as
        /// one icon, which is the kind of stacking players describe as "it randomly takes way more".
        /// </remarks>
        public StatusStackingRule Stacking => StatusStackingRule.Refresh;

        /// <inheritdoc />
        public void Apply(GameObject target) { }

        /// <inheritdoc />
        /// <remarks>
        /// Called every frame while the effect lives. Everything about the drain rate lives here, which
        /// is why the accumulator matters more than it looks. The debit passes a <c>reason</c> and no
        /// <c>sourceId</c> on purpose: the reason names the curse in the audit trail, while a
        /// <c>sourceId</c> carrying a request id would let the idempotency decorator read a per-second
        /// stream of identical charges as one replay and report success without moving anything.
        /// </remarks>
        public void Tick(GameObject target, float deltaTime)
        {
            if (_brokeOff || _remaining <= 0f)
                return;

            _remaining -= deltaTime;

            if (!target || _perSecond <= 0f)
                return;

            // Carried, not rounded. A drain of 2.5/second at 60fps is ~0.042 per tick, and rounding that
            // to a whole coin every frame either charges nothing forever or charges 60x too much.
            _carried += _perSecond * deltaTime;

            int due = Mathf.FloorToInt(_carried);
            if (due <= 0)
                return;

            // Resolved before the accumulator is touched: a scene whose currency service has not
            // published yet is an expected state, and a missing wallet is not a payment.
            var wallet = CurrencyResolve.ServiceFrom(target);
            if (wallet == null)
                return;

            // Ask for what the wallet can cover, not for `due`. Debit is all-or-nothing, so charging
            // the full amount to a victim a single coin short takes nothing.
            long before = wallet.GetBalance(target, _currency).amount;
            int take = before >= due ? due : (before > 0L ? (int)before : 0);

            if (take > 0)
            {
                var result = wallet.Debit(target, _currency, new Money(take), reason: "cursedpurse");

                // A misconfigured id is not a broke victim. Note the limit of reading the code here: a
                // currency that is merely wrong -- "golde" for "gold" -- is a valid id with a zero
                // balance, and nothing in the result distinguishes that from an empty wallet.
                if (result.Code == CurOpCode.InvalidArgs)
                    return;
            }

            // What the wallet actually lost, not what the call claimed: a CapMode.Clamp policy takes
            // less than was asked for and still reports success.
            long moved = before - wallet.GetBalance(target, _currency).amount;
            if (moved > 0L)
                _carried -= moved;   // only what moved leaves the ledger; the rest stays owed

            if (moved >= due)
                return;

            // Came up short. What that means is the game's decision, not the curse's -- so it is a flag
            // rather than a hidden default.
            if (_expireWhenBroke)
                _brokeOff = true;
        }

        /// <inheritdoc />
        public void Remove(GameObject target) { }

        /// <inheritdoc />
        /// <remarks>
        /// <para>The controller calls this on the <i>first</i> apply as well as on a re-apply, handing
        /// it the duration already scaled by any <c>IStatusResistance</c> on the target — so it has to
        /// <b>set</b> the remaining time, exactly as <c>TimedStatusEffect</c> does. Taking the
        /// <c>Mathf.Max</c> of the old and new values looks harmless and is not: the scale is clamped to
        /// 0..1, so on a fresh apply the incoming value is never the larger one, and every resistance —
        /// including a provider returning 0 for "blocked" — is silently discarded.</para>
        ///
        /// <para>The carried fraction survives on purpose: re-applying a curse should extend it, not
        /// hand the victim back the part-second they had already been charged for. A refusal that had
        /// stopped the curse is cleared, because a fresh application is a fresh decision.</para>
        /// </remarks>
        public void Refresh(float? newDuration = null)
        {
            float extend = newDuration ?? Duration;

            if (!float.IsFinite(extend))
                return;

            _remaining = Mathf.Max(0f, extend);
            _brokeOff = false;
        }
    }
}

Wiring it up

  1. Build one and apply it like any other effect: controller.ApplyStatus(new CursedPurse("gold", 2.5f, 30f), context). Its id is cursedpurse:gold, and that is the string HasStatus and RemoveStatus want.
  2. Or register a factory with StatusRegistry if you want it buildable by id from data — one registration per currency, under the matching id, with the currency baked into the closure. The build delegate is (duration, magnitudeOrMult) and cannot carry a currency, and the registered key must equal the effect's own Id or every controller query will miss it.
  3. The victim needs a currency service resolvable from their GameObject — the same one everything else uses.

What it deliberately does not do

It does not stack. Two running at once would drain at double rate behind one icon, which players describe as "it randomly takes way more". Refresh is the honest rule here.

It does not steal. The money is destroyed, not transferred. A curse that pays a thief is a different effect and wants a second owner on it.

It does not show itself. No icon, no VFX, no sound. The controller's StatusFx bridge is driven only for TimedStatusEffect, so a raw effect gets none of it and the display is entirely your UI's — which is the trade you accept for owning the whole interface.

It does not round-trip through a save. The Status Effects save participant persists the id and the remaining time and nothing else, so the drain rate and the expireWhenBroke choice have to come back from whatever your restore factory builds for that id. Vary those per curse and the id has to vary with them.