Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Attributes

An attribute is a number an actor has: its life, its mana, its fire resistance. In mettle each attribute is a type of yours. Its rules (where it starts, what it’s kept within) are written on the type.

The mage has six:

use bevy_mettle::prelude::*;
#[derive(Attribute)]
#[attribute(base = 100.0, min = 0.0, max = MaxLife)]
struct Life;

#[derive(Attribute)]
#[attribute(base = 100.0)]
struct MaxLife;

#[derive(Attribute)]
#[attribute(base = 100.0, min = 0.0)]
struct Mana;

#[derive(Attribute)]
#[attribute(max = MaxResistance)]
struct Resistance;

#[derive(Attribute)]
#[attribute(base = 0.75)]
struct MaxResistance;

#[derive(Attribute)]
#[attribute(base = 10.0)]
struct SpellPower;

assert_eq!(Key::of::<Life>().as_str(), "life");
assert_eq!(Key::of::<MaxResistance>().as_str(), "max_resistance");

Settings

#[attribute(..)] takes these settings. Each is optional.

SettingMeaningUnless it says
base = 50.0What its base starts at0.0
min = 0.0, min = OtherThe least it can be: a number, or another attribute’s current valuenone
max = ..The most it can be, like minnone
rounding = Down, Up, NearestIt’s a whole number, rounded that waynone
on_limit = Clamp, RatioWhat its base does as a limit that’s another attribute movesClamp
name = ".."The name data uses for itthe type’s name in snake_case

A limit that names another attribute reads that attribute’s current value on the same actor. Life can’t go above the mage’s MaxLife, whatever raises MaxLife. The limits win over everything else: a modifier can’t push Life past them.

When such a limit moves, the base follows it, the same frame:

  • Clamp, as it is unless it says: the base is kept within its limits. A mage at 100 life whose MaxLife drops to 80 has 80 life, and still 80 as the maximum comes back. Taking a buff off and putting it on again doesn’t heal.
  • Ratio: the base keeps its fraction of its maximum, which has to be another attribute. Half life stays half as MaxLife rises or falls.

Only the world that decides attributes moves bases: a server, or a game on its own. A client sees what the server serves. A base set by the game, with Base::set, isn’t kept within its limits; its current value is.

Down rounds 2.9 to 2. Up rounds 2.1 to 3. Nearest rounds halves away from zero, so 2.5 is 3. Use rounding for counts, such as projectiles or charges.

Data names an attribute by its name: "life", "max_resistance". Two attribute types can’t share a name. Adding the second one panics.

Without the derive

The derive writes an Attribute impl, whose rules are constants, and a Keyed impl, of kind AnyAttribute, whose key is its name. Write them yourself if you’d rather:

use bevy_mettle::prelude::*;
use bevy_mettle::core::registry::AnyAttribute;

struct MaxLife;

impl Keyed for MaxLife {
    type Kind = AnyAttribute;
    const KEY: &'static str = "max_life";
}

impl Attribute for MaxLife {
    const BASE: f32 = 100.0;
}

struct Life;

impl Keyed for Life {
    type Kind = AnyAttribute;
    const KEY: &'static str = "life";
}

impl Attribute for Life {
    const BASE: f32 = 100.0;
    const MIN: Option<Limit> = Some(Limit::Value(0.0));
    const MAX: Option<Limit> = Some(Limit::attribute::<MaxLife>());
}

assert_eq!(Key::of::<Life>().as_str(), "life");

Adding them to the app

Add each attribute with add_attribute:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0, max = MaxLife)] struct Life;
#[derive(Attribute)] #[attribute(base = 100.0)] struct MaxLife;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Mana;
fn main() {
    App::new()
        .add_plugins((DefaultPlugins, MettlePlugins))
        .add_attribute::<Life>()
        .add_attribute::<MaxLife>()
        .add_attribute::<Mana>()
        .run();
}

Once it’s added, data can name it, and every actor with its base has its current value kept. Its components are registered for reflection, so inspectors and scenes see them.

Adding it gives it its number, an [AttributeId]: what modifiers, actors’ state and the network know it by. Add attributes in the same order on a server and its clients, as replication already asks of components, and the numbers agree. Names and numbers says why. Ask for a number with world.attribute_id::<Life>(), or from the AttributeRegistry resource, which also gives each one’s name and rules:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0, max = MaxLife)] struct Life;
#[derive(Attribute)] #[attribute(base = 100.0)] struct MaxLife;
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
    .add_attribute::<Life>()
    .add_attribute::<MaxLife>();
let attributes = app.world().resource::<AttributeRegistry>();
let life = attributes.id_of::<Life>().unwrap();
assert_eq!(app.world().attribute_id::<Life>(), Some(life));
assert_eq!(attributes.name(life), Some("life"));
assert_eq!(attributes.id("max_life"), attributes.id_of::<MaxLife>());

