Skip to content

Who killed it

The health component already wrote down who landed the killing blow. The whole recipe is knowing the one moment that record is guaranteed to be about this death.

Recipe

Systems required: Health. Package: Health & Status Effects, or Complete. Shape: one file holding two small components — a credit reporter on the thing that dies, and a tally on the thing that killed it. Public API only. It assumes: a HealthSystem on the victim, and that whatever deals damage passes itself as the attacker. 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

Almost every Unity project plumbs the killer through its own damage call — a DealDamage(target, amount, attacker) wrapper, an event carrying the instigator, a field the enemy sets on itself before it dies — because it assumes the health component did not keep that information.

It did.

HealthSystem records a LastDamageReport on every hit that reaches the damage math, and hands it back through TryGetLastDamageReport:

public readonly struct LastDamageReport
{
    public readonly int Requested;          // asked for, before rules and shields
    public readonly int Applied;            // HP actually lost
    public readonly bool Cancelled;
    public readonly bool BypassShields;
    public readonly bool FromKillCommand;
    public readonly GameObject Attacker;    // may be null
    public readonly string SourceId;        // may be null or empty
}

The attacker is in there. So is the source id, which is what separates "killed by the dragon" from "killed by the fire the dragon left behind". Nothing in the framework consumes this — it is recorded for you and read by almost nobody — the before-death seam consumes it (TryCancelDeath, ExtraLifeTotemHandler), and nothing else does. Which is why it is easy to spend an afternoon rebuilding something that is already there.

The one moment it is true

The report is one field on the victim, not a log. The next hit overwrites it and a revive clears it. So the question is not how to read it but when, and there is exactly one answer that is always right: inside a Died handler.

That comes from the order the damage pipeline runs in — and it is a strong rule with one reachable exception, which the next section is about:

ctx.FinalApplied = applied;
host.RecordLastDamage(in ctx, requested);   // the report is written here

host.EmitDamageAppliedEvents(applied);      // ...then Damaged fires
host.NotifyPostDamage(in ctx);              // ...then POST rules run

if (lethal)
    host.HandleDeathInternal();             // ...and only then, death

The framework's own comment on those lines says why: "The report is recorded before any seam that can re-enter." A listener that applies a nested hit records its own report from the deeper frame, and recording afterwards would let the outer frame overwrite the newer record on the way out. The fix for that bug is what makes this recipe reliable — reading inside Died sees the hit you are being notified about, never the previous one.

Do not defer the read

A coroutine started from Died, a death-animation callback, an Invoke a quarter of a second later — each of those reads a field that may have moved on, and the object it lives on may not be there at all. A victim is commonly destroyed or returned to a pool by another Died listener.

Worse, the deferred read usually works: in a quiet test scene nothing else is taking damage, so the stale field happens to hold the right answer. It stops working when the game gets busy, which is the most expensive time to find out.

Resolve inside the handler. Queue the presentation afterwards, carrying the values you already read.

Two ways to die, and only one leaves a report

This is the part worth checking against the source rather than believing, and the answer is better than "usually the right hit".

How death was reached What TryGetLastDamageReport does inside Died
Damage — anything through TakeDamage and the pipeline Returns true. The attacker, source id and applied damage are this blow's
Kill() — a scripted death Returns false. The report is cleared before death is handled

So only two paths raise Died, and inside it the report holds the most recent evaluated damage attempt against this victim. On an ordinary hit that is the killing blow — which is what lets this component be a dozen lines instead of a subsystem that sanity-checks what it was handed.

"Most recent evaluated attempt" is not a hedge, and the gap is reachable. See below.

There is a third branch in the source, and it cannot be reached

SetMaxHealth contains a clamp-caused death path — lower the maximum below current health, current health follows it down, and if it lands on zero the component dies. That branch clears the report first, with its own comment saying why: a clamp is not a blow.

It is unreachable through the public API. The same method floors the new maximum at 1 before clamping (maxHealth = Mathf.Max(1, value)), so current health can never be clamped to zero from a positive value. Lowering the maximum wounds; it does not kill.

