Attributes — Overview¶
A general container for the numbers a character is made of, and the seams that turn a stored value into a stat.
The problem it solves¶
Every game that has characters ends up with the same three problems: where the numbers live, how bonuses stack, and which system gets to answer "what is my strength right now".
Numbers scattered across components mean every system invents its own storage and none of them can see the others'. Stacking written into a framework means the framework decided whether percent bonuses add or multiply — for every game, forever. And a stat system that other systems must consult couples everything to it: delete it and the project stops compiling.
Attributes answers all three, and the third answer shapes the other two. Values live in one container per owner. Stacking is a seam your project fills, because the framework shipping a formula would be the framework designing your game. And nothing consults Attributes — Attributes answers seams other systems already publish, through adapters that are optional, deletable and visible.
The shape¶
AttributeSet MonoBehaviour — the owner's container: authored rows, runtime ids, bounds
↓ reads
IAttributeSource the read seam — found on the owner chain, like every capability here
↓ contributions
IAttributeModifierProvider components that contribute — items, statuses, systems of your own
↓ combined by
IAttributeCombiner yours — the framework ships no stacking model
↓ adapted to
Integrations/Attributes where attributes become stats other systems consume
The shortest version¶
using RevGaming.RevFramework.Attributes.Abstractions;
using RevGaming.RevFramework.Attributes.UnityIntegration;
// Write — creates the attribute if the id is new, clamps into authored bounds.
attributeSet.SetBaseValue("strength", 12f);
// Read the effective value through the seam, from anywhere on the owner's chain.
var source = owner.GetComponentInParent<IAttributeSource>();
if (source != null && source.TryGetValue("strength", out var strength))
UseIt(strength);
// React to base-value changes — the delta carries both values.
attributeSet.BaseValueChanged += delta =>
Debug.Log($"{delta.id}: {delta.before} -> {delta.after}");
The container never combines¶
An effective value is:
effective = Clamp(combiner == null ? base : combiner.Combine(owner, id, base, modifiers))
With no combiner wired, reads return clamped base values and modifier providers are not consulted at all — and the container says so once, in the console, if providers exist that it has no combiner for.
No combiner ships, and that is load-bearing
Whether percent modifiers add or multiply is a design decision about your game. The seams this framework already ships refuse to canonicalise it — IDamageAffinity documents its combination semantics as consumer-defined — and a house formula here would contradict them. There is nowhere inside Runtime/Systems/Attributes to put a stacking rule; the combiner is the only place one can exist, and it is yours.
Sign convention¶
Values may be negative, and bounds may be negative. A debt, a temperature, a signed standing your own layer keeps — all representable. Nothing is silently normalised:
- A non-finite value (NaN, infinity) is refused loudly; the stored value is unchanged.
- An inverted bound pair (min above max) is reported and ignored — the attribute behaves as unbounded until the authoring is fixed. It is never swapped or clamped into something you did not author.
What you need installed¶
Attributes ships in the Complete package only, and is built as though it shipped everywhere: the core references no other system, and each adapter compiles only when both of its systems are present.
| You have | You get |
|---|---|
| Crafting | AttributeLevelSource — recipes gate by an attribute through ICraftingLevelSource |
Deleting any system — including Attributes itself — leaves everything else compiling. An absent adapter is not an error; it is the supported configuration for a project that does not need the bridge.
Change events carry both values¶
BaseValueChanged reports owner, id, and the value before and after, and fires only on an actual change. The old value is there because a threshold consumer — a rank, a gate, a HUD crossing — cannot be written without it, and adding a parameter to a shipped event later is a breaking change.
Effective values deliberately have no event: they are computed reads, and the container cannot know when a provider's answer changes. Re-read TryGetValue at the moments your game cares about, the way Health re-consults its rules per hit.
Where to go next¶
- Mental Model — the three ideas the rest follows from
- Public API — the supported surface
- Integration Surfaces — providers, combiners and adapters
- System Boundaries — what it deliberately does not do
- FAQ — "my modifier isn't doing anything" and its friends