Every RPG-style project hits the same wall. A character has an Attack stat of 10. A sword adds 5. A potion multiplies the result by 1.5 for thirty seconds. A curse subtracts 3 until the player visits a shrine. Somewhere in the codebase, one method has to turn that pile into a single number.

The first version usually mutates the stat in place. Equipping the sword does attack += 5, drinking the potion does attack *= 1.5f, and when the potion expires, a timer tries to undo the multiplication. This works until two buffs overlap, the undo runs in the wrong order, and the player ends the fight with an Attack of 11.2 that nobody can explain.

The broker chain pattern avoids the whole category of bug. Nothing mutates the stat. Modifiers sit in a list, and the final value is computed from the base value each time someone asks for it. This post builds on the mediator pattern post, because the broker chain is a mediator with a very specific job, and on the decorator post, which has a section explaining why stat modifiers deserve a different structure than wrapper stacks.

Broker chain query flow

What the pattern is made of

The name is a mouthful, but the parts are small.

A query is a short-lived object (or struct) that says “I want the value of this stat, starting from this base value.” It carries the stat type and a running value.

A broker (the mediator) owns the list of active modifiers. When a query arrives, it passes the query along the chain of modifiers, and each one gets a chance to change the value.

A modifier is a small object that knows which stat it affects and how to change the running value. It also knows how long it lives, or who gave it to the character.

The distinguishing move is that the stat itself stores only its base value. The Attack the rest of the game sees is not a field. It is the answer to a query. That is a direct application of the chain of responsibility pattern, with one twist: in the classic version, a handler consumes the request and the chain stops. In the broker chain, every handler sees the query and contributes to it. Refactoring.Guru’s description of chain of responsibility notes that a handler may either process a request or pass it on; a broker chain does both.

The naive version and why it breaks

Here is the mutation approach, the one that most projects start with:

public class Character
{
    public int Attack = 10;

    public void EquipSword()    => Attack += 5;
    public void UnequipSword()  => Attack -= 5;
    public void DrinkRagePotion() => Attack = (int)(Attack * 1.5f);
    public void RagePotionExpired() => Attack = (int)(Attack / 1.5f);
}

Three things go wrong, and they all come from the same decision.

Undo is lossy. Integer truncation means (int)(15 * 1.5f) = 22, and (int)(22 / 1.5f) = 14. The player ended up with 14 after the potion wore off instead of 15. Floats avoid that particular rounding error but pick up drift of their own after many apply-and-undo cycles.

Order depends on history. If the sword is equipped before the potion, the potion multiplies the sword’s bonus. If the potion comes first, it does not. Players get different results from the same inventory depending on what they did first, and the designer who wrote “potion multiplies total attack” has no way to enforce it.

Removal needs bookkeeping. Each effect has to remember exactly what it did so it can reverse it. Add stacking, expiry, and equipment swaps, and the bookkeeping becomes the bulk of the code.

Computing the value from scratch each time removes all three problems. There is nothing to undo, because nothing was changed.

The smallest version that works

Start with the data: a stat enum and a query.

public enum StatType { Attack, Defense, MoveSpeed }

public struct StatQuery
{
    public readonly StatType Stat;
    public float Value;

    public StatQuery(StatType stat, float baseValue)
    {
        Stat = stat;
        Value = baseValue;
    }
}

The query is a struct so that asking for a stat does not allocate. Modifiers receive it by ref, which is what allows them to change Value.

The modifier contract is small:

public enum ModifierPhase
{
    Flat = 100,       // +5 attack
    Multiplier = 200, // x1.5 attack
    Clamp = 300       // cap at 20
}

public interface IStatModifier
{
    StatType Stat { get; }
    ModifierPhase Phase { get; }
    object Source { get; }       // the sword, the potion, the shrine curse
    bool IsExpired { get; }
    void Apply(ref StatQuery query);
    void Tick(float deltaTime);
}

Phase is the part that most simple implementations skip, and it is what keeps results deterministic. More on that in a moment.

The broker itself is a list plus a loop:

public sealed class StatBroker
{
    readonly List<IStatModifier> modifiers = new();

    public event Action<StatType> Changed;