Named here only so nobody reads that branch, assumes a third way for attribution to go missing, and writes a case to handle it. The probe suite pins the flooring, because if it ever stopped being true the count in this section would change.

The report can be overwritten before Died runs

The pipeline records the report, then raises Damaged, HealthChanged and the IPostDamageRule observers, and only then enters the death flow. isDead is not set until death is finalised, so nothing in that window rejects a further attempt against the victim:

host.RecordLastDamage(in ctx, requested);   // written here

host.EmitDamageAppliedEvents(applied);      // Damaged, HealthChanged, the UnityEvents...
host.NotifyPostDamage(in ctx);              // ...then every IPostDamageRule

if (lethal)
    host.HandleDeathInternal();             // ...and only now, death

Anything that evaluates damage against the dying victim from inside that window overwrites the slot before your Died handler reads it, and the credit goes to the prober.

And the report it leaves is worse than merely wrong. The victim's health has already been decremented to zero by the time those events run, so the prober's hit evaluates to nothing — and an attempt past the guards records whatever it evaluated to. The framework documents that plainly:

Everything past those guards does record — including attempts cancelled by a PRE rule, attempts that evaluate to zero, and attempts fully absorbed by a shield.

So your Died handler is handed a live attacker with Applied == 0. It credits the wrong actor and quietly breaks the "overkill line" use suggested further down this page.

Where the boundary sits is narrower than "any nested call breaks it", and worth knowing: a non-positive requested amount is rejected by a guard before the damage math, records nothing, and leaves the previous report intact. A probe for 0 is harmless. A probe for 1 is not — not because one point matters, but because it gets past the guard.

Nothing shipped opens this window — your own code might

No first-party POST rule or damage listener touches the victim again. The reflect rule in particular cannot do it: it aims at the attacker, not the victim, and refuses a self-hit.

It takes code of your own to open it — an on-hit proc, a pain echo, a rule that deals a second instance of damage. If your game has one, kill credit is yours to make atomic: snapshot the report in a Damaged handler, or route the attribution yourself rather than reading it back.

Subscription order is the other way in

A Died subscriber earlier in the invocation list that calls Revive() — an ordinary respawn manager — clears the report before this component's handler runs, and C# gives no ordering guarantee across subscribers. The miss is reported as NoReport, which is honest but indistinguishable from a scripted kill.

Kill() crediting nobody is documented behaviour, not a defect

It is tempting to read "the scripted kill lost the attacker" as something the framework should fix. It should not. A kill command is the game asserting an outcome, not an actor landing a blow — there is no attacker, and inventing one would be the framework guessing at your intent.

Kill() clears the report deliberately, and Revive()'s own XML documentation says the same of itself: "this also clears the last damage report… including one triggered from the before-death seam, where it discards the record of the blow that nearly landed."

If a cutscene death should credit someone, that is your decision and you already have two ways to make it: deal lethal damage from the responsible actor instead of calling Kill(), or handle NoReport in your own code and attribute it however the scene means it.

Some deaths raise nothing at all

SetCurrentHealth, SetMaxHealthSilent and SetMaxHealthClampNoDeathEvents set the dead flag directly and emit no events, by design — they exist for restore and recalculation, where firing a death would be wrong.

A game that despawns things with SetCurrentHealth(0) will find this component never runs. That is the silent-write API behaving exactly as documented, and the fix is to use the loud one when you mean a death.

The attacker needs a Unity null check, not ?.

Attacker is a GameObject reference captured when the blow landed. By the time the death resolves, that object may be gone — a projectile that despawned on impact is the ordinary case, not the exotic one.

A destroyed UnityEngine.Object is only fake-null. The managed reference is still a live pointer; Unity overloads ==, != and bool to report the destroyed native object as null, and the C# null operators do not go through those overloads:

// Wrong. ?. and ?? are pure reference checks -- a destroyed attacker sails straight past them,
// and the member access on the other side throws MissingReferenceException.
var tally = report.Attacker?.GetComponentInParent<BountyTally>();

