# 2. Modifiers and Timed Buffs
Part of [[Stat-Driven RPG Systems]]. Requires Unity 6000.5+. Previous: [[1.Stat Definitions and Base vs Derived Stats]]. Next: [[3.Leveling and XP Curves]].
## 2.1 Three modifier types, and why ordering matters
| Type | Example | Combines as |
|---|---|---|
| **Flat** | +10 Strength from a ring | Added to the base |
| **PercentAdd** | +10% and +15% damage from two talents | **Summed** (+25%), applied once |
| **PercentMult** | ×1.2 from a rage buff, ×1.1 from a potion | **Multiplied** (×1.32) |
Formula: `final = (base + Σflat) × (1 + ΣpercentAdd) × Π(1 + percentMult)`
Why separate PercentAdd from PercentMult? Additive percentages grow linearly, so ten +10% bonuses give +100%. Multiplicative ones compound, so ten ×1.1 bonuses give ×2.59. Most games make common bonuses additive and keep multiplicative ones for rare, powerful effects, which keeps balance predictable.
Each modifier also has an `Order` for exceptions, such as a flat bonus that applies *after* percentages ("+5 damage, not scaled"). The math sorts by `Order` and uses each type's default order unless you override it.
```csharp
// StatModifier.cs
using System;
public enum ModType { Flat = 100, PercentAdd = 200, PercentMult = 300 }
[Serializable]
public class StatModifier
{
public float Value;
public ModType Type;
public int Order;
[NonSerialized] public object Source; // item, buff, talent… used for removal
public StatModifier(float value, ModType type, object source = null, int? order = null)
{
Value = value; Type = type; Source = source;
Order = order ?? (int)type;
}
}
```
```csharp
// StatModifierMath.cs
using System.Collections.Generic;
public static class StatModifierMath
{
static readonly List<StatModifier> Sorted = new();
public static float Apply(float baseValue, IReadOnlyList<StatModifier> mods)
{
Sorted.Clear();
Sorted.AddRange(mods);
Sorted.Sort((a, b) => a.Order.CompareTo(b.Order)); // stable enough for game use; use OrderBy for strict stability
float value = baseValue;
float percentAddSum = 0f;
for (int i = 0; i < Sorted.Count; i++)
{
var m = Sorted[i];
switch (m.Type)
{
case ModType.Flat:
value += m.Value;
break;
case ModType.PercentAdd:
percentAddSum += m.Value;
// apply the summed group when the next mod isn't PercentAdd at the same order
if (i + 1 >= Sorted.Count || Sorted[i + 1].Type != ModType.PercentAdd)
{
value *= 1f + percentAddSum;
percentAddSum = 0f;
}
break;
case ModType.PercentMult:
value *= 1f + m.Value;
break;
}
}
// Round away float noise (e.g. 12.0000001) so UI and comparisons stay clean.
return (float)System.Math.Round(value, 4);
}
}
```
Worked example: base STR 20, ring +10 flat, talent +10% add, talent +15% add, rage +20% mult.
`(20 + 10) × 1.25 × 1.2 = 45`.
## 2.2 Sources and removal
Every modifier records its `Source`. When you unequip a sword, call `stat.RemoveAllFromSource(swordInstance)`, so you never have to track the individual modifier objects. Use the **item instance** as the source, not the `ItemDefinition`. Otherwise two copies of the same ring would remove each other's bonuses.
## 2.3 Timed buffs
```csharp
// BuffDefinition.cs
using System;
using System.Collections.Generic;
using UnityEngine;
[CreateAssetMenu(menuName = "RPG/Buff")]
public class BuffDefinition : ScriptableObject
{
[Serializable] public struct Entry { public StatDefinition stat; public float value; public ModType type; }
public string displayName = "Rage";
public Sprite icon;
public float duration = 10f;
public enum StackMode { RefreshDuration, StackIntensity, Ignore }
public StackMode stacking = StackMode.RefreshDuration;
[Min(1)] public int maxStacks = 1;
public List<Entry> modifiers = new();
}
```
```csharp
// BuffController.cs
using System;
using System.Collections.Generic;
using UnityEngine;
[RequireComponent(typeof(CharacterStats))]
public class BuffController : MonoBehaviour
{
public class ActiveBuff
{
public BuffDefinition Def;
public float Remaining;
public int Stacks = 1;
}
readonly List<ActiveBuff> _active = new();
CharacterStats _stats;
public event Action<ActiveBuff> BuffAdded, BuffRemoved;
public IReadOnlyList<ActiveBuff> Active => _active;
void Awake() => _stats = GetComponent<CharacterStats>();
public void Apply(BuffDefinition def)
{
var existing = _active.Find(b => b.Def == def);
if (existing != null)
{
switch (def.stacking)
{
case BuffDefinition.StackMode.Ignore: return;
case BuffDefinition.StackMode.RefreshDuration: existing.Remaining = def.duration; return;
case BuffDefinition.StackMode.StackIntensity:
existing.Remaining = def.duration;
if (existing.Stacks >= def.maxStacks) return;
existing.Stacks++;
AddMods(existing); // each stack adds another copy, all with the same source
return;
}
}
var buff = new ActiveBuff { Def = def, Remaining = def.duration };
_active.Add(buff);
AddMods(buff);
BuffAdded?.Invoke(buff);
}
void AddMods(ActiveBuff buff)
{
foreach (var e in buff.Def.modifiers)
_stats.Get(e.stat)?.AddModifier(new StatModifier(e.value, e.type, buff));
}
public void Remove(ActiveBuff buff)
{
foreach (var s in _stats.All) s.RemoveAllFromSource(buff);
_active.Remove(buff);
BuffRemoved?.Invoke(buff);
}
void Update()
{
// Use scaled time so buffs pause with the game; iterate backwards because Remove mutates the list.
for (int i = _active.Count - 1; i >= 0; i--)
{
_active[i].Remaining -= Time.deltaTime;
if (_active[i].Remaining <= 0f) Remove(_active[i]);
}
}
}
```
> [!tip] Permanent vs timed
> Equipment and talents are permanent modifiers whose source is the item or talent. Buffs are timed modifiers whose source is the `ActiveBuff`. All of them go through the same `Stat` pipeline, so UI tooltips can list every modifier and its source in one place.