    public void Add(IStatModifier modifier)
    {
        // Keep the list sorted by phase; equal phases keep insertion order.
        int index = modifiers.FindLastIndex(m => m.Phase <= modifier.Phase) + 1;
        modifiers.Insert(index, modifier);
        Changed?.Invoke(modifier.Stat);
    }

    public void RemoveAllFrom(object source)
    {
        for (int i = modifiers.Count - 1; i >= 0; i--)
        {
            if (modifiers[i].Source != source) continue;
            var stat = modifiers[i].Stat;
            modifiers.RemoveAt(i);
            Changed?.Invoke(stat);
        }
    }

    public void Tick(float deltaTime)
    {
        for (int i = modifiers.Count - 1; i >= 0; i--)
        {
            var m = modifiers[i];
            m.Tick(deltaTime);
            if (!m.IsExpired) continue;
            modifiers.RemoveAt(i);
            Changed?.Invoke(m.Stat);
        }
    }

    public float Resolve(StatType stat, float baseValue)
    {
        var query = new StatQuery(stat, baseValue);
        for (int i = 0; i < modifiers.Count; i++)
        {
            if (modifiers[i].Stat == stat)
                modifiers[i].Apply(ref query);
        }
        return query.Value;
    }
}

Two details are worth a look. Add inserts by phase, so the list is always in application order and Resolve is a plain loop with no sorting. Tick and RemoveAllFrom iterate backwards, so removing an element does not skip the next one and no temporary list is needed.

Concrete modifiers can be tiny. One class covers the common cases:

public sealed class SimpleModifier : IStatModifier
{
    readonly Func<float, float> operation;
    float remaining;
    readonly bool timed;

    public StatType Stat { get; }
    public ModifierPhase Phase { get; }
    public object Source { get; }
    public bool IsExpired => timed && remaining <= 0f;

    public SimpleModifier(StatType stat, ModifierPhase phase, object source,
                          Func<float, float> operation, float duration = 0f)
    {
        Stat = stat; Phase = phase; Source = source;
        this.operation = operation;
        timed = duration > 0f;
        remaining = duration;
    }

    public void Apply(ref StatQuery q) => q.Value = operation(q.Value);
    public void Tick(float dt) { if (timed) remaining -= dt; }
}

The character’s stat block ties the base values to the broker:

public sealed class StatBlock
{
    readonly float[] baseValues;
    readonly float[] cache;
    readonly bool[] dirty;
    public StatBroker Broker { get; } = new();

    public StatBlock(float attack, float defense, float moveSpeed)
    {
        baseValues = new[] { attack, defense, moveSpeed };
        cache = new float[baseValues.Length];
        dirty = new bool[baseValues.Length];
        Array.Fill(dirty, true);
        Broker.Changed += stat => dirty[(int)stat] = true;
    }

    public float Get(StatType stat)
    {
        int i = (int)stat;
        if (dirty[i])
        {
            cache[i] = Broker.Resolve(stat, baseValues[i]);
            dirty[i] = false;
        }
        return cache[i];
    }
}

The cache is optional for small games and worth having once stats are read from Update loops, UI bindings, or damage formulas that run for dozens of entities. Because the broker raises Changed on every add, remove, and expiry, the cache never serves a stale number.

Why phases matter

Using the code above, put a character at Attack 10 and give them three effects:

  • a sword: +5 (Flat)
  • a ring: +3 (Flat)
  • a rage potion: ×1.5 (Multiplier)

Resolve order is Flat first, then Multiplier: (10 + 5 + 3) × 1.5 = 27. It does not matter whether the player drank the potion before or after equipping the gear. The phase decides the order, not the history.

Now consider what happens if the list is applied in insertion order. Drink the potion first, then equip the sword and the ring: 10 × 1.5 + 5 + 3 = 23. Equip first, then drink: 27. Two players with identical inventories see different damage. That is the bug the phase field exists to prevent.

Add a Clamp-phase modifier capping Attack at 20 (a design rule such as “no weapon bonus pushes the stat past 20 this season”) and the final value is 20 in both cases. Clamps belong in the last phase because they only make sense after everything else has contributed.

The three phases here are a starting point. Real projects often split Multiplier into “additive percent” and “multiplicative percent,” because +50% and +50% stacking to +100% is a different design decision from ×1.5 and ×1.5 stacking to ×2.25. Whichever convention a game chooses, the broker makes it a rule in one place instead of an accident spread across effect scripts.