// Right. if (x) and x != null use Unity's operator, which knows the object is gone.
GameObject attacker = report.Attacker;
if (!attacker) { /* nobody to credit */ }

This applies to ??= as well, which is the one that catches people caching a reference — the assignment does not happen, because the destroyed reference is not null enough to trigger it.

The distinction is worth keeping, not just surviving

A field nobody ever filled in and an attacker that has since been destroyed are both "no attacker" to if (!attacker) — and they are different events. One is environmental damage; the other is a kill whose killer left.

ReferenceEquals(attacker, null) asks the question Unity's operator deliberately will not, so the two can be told apart and a killfeed can word them differently. That is the only place in this recipe where the raw reference check is the correct one.

Where the credit goes is a walk, not a registry

The report hands back the attacker's GameObject. From there this component walks up to a BountyTally on it or above it, and that is the entire lookup:

BountyTally tally = attacker.GetComponentInParent<BountyTally>(findTallyOnInactive);

No table, no service, nothing to register with on spawn and nothing to unregister from on destroy. That is what makes the arrangement survive pooling, additive scenes, and a killer that was instantiated two seconds ago — the three situations where a registry of live combatants quietly goes wrong.

The walk itself is a decision you own. GetComponentInParent suits a projectile parented under its shooter; a projectile that is not parented needs its own field naming the owner, and a game where the tally lives on a squad rather than an individual walks somewhere else entirely. The framework has no view on any of it.

What a miss means

Attribution failing is the common case, not the error case, and each way of failing is a different sentence in a killfeed. KillCreditMiss names them so your code can branch instead of guessing:

Miss What happened Typical wording
NoReport Kill(), or a killing clamp "Eliminated."
NoAttacker A report with the field never filled in "Killed by the environment."
AttackerDestroyed Someone did it and is no longer here "Killed by a falling rock."
SelfInflicted The victim's own damage, and creditSelfKills is off "Died."
AttackerKeepsNoTally A live killer that does not collect bounty Silence — this is most of them

The last row is worth dwelling on: most things that can deal damage are not things that collect bounty, so AttackerKeepsNoTally fires constantly in a real game and means nothing is wrong. A design that treats every miss as a warning will produce a console full of them within a minute.

Drop it in

KillCredit.cs
using System;