add_attribute adds AttributePlugin if it isn’t there yet. So add MettlePlugins (or AttributePlugin itself) before the first add_attribute. Adding the plugin twice panics, as with any Bevy plugin.

Attributes are folded in the Mettle schedule, run in FixedPostUpdate by default, so what your systems change in a tick’s FixedUpdate is folded before the tick ends. To run it somewhere else, set the core plugin: MettlePlugins.set(MettleCorePlugin::new(Update)). Installation has more.

Base and Current

An actor has an attribute when it has the attribute’s Base. The base is the value before any modifier: what’s left of the mage’s life, the mana it has, a level it gained. It’s yours to set, and effects set it too.

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0, max = MaxLife)] struct Life;
#[derive(Attribute)] #[attribute(base = 100.0)] struct MaxLife;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Mana;
#[derive(Component)] struct Mage;
fn spawn_mage(mut commands: Commands) {
    commands.spawn((
        Mage,
        Base::<Life>::default(),
        Base::<MaxLife>::default(),
        Base::<Mana>::new(60.0),
    ));
}
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins.set(MettleCorePlugin::new(Update))))
    .add_attribute::<Life>()
    .add_attribute::<MaxLife>()
    .add_attribute::<Mana>()
    .add_systems(Startup, spawn_mage);
app.update();
let mana = app.world_mut().query::<&Current<Mana>>().single(app.world()).unwrap().get();
assert_eq!(mana, 60.0);

Base::default() starts at the attribute’s base setting. Base::new starts it anywhere. Base::get, Base::set and Base::add read and change it.

A base brings the attribute’s Current: the value with every modifier that counts when nothing is in play, kept within its limits. It’s the number a health bar shows. Until it’s first worked out, it’s the base.

Current is mettle’s to write. It’s written only when the value changes, so Changed<Current<Life>> means the mage’s life changed:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0, max = MaxLife)] struct Life;
#[derive(Attribute)] #[attribute(base = 100.0)] struct MaxLife;
#[derive(Component)] struct HealthBar(f32);
fn health_bars(mut mages: Query<(&Current<Life>, &mut HealthBar), Changed<Current<Life>>>) {
    for (life, mut bar) in &mut mages {
        bar.0 = life.get();
    }
}
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins.set(MettleCorePlugin::new(Update))))
    .add_attribute::<Life>()
    .add_attribute::<MaxLife>()
    .add_systems(Update, health_bars.after(MettleSystems));
let mage = app.world_mut().spawn((Base::<Life>::default(), Base::<MaxLife>::default(), HealthBar(0.0))).id();
app.update();
app.world_mut().get_mut::<Base<Life>>(mage).unwrap().set(40.0);
app.update();
assert_eq!(app.world().get::<HealthBar>(mage).unwrap().0, 40.0);

Changing a base doesn’t change Current at once. It’s worked out the next time Mettle runs, and written in its last step. Put systems that read Current after MettleSystems.

Taking Base<Life> off the mage takes the attribute away, and its Current<Life> with it.

Told as it changes

As an actor’s current value changes, mettle triggers AttributeChanged<A> on it, with what it was (old) and what it is (new). It’s an entity event, so observe it on the actor or globally:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0, max = MaxLife)] struct Life;
#[derive(Attribute)] #[attribute(base = 100.0)] struct MaxLife;
#[derive(Resource, Default)] struct Hurt(Vec<(f32, f32)>);
fn hurt(changed: On<AttributeChanged<Life>>, mut hurt: ResMut<Hurt>) {
    if changed.new < changed.old {
        hurt.0.push((changed.old, changed.new));
    }
}
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins.set(MettleCorePlugin::new(Update))))
    .add_attribute::<Life>()
    .add_attribute::<MaxLife>()
    .init_resource::<Hurt>()
    .add_observer(hurt);
let mage = app.world_mut().spawn((Base::<Life>::default(), Base::<MaxLife>::default())).id();
app.update();
assert!(app.world().resource::<Hurt>().0.is_empty(), "the first value isn't a change");
app.world_mut().get_mut::<Base<Life>>(mage).unwrap().set(70.0);
app.update();
assert_eq!(app.world().resource::<Hurt>().0, [(100.0, 70.0)]);

The first value worked out isn’t a change, so it isn’t told. Neither is a value only some reads see, such as the mage’s resistance to a fire hit: Reading in context covers those.

On a client

In a multiplayer game, the server works out the values and a client shows them. On a client, an actor marked Remote shows the values the server serves as its Current, not its own fold of the modifiers sent to it. Prediction explains how that works, and how a client’s own predictions show until the server answers.

Pitfalls

  • Writing Current. Set the base, or add a modifier. Current is overwritten the next time attributes are kept.
  • Reading too early. A base set this tick shows in Current once Mettle has run. Order your systems after MettleSystems.
  • The same name twice. Two attribute types named alike (by the name setting or by their type names) panic when the second is added.