Skip to content

Attributes — Mental Model

Three ideas. Everything else in the system is a consequence of one of them.

1. Attributes supply; stats are consumed somewhere else

The framework already had a stat system before this one shipped — five modifier interfaces and a level seam, spread across the systems that consult them. Nobody calls it that, because each seam is owned by its consumer: Health asks IAttackerDamageModifier per hit, Crafting asks ICraftingLevelSource per preflight. Those seams are deliberately contextual — a damage modifier sees the whole DamageContext — and a generic value lookup cannot replace them without throwing that expressiveness away.

So Attributes does not try. It is the supply side: a place for the numbers to live, with identity, bounds and change reporting. The adapters under Integrations/Attributes are where a stored value becomes a stat — one component per seam, each stating its own mapping decisions, each optional. The shipped example: AttributeLevelSource answers Crafting's level seam from an attribute you name.

Attributes owns one thing

Hold values, keyed by owner and id, and report when they change.

Everything else — what the values mean, how they stack, which seams they feed — composes around that, on your side of a seam.

2. The combine step is always yours

An effective value is the base plus contributions from IAttributeModifierProvider components on the owner's chain — folded together by an IAttributeCombiner your project supplies. The framework ships providers' plumbing (discovery, collection, fault isolation) and no formula.

This is the mechanism-not-policy rule that runs through the whole framework. Dependency discovery, change events, dirty state — plumbing every project rewrites badly, so the framework ships it. Whether a +10% and a +5 stack additively or multiplicatively — a design decision about your game, so the framework refuses to know. The test is simple: if the framework ever had to decide whether percent modifiers add or multiply, it shipped policy. There is no code path in Runtime/Systems/Attributes that combines two modifier values.

// The whole combine step, written where it belongs — in your project.
public sealed class AdditiveCombiner : MonoBehaviour, IAttributeCombiner
{
    public float Combine(GameObject owner, string attributeId, float baseValue,
                         IReadOnlyList<AttributeModifier> modifiers)
    {
        float total = baseValue;
        for (int i = 0; i < modifiers.Count; i++)
            total += modifiers[i].value;
        return total;
    }
}

With no combiner, providers are not consulted at all

Collecting contributions with nowhere defined to put them would imply a default stacking rule exists. Instead, reads return clamped base values, and the container explains itself once in the console if providers exist that it has no combiner for. The silence is a message, not a bug — and the FAQ's first entry.

3. A read is a question, not a subscription

Base values are owned state: they change when something writes them, so they have an event — BaseValueChanged, carrying the id and both the old and new value, because a threshold consumer cannot be written without the previous one.

Effective values are answers computed at the moment you ask: base, plus whatever the providers on the chain say right now, through your combiner, clamped. The container cannot know when a provider's answer changes — providers are pull seams, the same shape as every extensibility point in this framework, and none of those fire events either. So there is deliberately no "effective value changed" event, and anything that displays an effective value re-reads it at the moments the game cares about.

The same idea decides what is persisted: base values only. Contributions are re-derived on load from the systems that own them — an equipped item is Inventory's state, and when Inventory restores it, the provider finds it and the effective value is right without Attributes ever writing it down. A save file where the same fact is stored by two participants is a save file where one of them silently wins.

Keyed by owner, and by nothing else

An attribute belongs to an owner and an id — never to an (owner, context) pair. A container that could key by context would have absorbed relationship-shaped domains (standing with a faction, reputation in a town) as a side effect of a generalisation, and those deserve a deliberate design, not an accident. If you need per-context values, that is a system of your own holding several owners or several ids — visibly, as a decision you made.