using RevGaming.RevFramework.Health.Abstractions.Contracts;
using RevGaming.RevFramework.Health.UnityIntegration;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.WhoKilledIt
{
    /// <summary>
    /// Why a death produced no credit. The interesting half of kill attribution, because every one of
    /// these is a case a killfeed has to word differently.
    /// </summary>
    public enum KillCreditMiss
    {
        /// <summary>
        /// The victim died with no damage report at all. Two ways to arrive here: a scripted
        /// <c>Kill()</c>, which clears the report before raising death, and a <c>Died</c> subscriber
        /// ahead of this one that revived the victim, because <c>Revive()</c> clears it too. The first
        /// is "nothing killed them"; the second is indistinguishable from it, which is worth knowing
        /// if your game respawns from a death handler.
        /// </summary>
        NoReport,

        /// <summary>
        /// A report, but no attacker on it. Environmental damage, damage-over-time applied by nobody,
        /// or a caller that never filled the field in.
        /// </summary>
        NoAttacker,

        /// <summary>
        /// An attacker that has been destroyed between landing the blow and the death resolving.
        /// Caught by a Unity null check, which is not the same as <c>?.</c> — see the remarks on
        /// <see cref="KillCredit"/>.
        /// </summary>
        AttackerDestroyed,

        /// <summary>The victim killed itself, and this component was told not to credit that.</summary>
        SelfInflicted,

        /// <summary>
        /// A live attacker that carries no <see cref="BountyTally"/>. Ordinary rather than
        /// exceptional: most things that can deal damage in a game are not things that collect
        /// bounty.
        /// </summary>
        AttackerKeepsNoTally
    }

    /// <summary>
    /// Awards a kill to whoever actually landed the killing blow, read from the report Health already
    /// recorded, at the one moment it is guaranteed to describe this death.
    /// </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 framework already wrote down who did it.</b> <c>HealthSystem</c> records a
    /// <see cref="LastDamageReport"/> — attacker, source id, damage applied — on every hit that
    /// reaches the damage math, and exposes it through <c>TryGetLastDamageReport</c>. Almost every
    /// project plumbs the killer through its own damage call instead, because it assumes the health
    /// component did not keep it. It did.</para>
    ///
    /// <para><b>The whole recipe is knowing when to read it.</b> The report is one field on the
    /// victim, overwritten by the next hit and cleared by a revive. Read it a frame later, from a
    /// coroutine, or from a death animation callback, and it may describe a different hit or no hit
    /// at all. Read it inside <c>Died</c> and it is exactly this death — because the damage pipeline
    /// records the report <i>before</i> it enters the death flow, deliberately, so that a listener
    /// sees the hit it is being notified about rather than the previous one.</para>
    ///
    /// <para><b>Two paths raise <c>Died</c>, and only one of them leaves a report.</b> This is the
    /// part worth checking against the source rather than believing:</para>
    /// <list type="bullet">
    ///   <item><description><b>Damage.</b> The report is recorded, then death is handled. The attacker
    ///   is there.</description></item>
    ///   <item><description><b><c>Kill()</c>.</b> Clears the report, <i>then</i> handles death. There
    ///   is no attacker to find, and <c>TryGetLastDamageReport</c> returns <c>false</c> rather than
    ///   handing back a blank report.</description></item>
    /// </list>
    ///
    /// <para>So only two paths <i>raise</i> <c>Died</c>, and inside it the report holds <b>the most
    /// recent evaluated damage attempt</b> against this victim. On an ordinary hit that is the killing
    /// blow, which is what makes this recipe a dozen lines rather than a subsystem.</para>
    ///
    /// <para><b>It is the most recent attempt, not necessarily the lethal one, and the difference is
    /// reachable.</b> The pipeline records the report, then raises <c>Damaged</c>,
    /// <c>HealthChanged</c> and the <c>IPostDamageRule</c> observers, and only then enters the death
    /// flow — and <c>isDead</c> is not set until death is finalised, so nothing rejects a further
    /// attempt against the victim in that window. Any of your own code that evaluates damage against
    /// the dying victim from a damage-window listener or a POST rule overwrites the slot before
    /// <c>Died</c> runs, and the credit goes to the prober. Worse, the victim is already at zero by
    /// then, so the prober's hit evaluates to nothing and records <c>Applied == 0</c> — a live
    /// attacker with no damage on it. The boundary is narrower than "any nested call": a non-positive
    /// <i>requested</i> amount is rejected before the damage math and records nothing, so a probe for
    /// zero is harmless while a probe for one is not.</para>
    ///
    /// <para>Nothing shipped does this: the reflect rule aims at the attacker rather than the victim
    /// and refuses a self-hit, and no first-party POST rule or damage listener touches the victim
    /// again. It is customer code that opens the window — an on-hit proc, a pain echo, a
    /// second-instance-of-damage rule — and if your game has one, kill credit is yours to make atomic
    /// (snapshot the report in a <c>Damaged</c> handler, or route the attribution yourself).</para>
    ///
    /// <para><b>Subscription order is the other way in.</b> A <c>Died</c> subscriber earlier in the
    /// invocation list that calls <c>Revive()</c> — an ordinary respawn manager — clears the report
    /// before this component's handler runs, and C# gives no ordering guarantee across
    /// subscribers.</para>
    ///
    /// <para><b>A third path exists in the source and cannot be reached.</b> <c>SetMaxHealth</c> holds
    /// a branch that handles a clamp driving current health to zero, and clears the report before
    /// raising death — a clamp is not a blow. It is unreachable through the public API: the same
    /// method floors the new maximum at <c>1</c> before clamping, so current health can never land on
    /// zero from a positive value. Lowering the maximum below current health wounds; it does not kill.
    /// Worth knowing only so the branch is not mistaken for a way attribution can go missing.</para>
    ///
    /// <para><b><c>Kill()</c> crediting nobody is the documented behaviour, not a defect.</b> A kill
    /// command is the game asserting an outcome, not an actor landing a blow, so there is no attacker
    /// to record and the framework declines to invent one. If a cutscene death should credit someone,
    /// that is your game's decision and your game makes it — deal lethal damage from the responsible
    /// actor instead of calling <c>Kill()</c>, or handle <see cref="KillCreditMiss.NoReport"/>
    /// yourself.</para>
    ///
    /// <para><b>Some deaths raise nothing at all.</b> <c>SetCurrentHealth</c>,
    /// <c>SetMaxHealthSilent</c> and <c>SetMaxHealthClampNoDeathEvents</c> set the dead flag directly
    /// and emit no events by design — they exist for restore and recalculation flows. A game that
    /// despawns things with <c>SetCurrentHealth(0)</c> will find this component never runs, and that
    /// is the silent-write API behaving as documented rather than a gap here.</para>
    ///
    /// <para><b>The attacker needs a Unity null check, not <c>?.</c>.</b> <c>Attacker</c> is a
    /// <c>GameObject</c> reference captured when the blow landed, and the object may have been
    /// destroyed before the death resolves — a projectile that despawned on impact is the ordinary
    /// case, not the exotic one. A destroyed <c>UnityEngine.Object</c> is only <i>fake</i>-null: the
    /// managed reference is still there, so <c>?.</c>, <c>??</c> and <c>??=</c> all sail straight past
    /// it and the next member access throws. <c>if (attacker)</c> and <c>attacker != null</c> go
    /// through Unity's overloaded operator, which is the one that knows the object is gone.</para>
    ///
    /// <para><b>Where the credit goes is a walk, not a registry.</b> The report hands back the
    /// attacker's <c>GameObject</c>; this component walks from there to a <see cref="BountyTally"/> on
    /// it or above it. No lookup table, no service, nothing to register with and nothing to
    /// unregister from on destroy — which is what makes the arrangement survive pooling, additive
    /// scenes and a killer that was spawned two seconds ago.</para>
    ///
    /// <para><b>What is deliberately yours.</b> What a kill is worth, whether self-kills count,
    /// whether an assist exists at all, how a killfeed words each miss, and whether any of it survives
    /// a reload. This component raises <see cref="Credited"/> and <see cref="Unclaimed"/> and stops;
    /// everything past that point is game policy, and the framework has no opinion on any of it.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    [RequireComponent(typeof(HealthSystem))]
    public sealed class KillCredit : MonoBehaviour
    {
        [Tooltip("What killing this is worth. Your own unit — points, XP, currency you convert later. " +
                 "The framework has no notion of bounty, so this number means whatever you decide.")]
        [SerializeField, Min(0)] private int bounty = 1;

        [Tooltip("Optional label for the credit, passed through to the tally and any killfeed. " +
                 "Left blank, the GameObject's name is used.")]
        [SerializeField] private string bountyLabel = "";

        [Tooltip("Credit the victim when it kills itself. Off by default: self-damage crediting the " +
                 "victim turns a suicide into a payday, which is almost never what a bounty means.")]
        [SerializeField] private bool creditSelfKills;

        [Tooltip("Search deactivated objects when walking up from the attacker to a tally. On by " +
                 "default, because a pooled or despawning attacker is still the one that landed it.")]
        [SerializeField] private bool findTallyOnInactive = true;

        /// <summary>
        /// Raised when a death was attributed: the tally that was awarded, what it was awarded, and the
        /// report it was read from.
        /// </summary>
        /// <remarks>
        /// The report is passed on whole rather than unpacked, because the fields this component does
        /// not use are the ones a killfeed wants — <c>SourceId</c> for "killed by fire",
        /// <c>Applied</c> for an overkill line.
        /// </remarks>
        public event Action<BountyTally, int, LastDamageReport> Credited;

        /// <summary>Raised when a death produced no credit, with the reason it did not.</summary>
        public event Action<KillCreditMiss> Unclaimed;

        private HealthSystem _health;

        /// <summary>What this kill is worth. Read by the tally; exposed for a HUD that previews it.</summary>
        public int Bounty => bounty;

        private void Awake()
        {
            _health = GetComponent<HealthSystem>();
        }

        private void OnEnable()
        {
            // Awake has run for this component, but a scene that instantiates and enables in one frame
            // can still reach here with the field unset if something re-enabled the object from Awake.
            if (!_health)
                _health = GetComponent<HealthSystem>();

            if (_health)
                _health.Died += OnDied;
        }

        private void OnDisable()
        {
            if (_health)
                _health.Died -= OnDied;
        }

        /// <summary>
        /// The whole recipe. Runs inside the death event, which is the only moment the report is
        /// guaranteed to describe this death.
        /// </summary>
        /// <remarks>
        /// <para>Nothing here is deferred, and that is deliberate rather than an optimisation. A
        /// victim is commonly destroyed, pooled or deactivated by another <c>Died</c> listener, so a
        /// coroutine started here may never resume — and even if it did, <c>Revive()</c> clears the
        /// report, so a respawn between the death and the deferred read leaves nothing to attribute.
        /// Resolve now; queue the presentation afterwards if it needs to be slow.</para>
        /// </remarks>
        private void OnDied()
        {
            if (!_health.TryGetLastDamageReport(out LastDamageReport report))
            {
                // Kill() clears the report before raising death, so there is genuinely nothing to
                // attribute. A Died subscriber ahead of this one that revived the victim also lands
                // here, because Revive() clears the report too -- see the class remarks.
                Unclaimed?.Invoke(KillCreditMiss.NoReport);
                return;
            }

            GameObject attacker = report.Attacker;

            // Unity's operator, not ?. -- a destroyed attacker is fake-null and would pass a plain
            // reference check, then throw on the next member access.
            if (!attacker)
            {
                // ReferenceEquals asks the question Unity's operator deliberately will not: is the
                // managed reference itself null? A field nobody filled in and an object that has since
                // been destroyed are both "no attacker" to the check above, and a killfeed words them
                // differently.
                Unclaimed?.Invoke(ReferenceEquals(attacker, null)
                    ? KillCreditMiss.NoAttacker
                    : KillCreditMiss.AttackerDestroyed);
                return;
            }

            if (!creditSelfKills && attacker == gameObject)
            {
                Unclaimed?.Invoke(KillCreditMiss.SelfInflicted);
                return;
            }

            BountyTally tally = attacker.GetComponentInParent<BountyTally>(findTallyOnInactive);
            if (!tally)
            {
                Unclaimed?.Invoke(KillCreditMiss.AttackerKeepsNoTally);
                return;
            }

            string label = string.IsNullOrWhiteSpace(bountyLabel) ? name : bountyLabel;

            tally.Award(bounty, label);
            Credited?.Invoke(tally, bounty, report);
        }
    }

    /// <summary>
    /// What a killer has collected. The component <see cref="KillCredit"/> walks to, and the one that
    /// makes the attribution worth reading.
    /// </summary>
    /// <remarks>
    /// <para><b>This is the game's, entirely.</b> A running total with a label on the last award is
    /// the smallest thing that makes kill credit useful; a real game replaces it with whatever it
    /// already has — an XP curve, a wallet through Currency, a contract that only counts one kind of
    /// target. Nothing in the framework knows this class exists, and deleting it changes nothing
    /// except that <see cref="KillCredit"/> stops finding anywhere to put the credit.</para>
    ///
    /// <para><b>It holds no state across a reload.</b> A total that should survive one wants a save
    /// participant, and the framework has a first-class place for it — see the page for where to put
    /// a counter like this without writing a participant per killer.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    public sealed class BountyTally : MonoBehaviour
    {
        [Tooltip("Starting total. Set by your own progression code in a real game; serialised here so " +
                 "the component is useful the moment it is added.")]
        [SerializeField] private int total;

        /// <summary>Everything collected so far.</summary>
        public int Total => total;

        /// <summary>What the most recent award was called. Empty until something is awarded.</summary>
        public string LastAwardLabel { get; private set; } = "";

        /// <summary>Number of awards taken, which is the kill count when every kill awards once.</summary>
        public int AwardCount { get; private set; }

        /// <summary>Raised on every award that changed the total. Arguments = (before, after).</summary>
        /// <remarks>
        /// Carries both ends for the same reason <c>AttributeDelta</c> does: a HUD that counts up wants
        /// the distance, and a threshold — a rank, a contract completing — is a question about whether
        /// a boundary sits between the two, which one number cannot answer.
        /// </remarks>
        public event Action<int, int> Changed;

        /// <summary>
        /// Adds to the total and reports it. Non-positive amounts are recorded as an award that moved
        /// nothing rather than refused, because a zero-bounty target is a legitimate thing to kill.
        /// </summary>
        public void Award(int amount, string label)
        {
            int before = total;
            total = amount > 0 ? total + amount : total;

            AwardCount++;
            LastAwardLabel = label ?? "";

            if (total != before)
                Changed?.Invoke(before, total);
        }

        /// <summary>Resets the tally. Yours to call — a new run, a new contract, a spent reward.</summary>
        public void Clear()
        {
            int before = total;

            total = 0;
            AwardCount = 0;
            LastAwardLabel = "";

            if (before != 0)
                Changed?.Invoke(before, 0);
        }
    }
}