Using it from gameplay code

Equipment is the cleanest use of Source:

public void Equip(Sword sword)
{
    stats.Broker.Add(new SimpleModifier(
        StatType.Attack, ModifierPhase.Flat, sword, v => v + sword.AttackBonus));
}

public void Unequip(Sword sword) => stats.Broker.RemoveAllFrom(sword);

RemoveAllFrom(sword) removes everything the sword granted, whether that was one modifier or five. Nothing else has to remember what the sword did. A sword with a “+5 attack, -2 move speed” tradeoff registers two modifiers on equip and loses both on unequip.

A timed buff uses the duration argument:

stats.Broker.Add(new SimpleModifier(
    StatType.Attack, ModifierPhase.Multiplier, ragePotion, v => v * 1.5f, duration: 30f));

The potion expires on its own because Tick runs from one place, typically the owning character’s Update:

void Update() => stats.Broker.Tick(Time.deltaTime);

Damage code asks for the stat and does not care how it was built:

float damage = attacker.Stats.Get(StatType.Attack) - defender.Stats.Get(StatType.Defense);

UI can subscribe to Broker.Changed and redraw the stat panel when something relevant moves, which keeps the UI free of polling.

Delegates versus a list

The most common version of this pattern in tutorials uses a C# event instead of a list. The broker exposes an event, each modifier subscribes to it, and the query is raised through the event so every subscriber updates it in turn. Adam Myhre’s open-source Unity stats-and-modifiers project shows both shapes in one repository. Its StatModifier class has a Handle(object sender, Query query) method with the signature an event subscriber would use, while its StatsMediator keeps a plain list of modifiers and applies them through a separate ordering object. The list is the part that does the work.

The event version is shorter. It is also harder to control. Multicast delegates invoke subscribers in the order they were added, so the result depends on history again. Unsubscribing requires keeping the exact delegate instance. And a modifier that forgets to unsubscribe stays alive through the event’s invocation list, which is a classic source of memory leaks and ghost buffs.

The list version costs a few more lines and buys three things: explicit ordering, direct inspection (a debug panel can list every modifier on a character), and removal by source. For gameplay stats, those matter more than the saved lines. For a one-off effect that only needs to change a value in one place, an event is fine.

The example repository also shows an allocation trap worth avoiding. Its mediator builds a filtered list with LINQ when the cache misses, and the ordering class calls ToList() on each application. Its Update method also builds a filtered list of expired modifiers every frame. On a single player character this is harmless, but on a hundred enemies each ticking their own mediator, the garbage adds up. Reverse-index loops, as in the broker above, produce no garbage at all. The benchmarking post covers how to measure this before deciding it matters.

Common mistakes

Storing the resolved value as a second source of truth

A common slip is to read Get(Attack) once and save the result in a field, such as int cachedAttack, then use that field in combat code. The field does not know when a modifier expires. The pattern only works if the resolved value is read through the stat block each time (or through the dirty-flag cache, which handles invalidation).

Letting modifiers read other stats

A “gain attack equal to 20% of defense” modifier calls Get(Defense) from inside Apply. That works until a Defense modifier reads Attack, and the two recurse until the stack overflows. Cross-stat dependencies need an explicit order: resolve primary stats first, then derived stats from the resolved primaries. If the game needs many of these, model derived stats as their own resolve step instead of letting modifiers query the broker.

Mutating base values from outside

Leveling up changes the base value, which is legitimate, but it must also mark that stat dirty. If the base stat is a public field, the cache serves the old resolved value until some modifier happens to change. Expose a method such as SetBase(StatType, float) that marks the entry dirty, and keep the array private.

Using one global broker

The broker belongs to one entity. A single static broker shared by all characters turns every modifier into a global effect, and Source-based removal becomes a hazard. Give each character its own StatBlock and StatBroker. If an effect has to touch several entities (an aura, for instance), the aura owns the logic that adds and removes one modifier per affected character.

Forgetting that expiry is a state change

An expired modifier changes the character’s stats, and anything displaying or caching those stats needs to hear about it. The Changed event in the broker covers this. A broker without a change notification forces every consumer to poll, and polling is where the stale-value bugs come from.

