bevy_gauge
An attribute system for Bevy with expression-based modifiers, tagged filtering, cross-entity dependencies, and automatic propagation.
Built for games that need attribute systems beyond simple key-value stores — RPGs with derived attributes, ARPGs with PoE-style damage pipelines, or any game where attributes depend on other attributes (possibly on other entities) and need to stay in sync.
Quick start
use *;
use *;
Core concepts
Attributes component
Every entity that participates in the attribute system gets an Attributes component.
It stores the attribute nodes and their cached evaluated values:
commands.spawn;
Reading attributes
Reading only requires &Attributes — no special system param needed:
Writing attributes — AttributesMut
All writes go through the AttributesMut system parameter. This ensures
dependency edges are maintained and changes propagate automatically:
AttributesMut is a SystemParam that bundles mutable access to the ECS query,
the interner, the dependency graph, and the tag resolver.
Defining attributes
Flat attributes
The simplest form — a attribute with a numeric value. Modifiers are summed:
attributes.flat_attribute;
attributes.add_modifier; // now 75
Or at spawn time using the attributes! macro:
commands.spawn;
Expression modifiers
Modifiers can be dynamic expressions that reference other attributes. When a referenced attribute changes, dependents re-evaluate automatically:
// MaxHealth = Vitality * 10
attributes.add_expr_modifier?;
// Dodge rating scales with dexterity
attributes.add_expr_modifier?;
The expression language supports:
| Feature | Syntax |
|---|---|
| Arithmetic | +, -, *, /, unary - |
| Parentheses | (expr) |
| Attribute references | AttributeName, Attribute.Name |
| Cross-entity refs | AttributeName@Alias |
| Tag queries | Attribute{TAG|TAG} |
| Functions | max(a, b), min(a, b), abs(x), clamp(x, lo, hi) |
Expressions compile to a compact bytecode VM (stack-based, no heap allocation at eval time).
Reduce functions
Each attribute node has a reduce function that controls how its modifiers combine:
| ReduceFn | Behavior | Use case |
|---|---|---|
Sum (default) |
mod1 + mod2 + ... |
Flat / added values, % increases |
Product |
(1+mod1) * (1+mod2) * ... |
Multiplicative "more" / "less" |
Custom(fn) |
User-defined fn(&[f32]) -> f32 |
Anything else |
attributes.add_modifier_with_reduce; // 1.2x
attributes.add_modifier_with_reduce; // 1.2 * 1.3 = 1.56x
Complex attributes
A complex attribute is composed of named parts combined by an expression. Each part is a separate attribute node that receives modifiers independently:
// PoE-style: base * (1 + increased) * more
attributes.complex_attribute?;
// Add modifiers to individual parts
attributes.add_modifier;
attributes.add_modifier; // +50%
attributes.add_modifier; // 20% more → 1.2x
// Damage = 100 * 1.5 * 1.2 = 180
let total = attributes.evaluate;
Part names in the expression are short (base, increased). They are
automatically qualified to Damage.base, Damage.increased, etc.
Tags and filtered evaluation
Tags let you attach metadata to modifiers and then query attributes with a filter.
This powers systems like PoE-style damage where the same attribute (Damage.Added)
has modifiers for different damage types and delivery methods.
Defining tags
Tags are single bits in a u64 bitmask. Register names in the TagResolver
so expressions can use {TAG} syntax:
const PHYSICAL: TagMask = bit;
const FIRE: TagMask = bit;
const MELEE: TagMask = bit;
Tagged modifiers
Attach tags to modifiers. Untagged modifiers (TagMask::NONE) are global —
they participate in every query:
// 25 physical melee damage
attributes.add_modifier_tagged;
// 10 fire melee damage
attributes.add_modifier_tagged;
// +5 generic melee damage (applies to ALL melee queries)
attributes.add_modifier_tagged;
Tag matching rule
A modifier participates in a query when all of its tag bits are present in the query (the modifier's tags are a subset of the query):
Modifier [FIRE] + Query [FIRE|MELEE] → matches (FIRE ⊆ FIRE|MELEE)
Modifier [FIRE|RANGED] + Query [FIRE|MELEE] → no match (MELEE bit missing)
Modifier [NONE] + Query [anything] → always matches (global)
Tagged evaluation
// Only modifiers whose tags ⊆ PHYSICAL|MELEE
let phys = attributes.evaluate_tagged;
Tagged attributes (lazy materialization)
A tagged attribute combines parts with per-tag-combo expressions — and you
never have to enumerate combos up front. The system materializes expressions
lazily on first evaluate_tagged call:
attributes.tagged_attribute?;
// Add tagged modifiers to the parts
attributes.add_modifier_tagged;
attributes.add_modifier_tagged;
// Query any combo — expression auto-generates on first use
let phys = attributes.evaluate_tagged;
let fire = attributes.evaluate_tagged;
When evaluate_tagged(entity, "Damage", PHYSICAL | MELEE) is called for the
first time, the system:
- Decomposes
PHYSICAL | MELEEinto registered tag names - Qualifies the template:
"Damage.Added{PHYSICAL|MELEE} * (1 + Damage.Increased{PHYSICAL|MELEE})" - Compiles, registers dependencies, and caches the result
- Subsequent calls for the same combo are a no-op
Dependencies between attributes
When a modifier is an expression referencing another attribute, the dependency graph automatically tracks the relationship. Changes propagate recursively:
attributes.add_modifier;
attributes.add_expr_modifier?;
attributes.add_expr_modifier?;
// Changing Vitality propagates: Vitality → MaxHealth → HealthRegen
attributes.add_modifier; // all three update
The dependency graph is global (a DependencyGraph resource) and supports:
- Local dependencies: attribute A on the same entity depends on attribute B
- Cross-entity dependencies: attribute A on entity X depends on attribute B on entity Y
- Tag query dependencies: an expression reads a tag-filtered value
Cycles are detected at propagation time and short-circuited.
Cross-entity dependencies
Attributes on one entity can reference attributes on another through source aliases. This is how equipment, auras, buffs from other entities, etc. are modeled:
// The sword's damage scales with its wielder's Strength
attributes.add_expr_modifier_tagged?;
// Point the "Wielder" alias at the warrior entity
attributes.register_source;
// Sword's Damage.Increased now reads warrior's Strength.
// Changing warrior's Strength auto-propagates to the sword.
Swapping sources
Re-pointing an alias automatically rewires all dependency edges and re-evaluates affected attributes:
// Hand the sword to the mage — one call, everything updates
attributes.register_source;
Expression syntax
Cross-entity references use @Alias syntax: "Strength@Wielder",
"Intelligence@Parent", etc.
Batch operations — attributes! and mod_set!
attributes! — spawn-time initialization
Creates an AttributeInitializer component. When spawned alongside Attributes,
modifiers are automatically applied via an observer:
commands.spawn;
Values can be f32 literals (become flat modifiers) or string literals
(compiled as expression modifiers at apply time).
mod_set! — runtime buffs/debuffs
Creates a ModifierSet that can be applied to any entity:
let fire_enchant = mod_set! ;
fire_enchant.apply;
One-shot mutations — InstantModifierSet
InstantModifierSet applies attribute changes once without leaving persistent
modifiers on the attribute nodes. Used for ability effects, damage application,
and attributeus effect manipulation.
instant! macro
let effects = instant! ;
Operators: = (set), += (add), -= (subtract). Values can be f32
literals or expression strings.
Role-based evaluation
Expressions can reference attributes on role entities via @role syntax.
Roles are temporary source aliases registered for the duration of evaluation:
let roles: & = &;
apply_instant;
The evaluate_instant / apply_evaluated_instant functions are also available
for two-phase evaluation if you need the concrete values before applying.
Attribute requirements — AttributeRequirements
Boolean expressions over attributes that gate state-machine transitions, equipment prerequisites, ability conditions, etc.
// As a component:
commands.spawn;
// Multiple requirements (all must be satisfied):
commands.spawn;
Check requirements in a system:
Requirements are compiled lazily — source strings are stored at spawn time and
compiled to bytecode on the first met() call when the Interner is available.
Derived components
#[derive(AttributeComponent)] — the easy way
The AttributeComponent derive macro generates automatic AttributeDerived
and/or WriteBack implementations for your component:
#[read]/#[read("path")]reads from attributes (AttributeDerived)#[write]/#[write("path")]writes back to attributes (WriteBack)- No argument auto-generates the path from
StructName.field_name - Explicit string paths are also supported
- Fields without an annotation are plain struct fields
Components with #[derive(AttributeComponent)] are automatically registered via
the inventory crate — no manual app.register_*() calls needed.
AttributeDerived — manual implementation
A component whose fields are updated from attribute values whenever Attributes
changes. Implement the trait and register it:
Register with the inventory auto-registration macro (runs at link time, no
manual app setup needed):
register_derived!;
Or register manually in your plugin if you prefer:
app.;
The update system runs in PostUpdate (in the AttributeDerivedSet) and only
processes entities whose Attributes changed since the last tick.
WriteBack — write to attributes
A component whose fields are written back into the attribute system when
Attributes changes. Useful for input-driven attributes:
register_write_back!;
Write-back systems run in PostUpdate before AttributeDerived systems, so
written values are available for derived reads in the same frame.
API reference
AttributesMut methods
| Method | Description |
|---|---|
add_modifier(entity, attribute, value) |
Add an untagged flat or expr modifier |
add_modifier_tagged(entity, attribute, value, tag) |
Add a tagged modifier |
add_expr_modifier(entity, attribute, expr_str) |
Add an expression modifier |
add_expr_modifier_tagged(entity, attribute, expr_str, tag) |
Add a tagged expression modifier |
add_modifier_with_reduce(entity, attribute, value, reduce) |
Add modifier with custom reduce fn |
remove_modifier(entity, attribute, modifier) |
Remove a modifier by value |
set(entity, attribute, value) |
Shorthand for adding a flat modifier |
set_base(entity, attribute, value) |
Replace all untagged flat modifiers with a single value |
get_attributes(entity) |
Read-only access to an entity's Attributes |
flat_attribute(entity, name, value) |
Create a simple flat attribute |
complex_attribute(entity, name, parts, expr) |
Create a multi-part attribute with expression |
tagged_attribute(entity, name, parts, expr) |
Create a lazily-materialized tagged attribute |
evaluate(entity, attribute) |
Force re-evaluate and return value |
evaluate_tagged(entity, attribute, tag) |
Evaluate with tag filter |
register_source(entity, alias, source) |
Link a cross-entity source alias |
unregister_source(entity, alias) |
Remove a source alias |
Reading
| Method | On | Description |
|---|---|---|
get(id) |
&Attributes |
Read cached value by AttributeId |
get_by_name(name, interner) |
&Attributes |
Read by string name |
get_tagged(id, mask) |
&Attributes |
Read cached tag-filtered value |
get_tagged_by_name(name, mask, interner) |
&Attributes |
Read tag-filtered by name |
Macros
| Macro | Description |
|---|---|
attributes! { ... } |
Spawn-time attribute initialization (creates AttributeInitializer) |
mod_set! { ... } |
Create a ModifierSet for runtime application |
instant! { ... } |
Create an InstantModifierSet for one-shot mutations |
requires! { ... } |
Create a AttributeRequirements component |
attribute_component! { ... } |
Generate a component with AttributeDerived/WriteBack impls |
register_derived!(T) |
Auto-register a AttributeDerived component via inventory |
register_write_back!(T) |
Auto-register a WriteBack component via inventory |
Architecture notes
- String interning: attribute names are interned via
lassointoAttributeId(u32). Lookups are integer comparisons, not string hashes. - Bytecode VM: expressions compile to a stack-based bytecode with a 16-slot fixed stack. No heap allocation during evaluation.
- No unsafe: the crate contains no
unsafecode. - Dependency propagation: recursive DFS with cycle detection. Cross-entity source values are cached locally before evaluation.
- Tag queries: materialized as synthetic attribute nodes in the dependency graph. Once created, they propagate like any other attribute.
Examples
Run the PoE-style tagged damage example:
cargo run --example rpg_combat
This demonstrates tagged attributes, cross-entity references, source swapping, tag query specificity, and batch modifiers.
License
MIT OR Apache-2.0