Wiring it up

  1. Put KillCredit on anything that can die and is worth something. It needs the HealthSystem that is already there; set bounty to whatever your game counts in.
  2. Put BountyTally on the player, or on whatever should collect — a squad root, a team object, a contract holder.
  3. Make sure your damage calls name their attacker. This is the one thing the recipe cannot do for you: a DamageContext built without an attacker produces a report with a null one, and every kill lands in NoAttacker.
  4. Subscribe where you already draw things:
// A killfeed line, worded by why attribution succeeded or failed.
credit.Credited   += (tally, worth, report) =>
    Feed.Add($"{(report.Attacker ? report.Attacker.name : "someone")} +{worth}");
//                   ^ guarded, and not as a formality: `tally.Changed` subscribers run
//                     before this, and any one of them may have destroyed the attacker.
credit.Unclaimed  += miss => { if (miss == KillCreditMiss.NoAttacker) Feed.Add("Killed by the world."); };

// A counter on the HUD.
tally.Changed += (before, after) => BountyLabel.text = after.ToString();

What it deliberately does not do

It does not track assists. An assist is a question about damage history — who contributed, over what window, weighted how — and the report is a snapshot of one blow, not a log. Building assists means keeping your own list of contributors per victim, which is a real design decision with real costs (memory per living enemy, when to prune, whether a heal cancels a contribution). This recipe would be guessing at all three.

It does not persist. The tally resets with the scene. If a bounty total should survive a reload, it is a small flat number about something that may not be loaded — which is exactly what one store, many facts is for, and it costs one call rather than a participant per killer.

It ships no notion of a team. attacker == gameObject catches the victim killing itself and nothing else. Friendly fire, faction rules, and whether a turret's owner gets the credit are all team questions, and the framework deliberately has no team model to answer them with.

It does not decide what a kill is worth. bounty is a serialised int in whatever unit you count in. A curve, a difficulty multiplier, a first-kill bonus and a diminishing return on the same target are progression policy — they belong in the code that reads Credited, where your game already knows what it is doing.

It does not warn on a miss. AttackerKeepsNoTally fires for every arrow, hazard and rival monster in the game, because most things that deal damage do not collect bounty. Logging that would fill a console in a minute and teach the reader to ignore it.