A test that catches the real risks

The behavior worth locking down is order independence and clean removal. A plain test with no framework is enough:

public static void StatBrokerSelfCheck()
{
    var swordSource = new object();
    var ringSource = new object();
    var potionSource = new object();

    // Order A: potion first.
    var a = new StatBlock(10f, 0f, 0f);
    a.Broker.Add(new SimpleModifier(StatType.Attack, ModifierPhase.Multiplier, potionSource, v => v * 1.5f));
    a.Broker.Add(new SimpleModifier(StatType.Attack, ModifierPhase.Flat, swordSource, v => v + 5f));
    a.Broker.Add(new SimpleModifier(StatType.Attack, ModifierPhase.Flat, ringSource, v => v + 3f));

    // Order B: gear first.
    var b = new StatBlock(10f, 0f, 0f);
    b.Broker.Add(new SimpleModifier(StatType.Attack, ModifierPhase.Flat, swordSource, v => v + 5f));
    b.Broker.Add(new SimpleModifier(StatType.Attack, ModifierPhase.Flat, ringSource, v => v + 3f));
    b.Broker.Add(new SimpleModifier(StatType.Attack, ModifierPhase.Multiplier, potionSource, v => v * 1.5f));

    Debug.Assert(Mathf.Approximately(a.Get(StatType.Attack), 27f));
    Debug.Assert(Mathf.Approximately(b.Get(StatType.Attack), 27f));

    // Removal restores the base without any undo logic.
    a.Broker.RemoveAllFrom(potionSource);
    Debug.Assert(Mathf.Approximately(a.Get(StatType.Attack), 18f));
    a.Broker.RemoveAllFrom(swordSource);
    a.Broker.RemoveAllFrom(ringSource);
    Debug.Assert(Mathf.Approximately(a.Get(StatType.Attack), 10f));

    // Timed modifiers expire and invalidate the cache.
    var c = new StatBlock(10f, 0f, 0f);
    c.Broker.Add(new SimpleModifier(StatType.Attack, ModifierPhase.Flat, potionSource, v => v + 5f, duration: 1f));
    Debug.Assert(Mathf.Approximately(c.Get(StatType.Attack), 15f));
    c.Broker.Tick(1.1f);
    Debug.Assert(Mathf.Approximately(c.Get(StatType.Attack), 10f));
}

The first two asserts fail if the phase sort breaks. The removal asserts fail if source tracking or cache invalidation breaks. The last block fails if expiry does not notify the cache. Move it into a Unity Test Framework edit-mode test when the project already runs those in CI; the logic is plain C# and needs no scene.

When a broker chain is the wrong size

The pattern earns its place when several independent systems (gear, buffs, debuffs, passives, difficulty settings) can all touch the same stat, and when effects come and go during play. Roguelikes, ARPGs, and tactics games fit that description.

A game where Attack is set once per level and never modified does not need it. A plain field is clearer. If the stat is touched by only one system, say a speed boost from a single power-up, a simple timer on that power-up is enough.

If effects are more than numeric changes (granting abilities, altering behavior, triggering on hit), the broker chain covers only the numeric part. Behavior effects usually belong to a different mechanism, such as the decorator stack from the earlier post or an event-driven ability system. A reasonable split is to let the broker chain answer “what are my numbers right now?” and let another system answer “what happens when I get hit?”

Keep one more limit in view. Every Get on a dirty stat costs a pass over all of an entity’s modifiers. With a handful of modifiers per character this is far below the cost of the rest of the frame. With hundreds of modifiers per entity and thousands of entities, per-stat modifier lists (one list per StatType) beat the single list shown here, and the broker can hold an array of lists indexed by the enum. That upgrade does not change the public API at all, which is a good sign that the boundary was drawn in the right place.

Practical takeaway

Treat a stat as a question, not a field. Store the base value, keep modifiers in a list sorted by phase, and compute the final value by passing a query through that list. Let every modifier carry a source so a whole item or effect can be removed in one call, and raise a change event so caches and UI stay honest.

Pick the phases (flat, multiplier, clamp) before the first modifier ships, because changing stacking rules later means rebalancing every item in the game. Test order independence and removal with a few asserts, and watch the allocation behavior of whatever LINQ or temporary lists you let into the hot path.

References