Skip to content

Fire damage that actually sets you on fire

Your flaming sword deals fire damage. The victim takes more of it if they are weak to fire, and less if they resist it. What they never do is catch fire — because nothing in the framework turns a hit into a status, and the burn you were picturing has to be written.

It is about fifteen lines. The interesting part is that the obvious fifteen lines produce a fire that never goes out.

Recipe

Systems required: Health, Status Effects. 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 actor being hit has a StatusEffectController and a DamageRuleHub — the component brings the hub with it — and that your weapons tag their damage. 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

The framework already connects statuses and damage tags. It does it in one direction.

DamageTag names Fire and Poison. Burn and Poison statuses ship. And when a burn ticks, the integration tags the tick:

private static DamageTag TagFor(string statusId)
{
    if (statusId == StatusRegistry.Id.Burn) return DamageTag.Fire;
    if (statusId == StatusRegistry.Id.Poison) return DamageTag.Poison;

    return DamageTag.None;
}

So a status becomes typed damage already — that is why a fire immunity can stop a burn ticking. Nothing goes the other way. No shipped component turns typed damage back into a status, on any route: not as a damage rule, not from the Damaged event. The loop is half-built, and this recipe is the other half.

Which is exactly why the obvious version burns for ever

A burn tick is not a quiet health write. ApplyTaggedTick builds a DamageContext and pushes it through the full pipeline:

var ctx = DamageContext.CreateBasic(attacker: null, victim: target, amount, TagFor(statusId));
mutator.ApplyDamage(in ctx);

POST observers included. So a rule that says "any Fire hit applies Burn" is re-triggered by the burn's own ticks — refreshing the duration, every tick, for ever.

A permanent debuff, assembled entirely from documented behaviour, with the two halves documented in different systems and joined up nowhere.

The attacker is the discriminator, and the framework hands it to you

The same integration says what separates a blow from a tick, in its own remarks:

No attacker is supplied. A damage-over-time tick has no live attacker at the moment it fires.

So ctx.Attacker is non-null for a real hit and null for a tick. One line:

if (!ctx.Attacker)
    return;

That is not a null check, it is the mechanic

Delete it and the recipe still compiles, still applies burn on hit, and still looks right in a quick test — the fire simply never goes out. It is the sort of bug that is invisible for as long as nobody watches a burning enemy for more than four seconds.

The rule lives on the victim; the intent belongs to the attacker

HealthDamageProcessor reads host.DamageRuleHub, and the host is the victim's health system. Every damage rule in this framework — including the shipped CritRule, which reads like an attacker's property — sits on the thing being hit.

So 'my flaming sword sets people alight' cannot be written on the sword

It is written once, on everything that can burn, and keyed on what the attacker put into the context. That is what DamageTag is for: tag the weapon's damage Fire, and this rule does the rest for every victim that carries it.

Convenient, once you see it: one component on an enemy prefab handles every fire source in the game, and a new fire weapon needs no new wiring at all.

POST, not PRE, and the reason is a shield

IDamageRule.Apply runs before shields and before the hit can be cancelled. IPostDamageRule.OnDamageApplied runs with FinalApplied settled.

Igniting in PRE sets fire to people a ward saved

A hit fully absorbed by a shield still ran the PRE chain. Applying the status there burns a victim who took no damage at all — and the same goes for a hit some other rule cancelled outright.

FinalApplied is the number that says whether anything landed, and it only exists in POST.

What it deliberately does not do

It does not stack. Every shipped status except slow and haste uses Replace, so a second fire hit refreshes one burn rather than lighting a second. That is the effect's own decision and not this component's to override.

It does not scale the status by the hit. A grazing blow and a critical apply the same burn. Making severity follow damage means IAdjustableMagnitude and a live handle on the applied effect, which is a different recipe.

It does not apply more than one status per blow. DamageTag is a bit field, so one swing can be Melee | Fire | Crit; matching every row would let a single hit stack three debuffs. First matching row wins — order them most specific first.

