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
Combineis 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,IStatusPotencyandIStatusResistanceeach take a component of about the size ofAttributeLevelSource. They do not share its placement or its arithmetic: all five are discovered withGetComponents, but not all on the same object — four read the target andIAttackerDamageModifierreads 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, oneClamp01s, 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 exceptIDamageAffinity. - Progression: nothing in Attributes raises a level or applies a curve. Systems that grant XP write base values through
SetBaseValue; Attributes holds the result.