Skip to content

Attributes — Integration Surfaces

The seams Attributes exposes, what each is for, and what belongs on your side of them.

The three seams

Interface Answers Shipped implementation
IAttributeSource What is this attribute's effective value? AttributeSet
IAttributeModifierProvider Who contributes to it? none — yours
IAttributeCombiner How do contributions fold together? none — yours, deliberately

The first is how everything reads: GetComponentInParent<IAttributeSource>() on the owner's chain, the discovery every optional capability in this framework uses. Consumers hold the interface, never the component, so neither side depends on the other existing.

IAttributeModifierProvider — the seam you are most likely to implement

Discovered on the owner's parent chain, so a provider on a character's root contributes to everything that character's container serves. Every contributor is the same shape — an equipped item, a status, a system of your own — and none is special-cased by name, which is what lets a later system contribute without Attributes learning it exists.

public sealed class BlessingOfStrength : MonoBehaviour, IAttributeModifierProvider
{
    public void CollectModifiers(GameObject owner, string attributeId,
                                 List<AttributeModifier> modifiers)
    {
        if (attributeId == "strength")
            modifiers.Add(new AttributeModifier(5f, "flat"));
    }
}

The rules a provider lives under:

  • Append, never clear — the buffer already holds earlier providers' contributions.
  • A disabled provider does not run; one on a merely inactive GameObject still does — the modifier semantics settled framework-wide. A destroyed one is dropped.
  • A throwing provider costs its own contributions and nothing else. The exception is logged and the read continues.
  • It runs inside reads, which can be frequent. Keep it cheap and deterministic.

The tag is opaque to the framework: it exists so your combiner can distinguish kinds of contribution — a flat-versus-percent taxonomy, say — without the container ever knowing the taxonomy exists.

IAttributeCombiner — always yours

Wire it on the AttributeSet: the inspector slot for a MonoBehaviour, or SetCombiner from code (code wins while set; clearing it falls back to the slot). The contract:

  • Deterministic, allocation-free, non-throwing, finite. A throwing combiner is logged and the read serves the base value; a non-finite result is reported once and treated the same way.
  • Bounds are applied by the container after combining — an implementation never needs to know them and cannot bypass them.
  • Reading another attribute of the same owner from inside Combine is supported — a derived stat doing what derived stats do. The container allocates fresh buffers for the nested read.

Do not wait for a shipped default

There will not be one. A default combiner is a published stacking model, and the seams this framework already ships — IDamageAffinity's consumer-defined combination, IStatusResistance's 0..1 scale — refuse exactly that canonicalisation. The combiner is a few lines of your project's C#, written once, owned by you.

The adapters — where attributes become stats

Adapters live under Integrations/Attributes, one assembly per system pair, compiled only when both systems are installed. They implement seams the consuming system already publishes, so the consumer is unchanged and unaware.

Shipped today:

Component Implements Mapping decisions it states
AttributeLevelSource ICraftingLevelSource which attribute is the level; FloorToInt; a serialized fallback when nothing resolves

The pattern to copy when you bridge a seam of your own: a small component reading through IAttributeSource, stating its conversion and its miss behaviour in the inspector rather than hiding either. The mapping — which attribute feeds which seam, scaled how — is a design decision of your game, which is why it lives on a component you configure and not in the framework.

Where that component has to sit is decided by the seam you are bridging, not by this one. IAttributeSource is found with GetComponentInParent, so the AttributeSet it reads may be the owner or any ancestor. The consuming seam is a separate question and the answer is not the same for all of them: ICraftingLevelSource, which AttributeLevelSource implements, is resolved with ctx.owner.GetComponentInParent, so that adapter genuinely may live anywhere on the chain. Health's and Status Effects' modifier seams are resolved with GetComponents on the target object itself, so an adapter for one of those must sit on the same GameObject as the component that consumes it. Check the seam before choosing the object: an adapter one level too low compiles, wires, inspects correctly and is never called.

What belongs on your side

  • The stat vocabulary: which ids exist and what they mean.
  • The stacking model, as a combiner.
  • Contributors, as providers on the chain.
  • The adapters for seams the framework has not bridged yet — IAttackerDamageModifier, IHealingModifier, IDamageAffinity, IStatusPotency and IStatusResistance each take a component of about the size of AttributeLevelSource. They do not share its placement or its arithmetic: all five are discovered with GetComponents, but not all on the same object — four read the target and IAttackerDamageModifier reads the attacker, which is the whole point of an attacker-side multiplier. Each consumer also treats an out-of-range answer differently — one cancels the hit, one clamps every contribution, one Clamp01s, one floors at zero. Read the consuming seam before writing the curve. An attribute is not a multiplier bridges all four of the Health and Status Effects ones and sets out where they disagree — every one except IDamageAffinity.
  • Progression: nothing in Attributes raises a level or applies a curve. Systems that grant XP write base values through SetBaseValue; Attributes holds the result.