It does not tell you the status was refused, beyond a warning. ApplyStatus returns void and does nothing at all when an IStatusImmunity blocks the id or an authority denies it, so this checks HasStatus either side and warns once. A victim already carrying that id absorbs the refusal unnoticed — presence is the only evidence available. Brewing straight into a buff covers that gap from the crafting side, and it is the framework's parked question 7.

Drop it in

StatusOnHit.cs
using System;
using System.Collections.Generic;

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

using RevGaming.RevFramework.StatusEffects.Abstractions;
using RevGaming.RevFramework.StatusEffects.Core;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.StatusOnHit
{
    /// <summary>
    /// Fire damage that actually sets the victim on fire: a hit carrying a chosen
    /// <see cref="DamageTag"/> leaves a status behind — and the burn it starts still runs out.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
    /// <b>Health</b>, <b>Status Effects</b> — the same package, so this runs on Health &amp; Status
    /// Effects as well as Complete. Public API only. It goes on the actor being <i>hit</i>, beside its
    /// health component and its <see cref="DamageRuleHub"/>.</para>
    ///
    /// <para><b>The framework maps statuses to damage tags, in one direction only.</b>
    /// <c>DamageTag</c> names <c>Fire</c> and <c>Poison</c>; <c>Burn</c> and <c>Poison</c> statuses
    /// ship; and <c>StatusRegistryHealthIntegration.TagFor</c> tags a burn's tick damage as
    /// <c>Fire</c> and a poison's as <c>Poison</c>. So <b>status becomes typed damage</b> already.
    /// Nothing closes the loop the other way — no shipped component turns typed damage back into a
    /// status. That is the whole of this recipe, and it is fifteen lines.</para>
    ///
    /// <para><b>Which is exactly why the obvious version never stops burning.</b> A burn tick is not a
    /// direct health write: <c>ApplyTaggedTick</c> builds a <see cref="DamageContext"/> tagged
    /// <c>Fire</c> and pushes it through <c>ApplyDamage</c> — the full pipeline, POST observers
    /// included. So a rule that applies Burn to any <c>Fire</c> hit is re-triggered by the burn's own
    /// ticks, refreshing the duration for ever. <b>The debuff becomes permanent, from behaviour that
    /// is documented at both ends and joined up nowhere.</b></para>
    ///
    /// <para><b>The discriminator is the attacker, and the framework hands it to you.</b> The same
    /// integration's remarks say it out loud: <i>"No attacker is supplied. A damage-over-time tick has
    /// no live attacker at the moment it fires."</i> So <c>ctx.Attacker</c> is non-null for a real blow
    /// and null for a tick, and one check separates them. It is not defensive padding — it is the only
    /// thing standing between this recipe and an eternal fire.</para>
    ///
    /// <para><b>The rule lives on the victim, and the intent belongs to the attacker.</b>
    /// <c>HealthDamageProcessor</c> reads <c>host.DamageRuleHub</c>, and the host is the victim's
    /// health system — so <i>every</i> damage rule, including the shipped <c>CritRule</c>, sits on the
    /// thing being hit. "My flaming sword sets people alight" therefore cannot be written on the sword.
    /// It is written once on everything that can burn, and keyed on what the attacker put in the
    /// context: the tag. Tag the weapon's damage <c>Fire</c> and the rule does the rest.</para>
    ///
    /// <para><b>POST, not PRE, and the reason is a shield.</b> <see cref="IDamageRule.Apply"/> runs
    /// before shields and before the hit can be cancelled, so igniting there sets fire to someone a
    /// ward absorbed the blow for. <see cref="IPostDamageRule.OnDamageApplied"/> runs with
    /// <see cref="DamageContext.FinalApplied"/> settled, which is the number that says whether anything
    /// actually landed.</para>
    ///
    /// <para><b><c>ApplyStatus</c> returns <c>void</c> and refuses in silence</b> when the target is
    /// immune or a status authority denies it, so this checks <c>HasStatus</c> afterwards and warns
    /// once. That gap is the framework's parked question 7, and <c>CraftedStatusEffects</c> covers the
    /// same ground from the crafting side — including the honest limit, which is that presence is the
    /// only evidence available, so a victim already carrying that id absorbs the refusal unnoticed.
    /// </para>
    ///
    /// <para>Two limits, stated rather than hidden. Re-applying is the shipped effects' own business:
    /// every built-in except <c>slow</c> and <c>haste</c> uses <c>Replace</c>, so a second hit refreshes
    /// one burn rather than stacking two — which is usually what you want and is not this component's
    /// decision. And a status this applies is attributed to the victim, not the attacker: the
    /// <see cref="StatusContext"/> carries the attacker as its instigator so a killfeed can read it,
    /// but nothing in the status pipeline scales an effect by who caused it.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    [RequireComponent(typeof(DamageRuleHub))]
    [AddComponentMenu("RevFramework/Cookbook/Status On Hit")]
    public sealed class StatusOnHit : MonoBehaviour, IPostDamageRule
    {
        /// <summary>One damage tag, and the status a hit carrying it leaves behind.</summary>
        [Serializable]
        public struct Ignition
        {
            [Tooltip("Damage must carry this tag. Fire and Poison are the two the shipped statuses " +
                     "already tag their own ticks with.")]
            public DamageTag tag;

            [Tooltip("Status id to apply. Lowercase: StatusId compares ordinally and every id " +
                     "RevFramework ships is lowercase - burn, poison, slow, stun, vulnerability.")]
            public string statusId;

            [Tooltip("Seconds the status runs for.")]
            [Min(0.01f)] public float duration;

            [Tooltip("Magnitude handed to the status builder. Burn and poison read it as damage per " +
                     "second; slow, haste and vulnerability treat 1 as no change.")]
            [Min(0f)] public float magnitude;
        }

        [Tooltip("Controller the status is applied to. Leave empty to use the one on this object.")]
        [SerializeField] private StatusEffectController status;

        [Tooltip("What each tag ignites. The first row whose tag is present wins, so order them " +
                 "most specific first.")]
        [SerializeField] private Ignition[] ignitions =
        {
            new() { tag = DamageTag.Fire, statusId = "burn", duration = 4f, magnitude = 3f },
        };

        [Tooltip("Smallest hit that can ignite. A chip of damage from a wide-area effect setting " +
                 "somebody on fire is rarely intended.")]
        [SerializeField, Min(1)] private int minimumDamage = 1;

        // Warned once per status id rather than once per hit. A refusal is a setup condition -- an
        // immunity, a denying authority, an unregistered id -- and repeats every time the actor is
        // struck, which is the shape that buries the one line worth reading.
        private readonly List<string> _warned = new();

        /// <summary>
        /// Applies the configured status when a real blow carrying the tag has landed.
        /// </summary>
        /// <remarks>
        /// <para>Every early return here is load-bearing; none of them is defensive padding. In order:
        /// a preview must not change the world, a cancelled hit did not happen, a hit that landed
        /// nothing was absorbed, and <b>a null attacker is a damage-over-time tick</b> — see the class
        /// remarks for why that last one is the difference between a burn that ends and one that does
        /// not.</para>
        /// </remarks>
        /// <param name="ctx">The settled damage context.</param>
        public void OnDamageApplied(in DamageContext ctx)
        {
            if (ctx.IsPreview || ctx.Cancelled)
                return;

            // Nothing reached health. A ward absorbed it, or a rule cut it to nothing -- either way
            // the victim was not burned by a blow that never touched them.
            if (ctx.FinalApplied < minimumDamage)
                return;

            // THE guard. A status tick arrives through this same pipeline carrying the same tag it
            // was born from, and with no attacker, because a tick has none. Without this line the
            // burn re-ignites itself on every tick and never expires.
            if (!ctx.Attacker)
                return;

            if (!TryGetIgnition(ctx.Tags, out Ignition ignition))
                return;

            if (!status && !TryGetComponent(out status))
                return;

            var id = new StatusId(ignition.statusId);

            // Duration first, then magnitude. Both are floats, so the compiler cannot catch the swap
            // and a status with a magnitude-long duration looks like one that vanishes instantly.
            if (!StatusRegistry.TryBuild(id, ignition.duration, ignition.magnitude, out IStatusEffect effect))
            {
                WarnOnce(ignition.statusId,
                    $"No status is registered as '{ignition.statusId}', so a hit tagged " +
                    $"{ignition.tag} ignites nothing. Ids compare ordinally and every one " +
                    "RevFramework ships is lowercase.");
                return;
            }

            // Read before the apply, because presence afterwards is the only evidence available and it
            // cannot tell a refused apply from an effect that was already running.
            bool had = status.HasStatus(id);

            status.ApplyStatus(effect, new StatusContext(
                instigator: ctx.Attacker,
                sourceDef: null,
                sourceId: SourceId,
                sourceSlot: 0,
                note: "status on hit"));

            // ApplyStatus is void and does nothing at all when an IStatusImmunity blocks the id or a
            // status authority denies. Assuming it worked is how a fire weapon silently stops working
            // against exactly the enemies somebody configured an immunity for.
            if (!had && !status.HasStatus(id))
            {
                WarnOnce(ignition.statusId,
                    $"'{name}' did not take '{ignition.statusId}': an IStatusImmunity blocks that id, " +
                    "or a status authority denied it. The hit landed and nothing was applied.");
            }
        }

        /// <summary>The first configured row whose tag the hit carries.</summary>
        /// <remarks>
        /// <para>First match rather than every match, and that is a design choice worth stating. A
        /// <see cref="DamageTag"/> is a bit field, so one blow can be <c>Melee | Fire | Crit</c> at
        /// once — applying a status per matching row would let a single hit stack three debuffs from
        /// one swing. Order the rows most specific first.</para>
        /// <para>A row tagged <see cref="DamageTag.None"/> is skipped rather than matching everything:
        /// <c>None</c> is zero, so a mask test against it is true for every hit including untagged
        /// ones, and an empty inspector row would otherwise ignite on contact.</para>
        /// </remarks>
        private bool TryGetIgnition(DamageTag tags, out Ignition ignition)
        {
            ignition = default;

            if (ignitions == null)
                return false;

            for (int i = 0; i < ignitions.Length; i++)
            {
                Ignition candidate = ignitions[i];

                if (candidate.tag == DamageTag.None || string.IsNullOrWhiteSpace(candidate.statusId))
                    continue;

                if ((tags & candidate.tag) != candidate.tag)
                    continue;

                if (candidate.duration <= 0f)
                    continue;

                ignition = candidate;
                ignition.statusId = candidate.statusId.Trim();
                return true;
            }

            return false;
        }

        private const string SourceId = "recipe.statusOnHit";

        private void WarnOnce(string key, string message)
        {
            if (_warned.Contains(key))
                return;

            _warned.Add(key);
            Debug.LogWarning($"[Cookbook] {nameof(StatusOnHit)}: {message}", this);
        }
    }
}

Wiring it up

  1. Put the component on whatever can be set alight — enemies, the player, destructibles. It brings a DamageRuleHub with it, and it needs a StatusEffectController on the same object.
  2. Fill in the ignition rows: a DamageTag, a lowercase status id, a duration and a magnitude. StatusId compares ordinally and every id RevFramework ships is lowercase, so burn builds and Burn silently builds nothing.
  3. Tag your weapons' damage. Nothing ignites until something sets DamageTag.Fire on the hit — that is the attacker's half of the contract and it is where the design lives.
  4. Set minimumDamage above a chip of splash damage if you have area effects. A wide, weak fire aura setting everything in the room alight is rarely what was meant.
  5. Leave the status field empty to use the controller on the same object. Assign it only when the controller genuinely lives elsewhere.
  • Health — the damage pipeline, PRE and POST stages, and where rules run.
  • Status Effects — the registry, stacking rules and immunity.
  • Brewing straight into a buff — the other recipe that applies a status from an event, and the one that documents the void-return gap in full.
  • Only your worst debuff counts — what happens after several of these land, and the seam VulnerabilityStatus looks for first.
  • Last stand — the other recipe built across the PRE and POST stages, for a decision rather than a side effect.