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

Introduction

bevy_mettle is a gameplay framework for Bevy, in the spirit of Unreal’s Gameplay Ability System. It gives your actors attributes, such as life and mana. Effects change them: a fire that burns, a potion that heals, a ring that adds resistance. Abilities are what an actor can do: cast a fireball, paid for in mana and cooled down.

This book builds one actor from start to finish: a fire mage. Here is the mage’s fireball, after the first few chapters:

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

/// What the fireball does while it's cast: a component of yours.
#[derive(Component, Default)]
struct CastFireball;

/// The fireball, as code names it.
#[derive(Ability)]
struct Fireball;

/// The fireball, as content defines it.
fn fireball() -> AbilityData {
    AbilityData::new(Key::of::<Fireball>(), "cast_fireball")
        .tag("ability.spell.fire")
        .cost("fireball.cost")
        .cooldown("fireball.cooldown")
        .require(!Condition::owner_has("state.stunned"))
}

fn effects() -> [EffectData; 2] {
    [
        EffectData::instant("fireball.cost")
            .modifier(EffectModifier::new::<Mana>(Operation::Flat, -20.0)),
        EffectData::lasting("fireball.cooldown", 2.0).owner_tag("cooldown.fireball"),
    ]
}

The mage casts it with commands.entity(mage).activate_ability(Fireball). Content names things by string, so a designer can add them; code names them by type, so a typo doesn’t compile. That pays 20 mana, starts a two-second cooldown, and puts CastFireball on the ability for your systems to run. While the mage is stunned, or the cooldown lasts, or there isn’t enough mana, it fails, and says why.

What you get

  • Attributes are your types. Life, Mana and Resistance are marker types with their rules derived: a base, limits, rounding. Each actor keeps a Base and a Current of each as components.
  • Modifiers. Flat, increased, more, overrides and the rest, from items, effects or the actor itself. They can count only in context, such as resistance against fire, and you can ask why a value is what it is.
  • Effects. Instant, timed, infinite or periodic. They stack, tag their owner, need conditions to apply or to count, block other effects, and chain into each other.
  • Abilities. Granted, activated, paid for, cooled down and ended. What one costs and what blocks it is data; what it does is a component and systems of yours.
  • Gameplay tags. A tree of labels, such as damage.elemental.fire, for what effects and abilities are and for what an actor is going through.
  • Data first. Tags, curves, effects and abilities are data, handed over as one resource and checked as a whole, reloaded as it changes. Mettle doesn’t load files: your game reads them however it likes.
  • Prediction. A client predicts its own abilities and the server decides, with an adapter for lightyear.

The crates

Most projects only depend on bevy_mettle. It re-exports the other crates behind feature flags.

CrateContains
bevy_mettleEvery part, with one prelude and one plugin group
bevy_mettle_coreGameplay tags, curves and conditions
bevy_mettle_attributeAttributes, modifiers and reads in context
bevy_mettle_effectEffects
bevy_mettle_abilityAbilities
bevy_mettle_lightyearPrediction over lightyear
bevy_mettle_macrosThe derives: Attribute, Tag, Effect, Ability, Behaviour and the rest

This book is the guide. The API reference is on docs.rs.

Installation and features

Add bevy_mettle to your Cargo.toml:

[dependencies]
bevy_mettle = "0.1"

It needs Bevy 0.20 and Rust 1.99 or later, edition 2024.

Attributes, effects and abilities are on by default. Each part needs the one before it: abilities pay with effects, and effects change attributes.

FeatureAdds
attributeAttributes and modifiers (default)
effectEffects; turns on attribute (default)
abilityAbilities; turns on effect (default)
lightyearPrediction over lightyear; turns on ability

Gameplay tags, curves and conditions are always there. Feature flags has the full list.

Adding the plugins

Add MettlePlugins. It adds the plugin of every part you turned on:

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

Where mettle runs

Mettle’s gameplay runs in its own schedule, Mettle, in four steps, its MettleSets, one after another:

  1. Effects: active effects ticked. Requirements weighed, periods come round, time run out.
  2. Attributes: bases synced, modifiers folded, values worked out.
  3. Settle: effects reading another actor’s attribute live follow it, and what that changed is folded again, until nothing moves.
  4. Publish: Current<A> written, AttributeChanged told.

MettleCorePlugin runs Mettle in FixedPostUpdate, the way Bevy’s Main runs its schedules. It’s the fixed step for prediction and rollback: a tick is the unit a client predicts and a server confirms, and a rollback runs the ticks again. What your systems do in a tick’s FixedUpdate is ticked, folded and published before that tick ends, so a replayed tick comes out the same.

Two things run outside it. Abilities handle network messages in PreUpdate (AbilitySystems), where transports hand them over. Cues play in PostUpdate.

Ordering your systems

Mettle is run by a system in MettleSystems, in FixedPostUpdate. Systems in FixedUpdate run before it anyway. To read what it published in the same tick, put a system after it:

use bevy::prelude::*;
use bevy::time::TimeUpdateStrategy;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Mana;
#[derive(Resource, Default)] struct Seen(Vec<f32>);
fn drain(mut mana: Query<&mut Base<Mana>>) {
    for mut mana in &mut mana {
        mana.add(-10.0);
    }
}

fn see(mana: Query<&Current<Mana>>, mut seen: ResMut<Seen>) {
    seen.0.extend(mana.iter().map(|mana| mana.get()));
}

let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
  // One fixed step each update.
  .insert_resource(TimeUpdateStrategy::FixedTimesteps(1))
  .init_resource::<Seen>()
    .add_attribute::<Mana>()
    .add_systems(FixedUpdate, drain)
    .add_systems(FixedPostUpdate, see.after(MettleSystems));
// The first update starts the clock, and runs no fixed step.
app.update();
app.world_mut().spawn(Base::<Mana>::default());
app.update();

// Drained in the tick's FixedUpdate, and seen folded in the same tick.
assert_eq!(app.world().resource::<Seen>().0, [90.0]);

Systems in Update run after the fixed steps of the frame, so they see what the last one published.

Running it elsewhere

To run mettle in another schedule, set MettleCorePlugin::new as with Bevy’s own plugin groups:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
fn main() {
    App::new()
        .add_plugins((DefaultPlugins, MettlePlugins.set(MettleCorePlugin::new(Update))))
        .run();
}

Every part runs where it says. MettleSystems moves with it: order your systems against it in that schedule.

Using one part alone

To use one part alone, depend on its crate, such as bevy_mettle_attribute, and add its plugin. Each part’s crate depends on the parts before it, not after.

Each part’s plugin adds MettleCorePlugin if it isn’t there yet. To run mettle elsewhere, add MettleCorePlugin::new(..) before them. If you add the plugins one by one, also add MettleContentPlugin. It loads MettleContent; Loading data shows how.

Pitfalls

  • add_attribute before the plugins. add_attribute adds AttributePlugin itself if it isn’t there yet. MettlePlugins added after it adds it again, and Bevy panics. Add MettlePlugins first.
  • Time. Effects run out by Bevy’s time. DefaultPlugins and MinimalPlugins both include TimePlugin.
  • A frame without a fixed step. Mettle runs in the fixed step, so a frame too short for one runs none of it. In a test, step time with TimeUpdateStrategy::FixedTimesteps, or run mettle in Update.
  • Lightyear. With the lightyear feature, MettlePlugins also adds MettleNetPlugin, which needs lightyear’s plugins. Add MettlePlugins after them. Over lightyear shows how.

Your first attributes

This chapter gives the mage life and mana, and shows them changing.

Declaring attributes

An attribute is a marker type with #[derive(Attribute)]. Its rules go on #[attribute(..)]:

use bevy_mettle::prelude::*;
/// What's left of the mage's life: never below 0, nor above its maximum.
#[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;

base is what a new actor starts with. min and max are limits: a number, or another attribute. Life is capped by the mage’s own maximum life. Attributes lists every setting.

Adding them

Add each attribute to the app with add_attribute, after MettlePlugins:

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 spawn_mage() {}
fn show_life() {}
fn main() {
    App::new()
        .add_plugins((DefaultPlugins, MettlePlugins))
        .add_attribute::<Life>()
        .add_attribute::<MaxLife>()
        .add_attribute::<Mana>()
        .add_systems(Startup, spawn_mage)
        .add_systems(Update, show_life)
        .run();
}

The mage

An actor has an attribute when it has its Base. Spawn the mage with one of each:

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>::default(),
    ));
}

Base::default() starts at the attribute’s base. Base::new(80.0) starts elsewhere.

Each Base<A> brings a Current<A>: the value with every modifier counted, kept within its limits. Your systems read it like any component:

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;
fn show_life(lives: Query<&Current<Life>, Changed<Current<Life>>>) {
    for life in &lives {
        info!("life: {}", life.get());
    }
}

Current is written only when its value changes, so Changed<Current<Life>> is life changing.

Changing a base

The base is yours to set: what’s left of life after a hit, a level gained. Change it through Base::set or Base::add. The next time attributes are kept, Current follows, within its limits:

use std::time::Duration;
use bevy::prelude::*;
use bevy::time::TimeUpdateStrategy;
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>()
    // Each update moves time on by one fixed step.
    .insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f64(1.0 / 64.0)));
app.finish();
app.cleanup();
let mage = app
    .world_mut()
    .spawn((Base::<Life>::default(), Base::<MaxLife>::default()))
    .id();
app.update();
app.update();

// A hit for 30.
app.world_mut().get_mut::<Base<Life>>(mage).unwrap().add(-30.0);
app.update();
assert_eq!(app.world().get::<Current<Life>>(mage).unwrap().get(), 70.0);

// Its maximum drops below what's left: life is capped.
app.world_mut().get_mut::<Base<MaxLife>>(mage).unwrap().set(50.0);
app.update();
assert_eq!(app.world().get::<Current<Life>>(mage).unwrap().get(), 50.0);

The base keeps 70: the limit applies to Current. If maximum life comes back up, so does life.

Being told

As an actor’s Current<A> changes, AttributeChanged<A> is triggered on it, with what it was and what it is. Observe it for a health bar, or for death:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Life;
fn died(changed: On<AttributeChanged<Life>>, mut commands: Commands) {
    if changed.new <= 0.0 {
        commands.entity(changed.entity).despawn();
    }
}

Add it with app.add_observer(died), or on the mage alone with commands.entity(mage).observe(died).

Next, How it fits together shows where effects and abilities come in.

How it fits together

The parts of bevy_mettle meet on the actor. This chapter shows what is data and what is code, who writes what, and in which order.

Data and code

Mettle splits a game’s gameplay in two:

  • Code is what your systems read: attributes are types of yours, Life and Mana, so a system asks for Current<Life>. What an ability does is a component of yours, run by your systems.
  • Data is content: gameplay tags, curves, effects and abilities. Each is named by a Key, such as "burning" or "fireball", so a designer or a mod can add one without code.

Code names things by type, never by string. A tag code uses is a #[derive(Tag)] type, an effect it applies a #[derive(Effect)] type, an ability it grants a #[derive(Ability)] type. A misspelled one doesn’t compile. Each type carries its content’s name, checked once, as content loads.

Content built in code can name by type too. A Key comes from a string, or from a type with Key::of::<Burning>(), keyed by the name its derive gives it. Builders take either:

use bevy_mettle::prelude::*;
#[derive(Effect)]
struct Burning;

let named: Key<AnyEffect> = "burning".into();
assert_eq!(Key::of::<Burning>(), named);
let burning = EffectData::lasting(Key::of::<Burning>(), 4.0);
assert_eq!(burning.name, named);

A type is of one kind, its key’s kind inferred from it: Key::of::<Burning>() is a Key<AnyEffect>, and Burning::KEY its name. An ability and the effect it costs are two types; deriving both on one doesn’t compile.

Code is added to the app: attributes with add_attribute, behaviours with add_behaviour, effect code with add_execution, add_calculation and add_route, and the tags, effects and abilities code names with add_tag, add_effect and add_ability. Data is handed over as one resource, MettleContent, and checked as a whole against the code and itself: an effect naming a tag that isn’t defined, an attribute that wasn’t added, a curve that doesn’t exist, or an effect code names that content doesn’t define is an error, all of them at once. Content that holds becomes a TagRegistry, a CurveRegistry, an EffectRegistry and an AbilityRegistry; content that doesn’t is rejected, and the content before stays. Mettle doesn’t load files. Your game reads them however it likes, and hands the content over: see Loading data.

Names and numbers

Everything mettle runs on is known by a number, as Bevy knows a component by its ComponentId. Names are for people: what data files are written in, what’s shown in debug output, and what a client keys icons and translations by.

  • Code is numbered as it’s added. add_attribute::<Life>() gives Life the next AttributeId; behaviours, routes, executions and calculations get theirs the same way. Code names each by type: reads and changes, such as value::<Life>(), Current<Life> and BaseChange::of::<Life>, find its number for you. world.attribute_id::<Life>() asks for it, as Bevy has world.component_id::<T>(), to match what an event names.
  • Content is numbered as it’s checked. Tags, curves, effects and abilities get a TagId, a CurveId, an EffectId and an AbilityId, in order of name. A reload keeps the numbers things had, and numbers new ones after them.
  • Data names code by name. An effect names the attribute "life", an ability the behaviour "cast_fireball". Checking the content resolves each name to its number, once: an effect or an ability as the app runs it, its EffectDef or AbilityDef, holds numbers, not names. Code names content by type, and the type is resolved to its number the same way.
  • The network carries numbers. A server and its clients run the same code and the same content. Adding the same attributes, behaviours and routes in the same order numbers them alike, and content numbered in order of name is numbered alike wherever it’s checked. Replication already asks the same of components.

Each kind of thing has a registry, a resource that keeps the names beside the numbers: AttributeRegistry, BehaviourRegistry, ExecutionRegistry, CalculationRegistry and RouteRegistry for code; TagRegistry, CurveRegistry, EffectRegistry and AbilityRegistry for content. The registries of code, effects and abilities read the same way: get(id) for what’s numbered id, id("name") for a name’s number, id_of::<T>() for a type’s, name(id) for a number’s name, and iter() for all of them. TagRegistry goes between a tag and its number with id and key, and CurveRegistry with id and name.

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

Who writes what

PartWritesReads
AttributesCurrent<A>, from bases and modifiersBase<A>, Modifiers of the actor’s sources, its OwnedTags
EffectsModifiers and granted tags while they last; bases as they executeTheir registry, attributes, owned tags
AbilitiesTheir behaviour’s component; costs and cooldowns as effectsTheir registry, attributes, owned tags
Your systemsBases, OwnedTags, effects applied, abilities activatedCurrent<A>, abilities’ components, what’s told

For the mage, casting the fireball applies fireball.cost and fireball.cooldown. The cost lowers Base<Mana>, the cooldown tags its owner cooldown.fireball, and attributes work out Current<Mana> from the new base.

One step

Mettle runs in its own schedule, Mettle, once each fixed step, in FixedPostUpdate. Its steps, the MettleSets, run in order:

  1. Effects: requirements weighed, periods come round, time run out.
  2. Attributes: changed bases synced in, changed modifiers folded, owned tags told, values worked out.
  3. Settle: effects whose live reads moved are worked out again, and attributes folded again, until nothing moves.
  4. Publish: changed values written to Current<A>, and told.

So what your systems do in a tick’s FixedUpdate is ticked, folded and published before the tick ends. A rollback replays the tick, and mettle with it. Abilities stand apart: in AbilitySystems, in PreUpdate, new definitions are given out and, over a network, requests served and replies resolved.

The system running Mettle is in MettleSystems. Order your systems against it:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
fn regenerate() {}
fn health_bars() {}
let mut app = App::new();
app.add_systems(FixedUpdate, regenerate)
    .add_systems(FixedPostUpdate, health_bars.after(MettleSystems));

Applying an effect or activating an ability doesn’t wait for a schedule. It happens as the command is applied, and what it changes is folded the next time Mettle runs. Installation shows how to run it elsewhere.

Owned tags

An actor’s OwnedTags are what it’s going through: state.burning, state.stunned, cooldown.fireball. Effects grant them while they last, abilities while they’re active, and your systems add their own, such as state.moving. Every part reads them: a modifier can count only while its owner owns a tag, an effect can need one to apply, and an ability is blocked by one. Gameplay tags covers them.

Pitfalls

  • Ordering across schedules. .after(MettleSystems) orders systems only within the schedule mettle runs in. A system in Update sees what the frame’s last fixed step published.
  • A change made after mettle ran. A base changed after MettleSystems reaches Current the next time it runs.

Gameplay tags

A gameplay tag is a label in a tree: damage.elemental.fire sits under damage.elemental, which sits under damage. Everything in mettle is labelled with tags. The mage’s fireball is tagged ability.spell.fire, the burn it leaves is effect.affliction.burning, and while it burns the mage owns state.burning.

What has a tag has every tag above it. A rule written for damage.elemental holds for a fire hit, and a cure for effect.affliction takes off burning and poison alike. This is how Unreal’s gameplay tags work too.

Tags in code

Code names a tag by its type. Derive Tag on a unit struct, under its parent:

use bevy_mettle::prelude::*;
#[derive(Tag)]
struct State;                    // "state"

#[derive(Tag)]
#[tag(parent = State)]
struct Burning;                  // "state.burning"

#[derive(Tag)]
#[tag(parent = State, name = "wet")]
struct Soaked;                   // "state.wet"

let mut owned = OwnedTags::default();
owned.add(Burning);
assert!(owned.contains(Burning));
assert!(owned.contains(State)); // what's above it too

A tag’s name is its type’s in snake_case under its parent’s, unless name says otherwise. A tag with no parent is at the top. Its parent is Root, which every tag hangs from and nothing ever owns.

Naming a tag by type means a misspelled one doesn’t compile: owned.contains(Burnign) is an error, where "state.burnign" would quietly never match. The parent is a type too, so the tree in code can’t be misspelled either.

Tags code names are added to the app, as attributes are:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Tag)] struct State;
#[derive(Tag)] #[tag(parent = State)] struct Burning;
App::new().add_tag::<Burning>(); // and `state`, above it

They join the registry content is checked against, whether the content defines them or not, so data naming them is checked against code.

Keys

Wherever mettle takes a tag, it takes anything that becomes a TagKey: a tag type (Burning), or a name ("state.burning"). A key is the tag interned: a number, cheap to copy and compare, the same whether code named it by type or data by name. That number is this process’s alone. What’s sent over the network is the tag’s number in the registry, a TagId, which is the same on a server and its clients: see The tag registry.

A key knows where it sits in the tree:

use bevy_mettle::prelude::*;
#[derive(Tag)]
struct Damage;
#[derive(Tag)]
#[tag(parent = Damage)]
struct Elemental;
#[derive(Tag)]
#[tag(parent = Elemental)]
struct Fire;

let fire = TagKey::from(Fire);
assert_eq!(fire, TagKey::named("damage.elemental.fire"));
assert_eq!(fire.parent(), Some(TagKey::from(Elemental)));
assert!(fire.is_within(Damage));

let lineage: Vec<&str> = fire.lineage().map(TagKey::name).collect();
assert_eq!(lineage, ["damage.elemental.fire", "damage.elemental", "damage"]);

The tag registry

Tags that only data names needn’t be types. Content lists them by name, and a mod can add its own. In data, a tag is written as its name: "damage.elemental.fire".

In a game, they go in MettleContent’s tags, beside those code adds, and mettle checks them as the content is loaded, keeping them as a resource, Res<TagRegistry>. The tags code adds are kept in the registry too, so a reload doesn’t lose them. TagRegistry::build is that step on its own: it checks the definitions and gives you the registry everything else is checked against:

use bevy_mettle::prelude::*;
let definitions = ["damage", "damage.elemental", "damage.elemental.fire"].map(TagKey::named);
let tags = TagRegistry::build(definitions).unwrap();

assert!(tags.contains("damage.elemental.fire"));
assert!(!tags.contains("damage.poison"));

The registry numbers its tags: each gets a TagId, in order of name. The same tags are numbered the same wherever they’re checked, so a server and its clients agree on every number. id and key go between the two:

use bevy_mettle::prelude::*;
let definitions = ["damage", "damage.elemental", "damage.elemental.fire"].map(TagKey::named);
let tags = TagRegistry::build(definitions).unwrap();
let fire: TagId = tags.id("damage.elemental.fire").unwrap();
assert_eq!(tags.key(fire), Some(TagKey::named("damage.elemental.fire")));

// A reload keeps the numbers tags had, and numbers new ones after them.
let reloaded = tags
    .rebuild(["damage", "damage.elemental", "damage.elemental.fire", "damage.poison"].map(TagKey::named))
    .unwrap();
assert_eq!(reloaded.id("damage.elemental.fire"), Some(fire));

Mettle rebuilds the registry this way each time content is loaded again, so what holds a tag’s number still names the same tag.

build takes definitions from however many files at once: chain them, or collect them into one list. It reports every mistake together, not just the first:

  • A tag defined twice.
  • A tag that isn’t a path. A path is words joined by ., each word made of a to z, 0 to 9 and _. Fire and damage..fire aren’t tags.
  • A gap. Every tag’s parent has to be a tag of its own. Defining damage.elemental.fire without damage.elemental is an error.
use bevy_mettle::prelude::*;
let definitions = [TagKey::named("damage"), TagKey::named("damage.elemental.fire")];
let errors = TagRegistry::build(definitions).unwrap_err();
assert_eq!(
    errors.to_string(),
    "there's no tag called `damage.elemental` (in `damage.elemental.fire`)"
);

Mettle doesn’t read files. Your game loads them however it likes, and hands the definitions over: see Loading data.

Sets of tags

Tags is a set: what an effect, an ability or a hit is labelled with. It holds every tag above the ones you give it, so asking it for effect finds effect.affliction.burning:

use bevy_mettle::prelude::*;
let burning: Tags = ["effect.affliction.burning"].into_iter().collect();

assert!(burning.contains("effect.affliction"));
assert!(burning.contains("effect"));
// Written back as given, without what's above.
assert_eq!(burning.written().count(), 1);

In data, a set is written as a list of the tags as given, each by its name.

Owned tags

An actor’s states are tags it owns: OwnedTags, a component. The game marks the mage state.casting while it casts. Effects tag their owner too: while the mage burns, it owns state.burning. Every actor with an attribute has OwnedTags.

Owned tags are counted. Each source adds one and later takes its one away. If two effects both give it immune.fire, the mage stays immune until both are gone:

use bevy_mettle::prelude::*;
#[derive(Tag)]
struct Immune;
#[derive(Tag)]
#[tag(parent = Immune, name = "fire")]
struct ImmuneToFire;

let mut owned = OwnedTags::default();
owned.add(ImmuneToFire); // A potion.
owned.add(ImmuneToFire); // A ring.

owned.remove(ImmuneToFire); // The potion wears off.
assert!(owned.contains(ImmuneToFire));
assert!(owned.contains(Immune));

owned.remove(ImmuneToFire); // The ring comes off.
assert!(!owned.contains(ImmuneToFire));

Removing a tag only takes away what was added as that tag. Owning immune.fire means owning immune, but owned.remove(Immune) does nothing: no source added immune itself. OwnedTags reads as a TagCounts, which holds the counts: count(tag) says how many sources hold a tag, and iter() lists them. Counts are kept by key, so asking is an index, not a comparison of names.

Told as they come and go

When an actor comes to own a tag, TagAdded is triggered on it. When it stops, TagRemoved is. Each tag above counts too: the first fire immune state tells both immune.fire and immune. Observe them on the actor, or globally:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Resource, Default)]
struct Told(Vec<String>);

let mut app = App::new();
// Mettle runs in `Update` here, so one update tells them.
app.add_plugins((MinimalPlugins, MettlePlugins.set(MettleCorePlugin::new(Update))))
    .init_resource::<Told>()
    .add_observer(|added: On<TagAdded>, mut told: ResMut<Told>| {
        told.0.push(format!("+{}", added.tag));
    })
    .add_observer(|removed: On<TagRemoved>, mut told: ResMut<Told>| {
        told.0.push(format!("-{}", removed.tag));
    });

let mage = app.world_mut().spawn(OwnedTags::default()).id();
app.world_mut()
    .get_mut::<OwnedTags>(mage)
    .unwrap()
    .add("state.burning");
app.update();

assert_eq!(app.world().resource::<Told>().0, ["+state.burning", "+state"]);

They’re told as the publish_tags system runs, as attributes are folded (AttributeFold), before values are worked out. Changes are settled first: a tag added and taken away again before then isn’t told at all.

Asking from a behavior tree

owns is a guard: a read-only system that takes an agent and a tag key, and says whether the agent owns the tag or one under it. It has the shape bevy_arbor takes for a decorator, so a tree can wait on state.stunned.

Checking a client numbers tags as its server does

A client and its server have to agree on the registry: tags travel as their numbers, so a tag numbered differently would be read as another. TagRegistry::fingerprint, as every registry’s, is a hash of the tags with their numbers. Built from the same tags, in whatever order they were defined, registries hash the same; a registry rebuilt by a reload keeps old numbers, so two hash the same only if they were loaded the same way. Over lightyear, mettle compares them for you as a client connects: Lightyear.

Pitfalls

  • Names aren’t checked where you write them. "stat.burning" compiles wherever a tag is taken. Name tags code uses by type; a name is caught only when data naming it is checked against the registry.
  • Facts are tags too. Conditions name the numbers a read supplies by tags, such as fact.flown. Define them as tags, or conditions naming them fail their checks.
  • A tag can’t be under itself. #[tag(parent = ..)] loops compile, but panic with both names the first time either tag is used.
  • No events without the attribute plugin. publish_tags runs in AttributeFold. Without AttributePlugin, owned tags still count, but nothing is told.

Curves

A curve gives a value by key. Usually the key is the level: the mage’s fireball hits harder as it levels up, 10 damage at level 1, 85 at level 10, 240 at level 20. Effects scale their numbers by curves, as Unreal’s curve tables do. A magnitude can read a curve at anything else it works out too, an attribute for diminishing returns: see Magnitudes.

Defining a curve

A curve is data, a CurveData: a name, how it goes between its keys, and its keys as (at, value) pairs, rising:

use bevy_mettle::prelude::*;
fn curves() -> Vec<CurveData> {
    vec![
        CurveData {
            name: "fireball.damage".into(),
            interpolation: Interpolation::Constant,
            keys: vec![(1.0, 10.0), (10.0, 85.0), (20.0, 240.0)],
        },
        CurveData {
            name: "burning.damage".into(),
            interpolation: Interpolation::Linear,
            keys: vec![(1.0, 4.0), (20.0, 40.0)],
        },
    ]
}
assert_eq!(curves().len(), 2);

Here the fireball’s damage steps up at levels 10 and 20, and burning’s grows steadily between its keys. CurveData is a serde type: in a file, it’s a struct with a name (a string), an interpolation, and keys as a list of pairs. interpolation can be left out: it’s Linear unless it says.

Between keys, a curve goes one of three ways:

interpolationBetween two keys
LinearA straight line from one to the next. The default.
ConstantEach key’s value, held until the next.
Ease(..)One of Bevy’s easing functions, such as Ease(EaseFunction::SineInOut) or Ease(EaseFunction::ExponentialIn), from one key to the next.

Before the first key and past the last, a curve stays at that key’s value. A curve with one key is that value at every level.

The curve registry

In a game, the curves go in MettleContent’s curves, and mettle builds the registry as the content is loaded, keeping it as a resource, Res<CurveRegistry>. CurveRegistry::build is that step on its own: it checks each curve, builds it as one of Bevy’s curves, and numbers it.

A curve’s number is a CurveId, given in order of name, as Bevy gives a component its ComponentId. Effects scale by the number once content is checked; the name is for content and debug output. CurveRegistry::id finds a curve’s number by its name:

use bevy_mettle::prelude::*;
fn curves() -> Vec<CurveData> {
    vec![
        CurveData {
            name: "fireball.damage".into(),
            interpolation: Interpolation::Constant,
            keys: vec![(1.0, 10.0), (10.0, 85.0), (20.0, 240.0)],
        },
        CurveData {
            name: "burning.damage".into(),
            interpolation: Interpolation::Linear,
            keys: vec![(1.0, 4.0), (20.0, 40.0)],
        },
    ]
}
let curves = CurveRegistry::build(&curves()).unwrap();

let burning = curves.id("burning.damage").unwrap();
assert_eq!(curves.at(burning, 1.0), Some(4.0));
assert_eq!(curves.at(burning, 50.0), Some(40.0)); // Past the last key.
assert_eq!(curves.id("frost.damage"), None);

As content is loaded again, the registry is rebuilt with CurveRegistry::rebuild: curves keep the numbers they had, and new ones are numbered after them.

build reports every mistake together:

  • A curve defined twice.
  • A curve with no keys.
  • A key that isn’t finite, such as NaN.
  • Levels that don’t rise. Each key’s level has to be above the one before it.

Effects name curves by name. As content is loaded, every curve an effect names is checked against the registry, and resolved to its number. Defining effects shows a magnitude scaled by a curve.

As one of Bevy’s curves

CurveRegistry::curve hands back the built curve itself, as Bevy’s Curve<f32>. Use it to draw a graph in your tooling, or with Bevy’s curve adaptors:

use bevy_mettle::prelude::*;
let curves = CurveRegistry::build(&[CurveData {
    name: Key::from_static("burning.damage"),
    interpolation: Interpolation::Linear,
    keys: vec![(1.0, 4.0), (20.0, 40.0)],
}])
.unwrap();
let burning = curves.curve(curves.id("burning.damage").unwrap()).unwrap();
let graph: Vec<f32> = (1..=20).map(|level| burning.sample_clamped(level as f32)).collect();
assert_eq!(graph.len(), 20);

Pitfalls

  • Levels are numbers, not steps. A level can be 2.5. Each curve goes between its keys as it says, so a Constant curve is the way to get steps.
  • Easing goes from one key to the next. Ease(QuadraticIn) starts slow after every key, not once across the whole curve.

Conditions

A Condition says when something counts, and how much. The mage’s ring adds fire resistance only against fire. Burning stops hurting while the mage is wet. The fireball can’t be cast while the mage is stunned. Each of these is a condition.

A condition is weighed from 0, not at all, to 1, in full. Most terms either hold or don’t. One, FactRamp, can hold in part, and so can what’s built from it.

Writing one

A condition is data: a tree of terms, each naming tags. Here is one built from its variants:

use bevy_mettle::prelude::*;
let falloff = Condition::All(vec![
    Condition::ReadTagged("damage.elemental.fire".into()),
    Condition::FactRamp { fact: "fact.flown".into(), full: 2.0, none: 8.0 },
    Condition::Not(Box::new(Condition::TargetHas("immune.fire".into()))),
]);
let _ = falloff;

That reads: a fire hit, at full strength up to 2 tiles flown and fading to nothing at 8, against a target that isn’t immune to fire.

These are the terms:

TermWeight
ReadTagged(tag)1 if the read has the tag, or one under it: the hit’s tags, the ability’s
OwnerHas(tag)1 if the owner owns the tag, or one under it
TargetHas(tag)1 if the target owns the tag, or one under it
SourceHas(tag)1 if the source, such as the caster or the attacker, owns the tag, or one under it
FactSet(fact)1 if the fact is above 0
FactAbove { fact, value }1 if the fact is above value
FactBelow { fact, value }1 if the fact is below value
FactRamp { fact, full, none }1 at full, 0 at none, in proportion between, and as at the nearer end beyond
FactEvery { fact, n }1 if the fact, a count of 1 or more, is a whole multiple of n: every sixth hit
All(..)The product of the weights: every one holds
Any(..)The greatest of the weights: any one holds
Not(..)One less the weight: it doesn’t hold

A ramp can go either way. With full below none, it fades as the fact grows: a point-blank bonus. With full above none, it builds up: a bonus for a far shot.

In code, the same conditions come from builders. ! makes a Not, and and adds a term to an All:

use bevy_mettle::prelude::*;
let falloff = Condition::read_tagged("damage.elemental.fire")
    .and(Condition::fact_ramp("fact.flown", 2.0, 8.0))
    .and(!Condition::target_has("immune.fire"));

let written = Condition::All(vec![
    Condition::ReadTagged("damage.elemental.fire".into()),
    Condition::FactRamp { fact: "fact.flown".into(), full: 2.0, none: 8.0 },
    Condition::Not(Box::new(Condition::TargetHas("immune.fire".into()))),
]);
assert_eq!(falloff, written);

Condition is a serde type: in data, a condition is written as its variant, with each tag by its name.

The rest are Condition::all, any, owner_has, source_has, fact_set, fact_above, fact_below and fact_every.

Owner, target and source

Who is who depends on what’s reading:

  • The owner is the actor whose value is being worked out. For a ring’s modifier, it’s the mage wearing it.
  • The target is who a read is about: the actor a hit lands on.
  • The source is who caused it: the caster of a fireball, or the attacker.

A read that names no target or source has none, so TargetHas and SourceHas don’t hold. Reading in context shows how a read names them.

Facts

A fact is a number the read supplies, named by a tag: how far the fireball flew (fact.flown), whether the mage struck from behind. A fact the read didn’t supply isn’t there. FactSet, the comparisons and FactEvery don’t hold, and a ramp weighs 0. So a read with no hit gets no hit bonuses.

What a weight in part means

A weight between 0 and 1 means whatever reads it says it means. A modifier counts as much as its condition holds: at half weight, the ring gives half its resistance. A requirement asks for the condition to hold in full, Condition::holds: an effect applies, or an ability activates, only at weight 1.

Weighing a condition yourself

Mettle weighs conditions for you as it reads attributes and checks effects and abilities. To weigh one in your own code, give it a Context: the read’s tags, what the owner, target and source own, and the facts.

use bevy_mettle::prelude::*;
use bevy_mettle::core::condition::Context;

#[derive(Tag)] struct Damage;
#[derive(Tag)] #[tag(parent = Damage)] struct Elemental;
#[derive(Tag)]
#[tag(parent = Elemental)]
struct Fire;

#[derive(Tag)] struct Immune;
#[derive(Tag)] #[tag(parent = Immune, name = "fire")] struct ImmuneToFire;
#[derive(Tag)] struct Fact;
#[derive(Tag)]
#[tag(parent = Fact)]
struct Flown;

/// A fireball's hit, landing on a target that owns `target`.
struct Hit {
    flown: f32,
    target: OwnedTags,
}

impl Context for Hit {
    fn tag(&self, tag: TagKey) -> bool {
        TagKey::from(Fire).is_within(tag)
    }
    fn has(&self, _: TagKey) -> bool {
        false
    }
    fn target(&self, tag: TagKey) -> bool {
        self.target.contains(tag)
    }
    fn source(&self, _: TagKey) -> bool {
        false
    }
    fn fact(&self, fact: TagKey) -> Option<f32> {
        (fact == TagKey::from(Flown)).then_some(self.flown)
    }
}

let falloff = Condition::read_tagged("damage.elemental")
    .and(Condition::fact_ramp("fact.flown", 2.0, 8.0))
    .and(!Condition::target_has("immune.fire"));

let close = Hit { flown: 1.0, target: OwnedTags::default() };
let far = Hit { flown: 5.0, target: OwnedTags::default() };
assert_eq!(falloff.weight(&close), 1.0);
assert_eq!(falloff.weight(&far), 0.5);
assert!(!falloff.holds(&far));

let mut immune = OwnedTags::default();
immune.add(ImmuneToFire);
assert_eq!(falloff.weight(&Hit { flown: 1.0, target: immune }), 0.0);

Checking

Condition::check checks every tag a condition names against the tag registry, and that its numbers can be: comparisons with finite values, a ramp whose ends differ, FactEvery of more than 0. Mettle checks the conditions in effects and abilities as content is loaded, so you only call it for conditions of your own.

use bevy_mettle::prelude::*;
let tags = TagRegistry::build([
    TagKey::named("fact"),
    TagKey::named("fact.flown"),
])
.unwrap();
assert!(Condition::fact_ramp("fact.flown", 2.0, 8.0).check(&tags, None).is_ok());
assert!(Condition::fact_ramp("fact.flown", 2.0, 2.0).check(&tags, None).is_err());
assert!(Condition::fact_set("fact.behind").check(&tags, None).is_err());

Tags by name or by number

A Condition names its tags by TagKey: what content holds and what mettle weighs. Over a network, a tag goes as its number in the tag registry, so a condition goes as a ConditionOver<TagId>: the same terms over tag numbers. Condition is ConditionOver<TagKey>. ConditionOver::map_tags goes between the two, and gives None if a tag doesn’t map:

use bevy_mettle::prelude::*;
let tags = TagRegistry::build([
    TagKey::named("immune"),
    TagKey::named("immune.fire"),
])
.unwrap();
let condition = !Condition::target_has("immune.fire");

let numbered: ConditionOver<TagId> = condition.map_tags(|tag| tags.id(tag)).unwrap();
let back = numbered.map_tags(|id| tags.key(id)).unwrap();
assert_eq!(back, condition);

Mettle does this for you where it sends conditions: see Modifiers.

Pitfalls

  • Tags match downward only. ReadTagged("damage.elemental") holds for a fire hit. ReadTagged("damage.elemental.fire") doesn’t hold for a hit tagged only damage.elemental.
  • Not of a ramp is a ramp. Not(FactRamp { .. }) weighs one less the ramp, so it holds in part too.
  • An empty All always holds; an empty Any never does.

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.

Modifiers

A modifier changes one attribute: +5 spell power from a ring, +20% mana from a passive, a fire resistance an effect grants. Modifiers sit on sources (an item, an effect, the actor itself) and count for one actor. Mettle folds them into the attribute’s current value.

Code writes a modifier by its attribute’s type, as a ModifierSpec, and gives an entity its modifiers with modifiers(..), on EntityCommands or EntityWorldMut. The mage’s ring:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 10.0)] struct SpellPower;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Mana;
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins.set(MettleCorePlugin::new(Update))))
    .add_attribute::<SpellPower>()
    .add_attribute::<Mana>();
let world = app.world_mut();
let mage = world
    .spawn((Base::<SpellPower>::default(), Base::<Mana>::default()))
    .id();
let ring = world
    .spawn(ModifierOf(mage))
    .modifiers([
        ModifierSpec::flat::<SpellPower>(5.0),
        ModifierSpec::additive::<Mana>(0.2),
    ])
    .id();
app.update();
let world = app.world();
assert_eq!(world.get::<Current<SpellPower>>(mage).unwrap().get(), 15.0);
let mana = world.get::<Current<Mana>>(mage).unwrap().get();
assert!((mana - 120.0).abs() < 1e-4);
let _ = ring;

As it’s put on, each becomes a Modifier, which names its attribute by number, an AttributeId, as Bevy names a component by its ComponentId. What’s kept, folded and replicated is the number: a Modifier is what’s read back, and ModifierSpec what code writes. spec.resolve(&attributes) makes one by hand, against the AttributeRegistry.

Sources

Modifiers is a list of modifiers on whatever holds them. On a source, they count for the actor its ModifierOf names. On the actor itself, they count for itself: a passive, a class bonus.

Equipping the ring is adding ModifierOf(mage) to it. Taking the ring off (removing ModifierOf) or despawning it takes its modifiers away. The actor’s ModifierSources lists its sources. Mettle keeps it; don’t set it by hand.

Effects are sources too. A lasting effect carries its modifiers this way while it counts. Applying effects shows that.

How they add up

An attribute is

((base + Σ flat) × (1 + Σ additive) × Π (1 + multiplicative) / (1 + Σ divide)) + Σ unscaled

unless something overrides it. Then it’s rounded, if it rounds, and kept within its limits. The limits always win.

OperationBuilderWhat it does
BaseModifierSpec::baseReplaces the base. The one applied last counts
FlatModifierSpec::flatAdded to the base
AdditiveModifierSpec::additiveA fraction, summed with the others of its kind: +20% and +30% are +50% (“increased”)
MultiplicativeModifierSpec::multiplicativeA fraction, multiplied in on its own: +20% and +30% are +56% (“more”)
DivideModifierSpec::divideA fraction, summed, then divided by: +100% halves it, and more never reaches 0 (cooldown recovery)
UnscaledModifierSpec::unscaledAdded after everything else, scaled by nothing
OverrideModifierSpec::overridingThe value, whatever else there is. The one applied last counts

Fractions are written as fractions: 0.2 is +20%, -0.2 is -20%. Amounts (Base, Flat, Unscaled, Override) are written as amounts.

Here they are all at once on the mage’s spell power:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 10.0)] struct SpellPower;
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins.set(MettleCorePlugin::new(Update))))
    .add_attribute::<SpellPower>();
let world = app.world_mut();
let mage = world
    .spawn(Base::<SpellPower>::default())
    .modifiers([
        ModifierSpec::flat::<SpellPower>(5.0),
        ModifierSpec::additive::<SpellPower>(0.2),
        ModifierSpec::additive::<SpellPower>(0.3),
        ModifierSpec::multiplicative::<SpellPower>(0.2),
        ModifierSpec::multiplicative::<SpellPower>(0.3),
        ModifierSpec::divide::<SpellPower>(1.0),
        ModifierSpec::unscaled::<SpellPower>(2.0),
    ])
    .id();
app.update();
// ((10 + 5) × 1.5 × 1.2 × 1.3 / 2) + 2
let power = app.world().get::<Current<SpellPower>>(mage).unwrap().get();
assert!((power - 19.55).abs() < 1e-4);

Two edges are clamped. Additive below -100% leaves nothing, not less than nothing. A divide of -99% or below divides by 0.01, so a hundred times at most.

Which base or override counts

Of all the Base and Override modifiers on an attribute, only the one applied last counts:

  • a source’s modifiers come in the order its ModifierOf was last set: an effect applied, an item equipped;
  • the actor’s own modifiers come as its Modifiers was added;
  • within one source, the later in its list wins;
  • sources applied in the same tick are ordered by entity.

On the server this order is exact, and the server decides. A client shows what the server serves (Prediction).

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 10.0)] struct SpellPower;
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins.set(MettleCorePlugin::new(Update))))
    .add_attribute::<SpellPower>();
let mage = app.world_mut().spawn(Base::<SpellPower>::default()).id();
app.world_mut()
    .spawn(ModifierOf(mage))
    .modifiers([ModifierSpec::overriding::<SpellPower>(5.0)]);
app.update();
app.world_mut()
    .spawn(ModifierOf(mage))
    .modifiers([ModifierSpec::overriding::<SpellPower>(50.0)]);
app.update();
assert_eq!(app.world().get::<Current<SpellPower>>(mage).unwrap().get(), 50.0);

When a modifier counts

A plain modifier always counts. Three builders narrow that.

A condition. ModifierSpec::condition takes any Condition. The modifier counts while it holds, and as much as it does: a condition weighing 0.5 counts half the amount. ModifierSpec::read_tagged and ModifierSpec::owner_has are the common ones. Each condition you add must hold as well: they’re combined with “and”.

Per another attribute. ModifierSpec::per counts once for each whole amount of another attribute, read in the same context. +1 spell power per 10 mana gives +6 at 60 mana. A multiplicative one is multiplied in each time: +10% twice is +21%.

Local. ModifierSpec::local counts only for reads that name its source: a staff’s own spell power, not every spell’s. Reading in context shows how a read names one.

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 10.0)] struct SpellPower;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Mana;
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins.set(MettleCorePlugin::new(Update))))
    .add_attribute::<SpellPower>()
    .add_attribute::<Mana>();
#[derive(Tag)]
struct State;
#[derive(Tag)]
#[tag(parent = State)]
struct Casting; // "state.casting"

let mage = app
    .world_mut()
    .spawn((Base::<SpellPower>::default(), Base::<Mana>::new(60.0)))
    .modifiers([
        // +1 for each whole 10 mana: +6.
        ModifierSpec::flat::<SpellPower>(1.0).per::<Mana>(10.0),
        // +20% while casting.
        ModifierSpec::additive::<SpellPower>(0.2).owner_has(Casting),
    ])
    .id();
app.update();
assert_eq!(app.world().get::<Current<SpellPower>>(mage).unwrap().get(), 16.0);

app.world_mut()
    .get_mut::<OwnedTags>(mage)
    .unwrap()
    .add(Casting);
app.update();
let power = app.world().get::<Current<SpellPower>>(mage).unwrap().get();
assert!((power - 19.2).abs() < 1e-4);

Base and Override count only in full. One whose condition weighs less than 1 doesn’t count at all. They can’t be per anything.

A modifier whose numbers can’t be (a value that isn’t finite, an attribute the app doesn’t have, a per of 0, a Base per something) is skipped with a warning. Data is checked earlier: Modifier::check checks a modifier against the attributes the app added and the tag registry, and effects’ are checked as content is loaded.

Owned tags

owner_has reads the tags the actor owns: its OwnedTags, a component from bevy_mettle_core. Effects and abilities share them. The game marks the mage state.casting while it casts. Effects tag their owner, such as state.burning, while they last. A tag counts for every tag above it, so owner_has("state") holds while the mage owns state.casting.

Mettle tells owned tags coming and going with TagAdded and TagRemoved as attributes are folded (AttributeFold), before values are worked out. Gameplay tags covers them.

In data

Modifiers are for sources made in code. What data writes, an item’s stats say, is an infinite effect, whose modifiers name attributes by name: checking the content resolves them to numbers. Applied as the item is equipped and taken off as it’s unequipped, it does what a source does, as GAS equips items.

Over a network

On a server, a modifier source the game makes in code, an item or a passive, has its Modifiers there and nowhere else. To show them on a client, opt in with a ModifiersReplica: put one on the source, empty, and replicate it.

  • On a server, mettle keeps the replica in step with the source’s Modifiers, and the actor it’s ModifierOf: as either changes, as the replica’s put on, and as the source is taken off its actor.
  • On a client, mettle gives the source the Modifiers and the ModifierOf the replica says, as it arrives or changes. The actor is an entity: replication maps it into the client’s world.

A replica holds numbers only. A Modifier names its attribute by its AttributeId, which agrees on both sides as long as they add the same attributes in the same order. Its condition’s tags are TagKeys, this process’s alone, so the replica holds a Modifier<TagId>: each tag by its number in the tag registry, which agrees wherever the same content is loaded. A modifier whose condition names a tag the registry hasn’t can’t be sent; it’s left out, with a warning.

Here the replica goes from a server’s world to a client’s by hand, as the game’s replication would carry it:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 10.0)] struct SpellPower;
#[derive(Tag)]
struct State;
#[derive(Tag)]
#[tag(parent = State)]
struct Casting;

fn app(role: NetRole) -> App {
    let mut app = App::new();
    app.add_plugins((MinimalPlugins, MettlePlugins.set(MettleCorePlugin::new(Update))))
        .add_attribute::<SpellPower>()
        .add_tag::<Casting>()
        .insert_resource(role)
        .insert_resource(MettleContent::default());
    app
}
let mut server = app(NetRole::Server);
let mut client = app(NetRole::Client);

// The server's ring, opted in.
let mage = server.world_mut().spawn(Base::<SpellPower>::default()).id();
let ring = server
    .world_mut()
    .spawn((ModifierOf(mage), ModifiersReplica::default()))
    .modifiers([ModifierSpec::flat::<SpellPower>(5.0).owner_has(Casting)])
    .id();
server.update();

// What's sent: the tag by its number.
let replica = server.world().get::<ModifiersReplica>(ring).unwrap().clone();
let casting = server.world().resource::<TagRegistry>().id(Casting).unwrap();
assert_eq!(replica.of, Some(mage));
assert_eq!(replica.modifiers[0].condition, Some(ConditionOver::OwnerHas(casting)));

// The client's copies, as they arrive: its mage, as replication maps it.
let mage = client.world_mut().spawn(Base::<SpellPower>::default()).id();
let ring = client.world_mut().spawn(ModifiersReplica { of: Some(mage), ..replica }).id();
client.update();
assert_eq!(client.world().get::<ModifierOf>(ring), Some(&ModifierOf(mage)));
client.world_mut().get_mut::<OwnedTags>(mage).unwrap().add(Casting);
client.update();
assert_eq!(client.world().get::<Current<SpellPower>>(mage).unwrap().get(), 15.0);

Effects don’t need it: a client makes an active effect’s modifiers from the effect’s own replica (Prediction). With the lightyear adapter, ModifiersReplica is replicated for you, and ModifierOf isn’t: the replica carries it. The game still puts the replica on each source it replicates (Over lightyear).

Pitfalls

  • Writing a fraction as a percent. ModifierSpec::additive::<Mana>(20.0) is +2000%. Write 0.2.
  • Expecting two overrides to combine. Only the last applied counts.
  • A modifier on the wrong entity. A source needs ModifierOf. Without it, its Modifiers count for the source itself, if it has attributes.
  • A source replicated without its replica. Replicating a source’s ModifierOf alone shows nothing on a client: its Modifiers aren’t replicated. Put a ModifiersReplica on it.
  • A limit that reads itself. If an attribute’s limit or per leads back to itself, the loop is cut where it closes, so the value stays finite.

Reading in context

Current<A> is an attribute with nothing in play. A fireball hitting the mage is something in play: it’s fire, it has a source, it flew some way. Some modifiers count only for reads like that. This chapter reads attributes in a context.

Kinds of a value

The mage resists fire better than cold. That’s not two attributes. It’s one, Resistance, with modifiers that count only for reads tagged with a damage type:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(max = MaxResistance)] struct Resistance;
#[derive(Attribute)] #[attribute(base = 0.75)] struct MaxResistance;
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins.set(MettleCorePlugin::new(Update))))
    .add_attribute::<Resistance>()
    .add_attribute::<MaxResistance>();
let mage = app
    .world_mut()
    .spawn((Base::<Resistance>::default(), Base::<MaxResistance>::default()))
    .id();
#[derive(Tag)]
struct Damage;
#[derive(Tag)]
#[tag(parent = Damage)]
struct Elemental;
#[derive(Tag)]
#[tag(parent = Elemental)]
struct Fire;
#[derive(Tag)]
#[tag(parent = Elemental)]
struct Cold;

// A ring: +90% elemental resistance, +5% maximum fire resistance.
app.world_mut().spawn(ModifierOf(mage)).modifiers([
    ModifierSpec::flat::<Resistance>(0.9).read_tagged(Elemental),
    ModifierSpec::flat::<MaxResistance>(0.05).read_tagged(Fire),
]);
app.update();

let world = app.world();
let fire = AttributeContext::new().tag(Fire);
let cold = AttributeContext::new().tag(Cold);
let resists = |context: &AttributeContext| world.value::<Resistance>(mage, context).unwrap();
assert!((resists(&fire) - 0.8).abs() < 1e-4); // capped at 75% + 5%
assert!((resists(&cold) - 0.75).abs() < 1e-4); // capped at 75%
assert_eq!(resists(&AttributeContext::new()), 0.0); // nothing in play

Tags are a tree, so the modifier for damage.elemental counts for a fire hit: a read tagged damage.elemental.fire is tagged damage.elemental too. Limits are read in the same context, so fire resistance is capped by maximum fire resistance. A mod adds a damage type by adding its tag. Unreal’s Gameplay Ability System does the same: modifiers with tag requirements, weighed as the attribute is read.

The context

AttributeContext says what’s in play. Start from AttributeContext::new(), nothing, and add to it:

BuilderWhat the read has
tags(&tags), tag("..")The read’s own tags: the hit’s, the ability’s
target_tags(&owned), target_tag("..")What the target owns
source_tags(&owned), source_tag("..")What the source (the caster, the attacker) owns
fact("fact.flown", 7.0)A number named by a tag. Supplying it again replaces it
source(entity)The source involved: its local modifiers count

The owner’s own tags are always in play: they’re the actor’s OwnedTags.

These are what a modifier’s condition is weighed in. ReadTagged reads the read’s tags, TargetHas the target’s, SourceHas the source’s, and FactRamp and the other fact terms read the facts. A fact the read didn’t supply isn’t there, so a read with no hit gets no hit bonuses.

tags, target_tags and source_tags borrow what they’re given. A read per hit copies nothing.

In a system

AttributeReader is a system parameter that reads in context:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 10.0)] struct SpellPower;
#[derive(Component)] struct Mage;
#[derive(Resource, Default)] struct Dealt(f32);
fn fireball_damage(
    mages: Query<Entity, With<Mage>>,
    attributes: AttributeReader,
    mut dealt: ResMut<Dealt>,
) {
    for mage in &mages {
        let hit = AttributeContext::new()
            .tag("ability.spell.fire")
            .fact("fact.flown", 7.0);
        if let Some(power) = attributes.value::<SpellPower>(mage, &hit) {
            dealt.0 = power;
        }
    }
}
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins.set(MettleCorePlugin::new(Update))))
    .add_attribute::<SpellPower>()
    .init_resource::<Dealt>()
    .add_systems(Update, fireball_damage.after(MettleSystems));
app.world_mut().spawn((Mage, Base::<SpellPower>::default())).modifiers([
    ModifierSpec::additive::<SpellPower>(0.5).read_tagged("ability.spell.fire"),
    ModifierSpec::flat::<SpellPower>(10.0)
        .condition(Condition::fact_ramp("fact.flown", 2.0, 12.0)),
]);
app.update();
// (10 + 10 × half) × 1.5: the ramp is half-way at 7.
assert!((app.world().resource::<Dealt>().0 - 22.5).abs() < 1e-4);

value is None if the actor doesn’t have the attribute. With nothing in play, read Current<A> instead: it’s already worked out.

The reader also has breakdown (below), and owned, an actor’s owned tags. Each names its attribute by type.

On the world

AttributeWorld does the same on a World, for exclusive systems and tests: value, breakdown and base, by type. attribute_id::<A>() is A’s number, what events such as EffectExecuted name it by.

It also changes a base the way an instant effect does. apply_to_base changes it by an operation and a value, then rounds it and keeps it within its limits. base_after says what that would leave, without changing anything:

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.set(MettleCorePlugin::new(Update))))
    .add_attribute::<Life>()
    .add_attribute::<MaxLife>();
let mage = app
    .world_mut()
    .spawn((Base::<Life>::default(), Base::<MaxLife>::default()))
    .id();
app.update();
let world = app.world_mut();
// A fireball for 130: life stops at its minimum, 0.
assert_eq!(
    world.base_after::<Life>(mage, 100.0, Operation::Flat, -130.0),
    Some(0.0)
);
world.apply_to_base::<Life>(mage, Operation::Flat, -30.0);
assert_eq!(world.base::<Life>(mage), Some(70.0));

Limits are read as the bases are now, even if they changed since attributes were last kept.

Breakdowns

A Breakdown is an attribute’s modifiers summed but not yet applied: the base, Σ flat, Σ additive, the product of the multipliers, Σ divide, Σ unscaled, and the override if there is one. total() applies them with the formula from Modifiers.

Use it for a tooltip, or to combine an attacker’s and a target’s modifiers into one hit:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 10.0)] struct SpellPower;
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins.set(MettleCorePlugin::new(Update))))
    .add_attribute::<SpellPower>();
let mage = app
    .world_mut()
    .spawn(Base::<SpellPower>::default())
    .modifiers([
        ModifierSpec::flat::<SpellPower>(5.0),
        ModifierSpec::additive::<SpellPower>(0.2),
    ])
    .id();
app.update();
let parts = app
    .world()
    .breakdown::<SpellPower>(mage, &AttributeContext::new())
    .unwrap();
assert_eq!((parts.base, parts.flat), (10.0, 5.0));
assert!((parts.total() - 18.0).abs() < 1e-4);

Local modifiers

A local modifier counts only for reads naming its source. The mage’s staff adds 100 spell power to its own spells, not to every spell:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 10.0)] struct SpellPower;
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins.set(MettleCorePlugin::new(Update))))
    .add_attribute::<SpellPower>();
let mage = app.world_mut().spawn(Base::<SpellPower>::default()).id();
let staff = app
    .world_mut()
    .spawn(ModifierOf(mage))
    .modifiers([ModifierSpec::flat::<SpellPower>(100.0).local()])
    .id();
app.update();
let world = app.world();
let with_staff = AttributeContext::new().source(staff);
assert_eq!(world.value::<SpellPower>(mage, &with_staff), Some(110.0));
assert_eq!(world.get::<Current<SpellPower>>(mage).unwrap().get(), 10.0);

Guards

Two guards read attributes with nothing in play: attribute_above and attribute_below. They’re read-only systems taking an agent and a value, the shape bevy_arbor’s behavior trees take as conditions: when(attribute_below::<Life>, 20.0) => task(Flee). An agent without the attribute is neither above nor below. The guard on owned tags, owns, is in bevy_mettle_core (Gameplay tags).

use bevy::prelude::*;
use bevy_mettle::prelude::*;
use bevy_mettle::attribute::read::{attribute_above, attribute_below};
#[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.set(MettleCorePlugin::new(Update))))
    .add_attribute::<Life>()
    .add_attribute::<MaxLife>();
let mage = app
    .world_mut()
    .spawn((Base::<Life>::new(15.0), Base::<MaxLife>::default()))
    .id();
app.update();
let world = app.world_mut();
assert!(world.run_system_cached_with(attribute_below::<Life>, (mage, 20.0)).unwrap());
assert!(!world.run_system_cached_with(attribute_above::<Life>, (mage, 20.0)).unwrap());

On a client

On a client, values with nothing in play are the ones the server serves. A read in context is worked out on the client, from the modifiers replicated to it. So it’s advisory, as in Unreal: the server’s read is the one that decides. See Prediction.

Pitfalls

  • Reading Current for a hit. Current leaves out every modifier with a condition on the read. Read the hit in its context.
  • A context per frame per actor. A context is cheap, but reading every attribute in context every frame isn’t. Read in context where something happens: a hit, a cast.
  • A fact not supplied. A condition on a fact the read doesn’t supply doesn’t hold. Supply every fact your modifiers name.

Defining effects

An effect is what changes an actor: damage, a buff, a cooldown, a stun. It says what it changes, for how long, and what tags its owner holds while it lasts. It’s Unreal’s GameplayEffect, as data.

Effects are content: definitions, each with a name. A game builds them as EffectData values, from its files or in code, and hands them to mettle in MettleContent, which checks them all before anything is applied.

The mage’s effects

The fire mage from earlier chapters needs four effects:

  • burning, fire damage every half second for four seconds.
  • fireball.cost, 20 mana taken at once.
  • fireball.cooldown, two seconds during which the mage owns cooldown.fireball.
  • staff, a staff the mage holds for as long as it’s equipped.

Here they are, with the builders on EffectData:

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 effects() -> Vec<EffectData> {
    vec![
        EffectData::lasting("burning", 4.0)
            .tag("effect.affliction.burning")
            .period(0.5, true)
            .modifier(EffectModifier::new::<Life>(
                Operation::Flat,
                Magnitude::param("param.damage"),
            ))
            .owner_tag("state.burning")
            .require(!Condition::owner_has("immune.fire"))
            .ongoing(!Condition::owner_has("state.wet")),
        EffectData::instant("fireball.cost")
            .modifier(EffectModifier::new::<Mana>(Operation::Flat, -20.0)),
        EffectData::lasting("fireball.cooldown", 2.0).owner_tag("cooldown.fireball"),
        EffectData::infinite("staff"),
    ]
}
let burning = &effects()[0];
assert_eq!(burning.name.as_str(), "burning");
assert!(burning.lasts());

EffectData is a serde type, so the same effects can come from files in any serde format. It’s a struct with the fields below; tags, and the attributes, curves, effects, executions, calculations and routes it names, are written as their names, strings; and fields left out take their defaults. Loading data says more.

Naming effects in code

Content names effects by string, since it’s data: a Key<AnyEffect>. Code names an effect by its type, as it does a tag. Derive Effect on a unit struct, and add it to the app:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Effect)]
struct Burning;                    // "burning"

#[derive(Effect)]
#[effect(name = "fireball.cooldown")]
struct FireballCooldown;           // "fireball.cooldown"

let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
    .add_effect::<Burning>()
    .add_effect::<FireballCooldown>();

An effect’s name is its type’s in snake_case, unless name says otherwise. The type is how code applies it: apply_effect(Burning), as Applying effects shows. A misspelled type doesn’t compile, where a misspelled string would only fail as it’s applied.

add_effect is a promise that content defines the effect. Content that doesn’t is rejected with “code names effect burning, which content doesn’t define”, so the name is checked once, as content loads, not each time code applies it. Effects only content names, such as those chained by other effects, needn’t be types at all.

The staff does nothing yet. Grants and triggers gives it an ability to grant.

Fields

Only name and duration are required. Everything else is empty unless the effect says.

FieldWhat it does
nameWhat content and an Effect type call it by
tagsWhat it is: what ending, blocking and requirements match on
durationInstant, Infinite, or For(..) so many seconds
periodExecutes every so often while it lasts, instead of its modifiers counting all along
modifiersWhat it changes: modifiers while it lasts, or the base as it executes
executionsFormulas in code, run as it executes: see Effects in code
owner_tagsTags the target, its owner, holds while it counts
cuesCues held on the target while it counts, and played each time it executes: see Cues
requireWhat has to hold for it to apply at all
ongoingWhat has to hold for it to count; while it doesn’t, it’s inhibited
end_ifWhat ends it as soon as it holds
endsActive effects with any of these tags, ended as it applies
blocksEffects with any of these tags can’t apply while it counts
stackingHow applying it again stacks: see Stacking
on_apply, on_remove, on_expireEffects applied to the target as it applies, comes off, or runs out of time

Durations

  • Instant executes once and is gone. Its modifiers change the target’s bases: the fireball’s cost takes 20 off the mage’s base mana for good.
  • Infinite lasts until something takes it off: gear, an aura, a curse that has to be cleansed.
  • For(seconds) lasts that long. Its time runs down as effects tick.

A lasting effect’s modifiers count as modifiers of the target while it counts, and stop counting when it goes. The base is left alone, so a two-second buff can’t leave anything behind.

An instant effect can’t have anything that needs it to last: a period, owner tags, blocked effects, an ongoing or end condition, stacking, or effects on removal or expiry. Content with one is rejected.

Periods

A period turns a lasting effect into one that executes every so often, like an instant effect would, for as long as it lasts. burning executes every half second, and once more as it’s applied, since on_apply is true.

A periodic effect’s modifiers never count as modifiers. Each execution changes the base instead, so burning’s damage stays taken when it ends.

If a tick is longer than the period, the effect executes as many times as periods came round. A period has to be more than 0.

Modifiers

An EffectModifier names an attribute, an Operation and a Magnitude. In a lasting effect, it’s a modifier like any other, folded as Modifiers explains. As an effect executes, it changes the base instead:

OpWhat executing does to the base
Flat, UnscaledAdds the value
Additive, MultiplicativeScales by 1 + value
DivideDivides by 1 + value
Base, OverrideSets it to the value

The base is then rounded and kept within the attribute’s limits, so burning can’t take life below 0.

A modifier can have a condition, with .condition(..). In a lasting effect, it’s weighed on each read, as an item’s would be:

  • ReadTagged matches the read’s tags: +20% damage with fire hits.
  • OwnerHas matches the target’s owned tags.
  • TargetHas matches the owned tags of the read’s target.
  • SourceHas matches what the effect’s source owned when it applied.

As an effect executes, ReadTagged matches the effect’s own tags, and OwnerHas and TargetHas match the target’s. A condition that holds in part scales the amount. A Base or Override counts only when it holds in full.

.per::<A>(every) makes a modifier count once for each whole every of the target’s A: +1 damage per 10 spell power.

A modifier can route into a route instead: an amount no actor holds, routed into attributes they do as the effect executes. EffectModifier::route::<IncomingDamage>(40.0). In data, a route modifier names its route by name, "incoming_damage", where an attribute’s names the attribute, and its op is flat unless it says. A route takes flat amounts only, from an effect that executes: instant, or periodic. The registry rejects any other.

Data names attributes, routes, executions and calculations by name. Checking the content resolves each name to its number, which is what the effect runs by: see Names and numbers.

Magnitudes

A Magnitude is how much: a modifier’s value, a duration, or a period. It’s a small expression. Its terms are:

TermHow much
Value(f32)That number. A plain f32 converts to it.
LevelThe spec’s level
Param(tag)What the code applying it set, by a tag such as param.damage
Attribute(read)An attribute of the source’s or the target’s, an AttributeRead: see Captures
Custom(name)A calculation in code, by name, such as "burn_time": see Effects in code
Curve { curve, at }The curve’s value at at, any magnitude: the spec’s level unless it says

And it combines them:

CombinationHow much
Sum(terms)The terms added up
Product(terms)The terms multiplied together
Clamp { value, min, max }value, kept within min and max, either of them left out

In code, the builders keep the common cases short: 4.0.into(), Magnitude::param, Magnitude::curve (at the level), Magnitude::curve_at, Magnitude::scaled (a value times a curve at the level), Magnitude::source and Magnitude::target (an attribute’s current value), and Magnitude::Custom("burn_time".into()). Then times, plus and clamp combine them. In data, each is its variant.

Param is Unreal’s SetByCaller. The applying code knows something the data doesn’t, such as how hard the fireball hit, and passes it on the spec. A spec that doesn’t set the param gets 0, with a warning.

Unreal’s magnitudes

Unreal has four kinds of magnitude, each with its own extras: a coefficient here, a curve there. In mettle, each is a composition of the terms:

UnrealMettle
Scalable floatProduct[Value, Curve]: Magnitude::scaled(10.0, "fireball.damage")
Attribute based, coef × (attr + pre) + postSum and Product of an Attribute, each part any magnitude, a curve too
Attribute based, by an attribute curveCurve { at: Attribute }
Custom calculation class, its coefficientProduct[Custom, Value]
Source and target tag filtersThe read’s tagged tags
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 10.0)] struct SpellPower;
#[derive(Attribute)] struct Armour;
// A scalable float: 10 times the curve at the spec's level.
assert_eq!(
    Magnitude::scaled(10.0, "fireball.damage"),
    Magnitude::Product(vec![10.0.into(), Magnitude::curve("fireball.damage")]),
);

// Attribute based: half of (the caster's bonus spell power plus 4), plus 10.
let ward = Magnitude::source::<SpellPower>().bonus().plus(4.0).times(0.5).plus(10.0);

// Its coefficient scaling with level, as Unreal's scalable coefficient.
let scaling = Magnitude::source::<SpellPower>().times(Magnitude::scaled(0.1, "ward.scaling"));

// An attribute curve: the target's armour, looked up on a falloff curve.
let falloff = Magnitude::curve_at("armour.falloff", Magnitude::target::<Armour>());

// A calculation, times 1.5.
let burn = Magnitude::Custom("burn_time".into()).times(1.5);

// Read in a tag: the target's armour as fire hits see it, at least 0.
let armour = Magnitude::from(Magnitude::target::<Armour>().tagged("damage.fire"))
    .clamp(Some(0.0), None);

It’s deliberately limited to sums, products and clamps: no division, no conditions, no variables. Anything more is a Custom calculation, in code.

Checking the content turns each magnitude into a MagnitudeDef: its names resolved to numbers, so no string reaches the game as it runs, and its constants folded, so Product[2, 3] is 6.

Captures

An Attribute magnitude reads an AttributeRead. Unreal calls it an attribute capture:

FieldWhat it is
attributeThe attribute, by name
fromWhose: Source unless it says, or Target
valueWhich of its values: Current unless it says, Base, or Bonus (its current value less its base)
snapshotCaptured and kept, unless it says false: then it’s read live
tagsWhat it’s read in: which conditional modifiers count

In code, Magnitude::source and Magnitude::target start one, and .base(), .bonus(), .live() and .tagged(tag) set the rest; it’s a magnitude wherever one’s taken.

A capture is read as the hit it’s part of is: in the effect’s tags and its own, with the effect’s target and source and what each owns, as any read in context is. So a caster’s +20% damage with fire hits counts for a fireball tagged damage.fire, and +10% against burning targets counts on a burning target, with nothing more said. .tagged(tag) reads in more. With no source, a source’s attribute is 0.

Snapshot or live

A capture is a snapshot unless it says, as Unreal’s bSnapshot. A snapshot is taken at the earliest moment, and kept:

  • The source’s as the spec is made, if the code making it takes its snapshot; if not, as it’s applied.
  • The target’s as it’s applied.

New stacks and periods use what was captured, not the caster’s spell power now.

.live() reads it as it is. An effect that executes reads it each time it does. A lasting effect follows it: when the attribute changes, the effect’s modifiers are worked out again in the same step, before values are published. Only the effects reading the actor that changed are, and nothing polls. An aura that grows with its caster reads the caster’s spell power live:

use bevy::prelude::*;
use bevy::time::TimeUpdateStrategy;
use bevy_mettle::prelude::*;
#[derive(Attribute)]
struct SpellPower;

#[derive(Attribute)]
struct Armour;

#[derive(Effect)]
struct Aura;

#[derive(Effect)]
struct Blessing;

fn effects() -> Vec<EffectData> {
    vec![
        // Follows the caster's spell power.
        EffectData::infinite(Key::of::<Aura>()).modifier(EffectModifier::new::<Armour>(
            Operation::Flat,
            Magnitude::source::<SpellPower>().live().times(0.5),
        )),
        // Keeps what it was as it applied.
        EffectData::infinite(Key::of::<Blessing>()).modifier(EffectModifier::new::<Armour>(
            Operation::Flat,
            Magnitude::source::<SpellPower>().times(0.5),
        )),
    ]
}

let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
  // One fixed step, where mettle runs, each update.
  .insert_resource(TimeUpdateStrategy::FixedTimesteps(1))
    .add_attribute::<SpellPower>()
    .add_attribute::<Armour>()
    .add_effect::<Aura>()
    .add_effect::<Blessing>()
    .insert_resource(MettleContent { effects: effects(), ..default() });
app.update();

let caster = app.world_mut().spawn(Base::<SpellPower>::new(20.0)).id();
let (warded, blessed) = (
    app.world_mut().spawn(Base::<Armour>::default()).id(),
    app.world_mut().spawn(Base::<Armour>::default()).id(),
);
app.update();
app.world_mut().apply_effect(warded, Aura.source(caster));
app.world_mut().apply_effect(blessed, Blessing.source(caster));
let armour = |app: &App, actor| app.world().get::<Current<Armour>>(actor).unwrap().get();
app.update();
assert_eq!((armour(&app, warded), armour(&app, blessed)), (10.0, 10.0));

// The caster grows stronger: the aura follows, the blessing doesn't.
app.world_mut().get_mut::<Base<SpellPower>>(caster).unwrap().set(40.0);
app.update();
assert_eq!((armour(&app, warded), armour(&app, blessed)), (20.0, 10.0));

An effect can’t follow itself. A lasting effect reading live what it changes of the target’s would change it, read the change, and change it again, so content with one is rejected: “a live read of the target’s armour, which it changes: it would follow itself”.

Requirements

Three conditions decide whether an effect applies, counts, and stays. Each is a condition, weighed against the effect’s own tags (as ReadTagged), the target’s owned tags (as OwnerHas and TargetHas), and what the source owned as it applied (as SourceHas). It has to hold in full. Facts are never set here.

  • require is checked once, as it’s applied. If it doesn’t hold, the effect doesn’t apply, and EffectBlocked says why. Burning doesn’t apply to a mage who owns immune.fire.
  • ongoing is checked every tick. While it doesn’t hold, the effect is inhibited: its modifiers stop counting, its tags are taken back, and its time stands still. Burning is inhibited while the mage is wet, and picks up where it left off when the mage dries.
  • end_if is checked every tick. As soon as it holds, the effect ends.

Ending and blocking

ends ends active effects on the target as the effect applies: a potion that ends effect.affliction cleanses burning and poison. It’s done before the new effect lands, so it never ends itself.

blocks keeps effects off while it counts: Unreal’s immunity. A fire ward with .blocks("effect.affliction.burning") blocks burning, with EffectBlocked’s reason Effect. An inhibited ward protects no one.

Tags match by their tree: ending effect.affliction ends everything tagged under it.

Abilities have the same pair under the same names: an ability ends the active abilities it matches as it starts, and blocks others while it’s active. Each has end_if too.

Chains

An effect can apply other effects to its target:

  • on_apply as it applies, and again as it gains a stack.
  • on_remove as it comes off, however that happens.
  • on_expire as it runs out of time, after on_remove.

A chained effect is applied with the same spec: the same level, params, source and activation. A chain more than 16 effects deep is taken for a loop, and stopped with a warning.

Checking them

Effects reach mettle in MettleContent, with the tags and curves they name. As it’s inserted, mettle checks every effect against the tag registry, the curves, the attributes the app added, and the effect code, and turns them into the EffectRegistry. It reports every error at once:

  • effects defined twice;
  • effects code names with add_effect that content doesn’t define;
  • tags, curves, attributes, effects, executions, calculations and routes that don’t exist;
  • numbers that can’t be: not finite, a duration or period of 0 or less, a per of 0 or less, a stacking limit of 0;
  • magnitudes that can’t be: a sum or product of nothing, a clamp whose least is above its most;
  • a lasting effect reading live what it changes of the target’s;
  • instant effects with parts that need them to last;
  • executions on a lasting effect with no period, which never executes.

Content that doesn’t check out is rejected: each error is logged, ContentRejected is triggered with them, and the content before stays. To ask before inserting it, call MettleContent::check:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
use bevy_mettle::core::Errors;
#[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 effects() -> Vec<EffectData> {
    vec![
        EffectData::lasting("burning", 4.0)
            .tag("effect.affliction.burning")
            .period(0.5, true)
            .modifier(EffectModifier::new::<Life>(Operation::Flat, Magnitude::param("param.damage")))
            .owner_tag("state.burning")
            .require(!Condition::owner_has("immune.fire"))
            .ongoing(!Condition::owner_has("state.wet")),
        EffectData::instant("fireball.cost")
            .modifier(EffectModifier::new::<Mana>(Operation::Flat, -20.0)),
        EffectData::lasting("fireball.cooldown", 2.0).owner_tag("cooldown.fireball"),
        EffectData::infinite("staff"),
    ]
}
fn tags() -> Vec<TagKey> {
    [
        "cooldown", "cooldown.fireball",
        "effect", "effect.affliction", "effect.affliction.burning",
        "immune", "immune.fire",
        "param", "param.damage",
        "state", "state.burning", "state.wet",
    ]
    .into_iter()
    .map(TagKey::named)
    .collect()
}

let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
    .add_attribute::<Life>()
    .add_attribute::<MaxLife>()
    .add_attribute::<Mana>();

let content = MettleContent {
    tags: tags(),
    effects: effects(),
    ..default()
};
content.check(app.world())?;
app.insert_resource(content.clone());

// A typo is caught before anything runs.
let typo = MettleContent {
    tags: tags(),
    effects: vec![EffectData::lasting("chill", 2.0).owner_tag("state.chilled")],
    ..default()
};
let errors = typo.check(app.world()).unwrap_err();
assert_eq!(errors.to_string(), "there's no tag called `state.chilled` (in `chill`)");

// So is an effect code names that content doesn't define.
#[derive(Effect)]
struct Frostbite;
app.add_effect::<Frostbite>();
let errors = content.check(app.world()).unwrap_err();
assert_eq!(
    errors.to_string(),
    "code names effect `frostbite`, which content doesn't define"
);
Ok::<(), Errors>(())

Mettle doesn’t load files. The game reads them however it likes and hands over the content: see Loading data.

Numbers

As content is checked, each effect is numbered: an EffectId, given in order of name, as Bevy gives a component its ComponentId. Active effects, chains, events and the network all go by the number. The name is for content, debug output and what a client shows. The registry goes between them:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Effect)] struct Burning;
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
    .add_effect::<Burning>()
    .insert_resource(MettleContent {
        tags: ["cooldown", "cooldown.fireball"].map(TagKey::named).to_vec(),
        effects: vec![
            EffectData::lasting(Key::of::<Burning>(), 4.0),
            EffectData::lasting("fireball.cooldown", 2.0).owner_tag("cooldown.fireball"),
        ],
        ..default()
    });
app.update();

let registry = app.world().resource::<EffectRegistry>();
let burning = registry.id("burning").unwrap();
assert_eq!(registry.id_of::<Burning>(), Some(burning));
assert_eq!(registry.name(burning), Some("burning"));
assert!(registry.get(burning).is_some());
assert_eq!(registry.iter().count(), 2);

EffectRegistry reads as every registry does: get(id) gives the checked effect, id("name") and id_of::<E>() its number, name(id) its name, and iter() every one. EffectRegistry::rebuild is the check on its own, against an EffectScope of the tag, curve, attribute, execution, calculation and route registries; it keeps the numbers effects had, and the effects code named with add_effect.

Numbered in order of name, the same content is numbered the same wherever it’s checked: a server and its clients agree on every number without sending names.

Reloading

Change MettleContent, and it’s checked and loaded again, as a whole. Effects keep the numbers they had, and new ones are numbered after them, so what holds a number still names the same effect. Every active effect takes its new definition on the next tick. Its modifiers and granted tags are worked out again, and its captures are taken again: the source’s from the spec’s snapshot, if it has one. Its time left is kept. An active effect whose definition is gone comes off, as if removed.

That’s a hot reload, or a mod adding its effects: change the content, and everything is checked against everything again.

Pitfalls

  • Damage is negative. A Flat modifier adds. Burning’s spec sets param.damage to a negative number, or the modifier says -12.0.
  • Add attributes first. Content is checked against the app’s attributes. Checked before add_attribute, it rejects every effect that names one.
  • A periodic effect’s modifiers aren’t buffs. Each period changes the base, and the change stays. For a buff that comes and goes, leave out the period.

Applying effects

An effect’s definition says what it does. To use it, you apply a spec of it to a target: this effect, at this level, from this caster, with these numbers. Code names the effect by its type, as Defining effects shows: #[derive(Effect)] struct Burning;, added with app.add_effect::<Burning>(). A lasting effect then becomes an entity of its own, attached to its target, until it runs out or something takes it off.

Specs

An EffectSpec is what’s applied. It’s Unreal’s GameplayEffectSpec: which effect, and these fields:

FieldWhat it is
levelWhat curves are read at: 1 unless it says
paramsNumbers the applying code sets, for Param magnitudes
contextWhat else it carries: who applied it, what struck, where. See Context
snapshotIts source’s attributes as it was made, if the code making it took them: see Snapshots
activationThe ability activation it’s applied under, if any: see Prediction

An effect type is its own spec, at level 1 from no one: Burning is EffectSpec::of::<Burning>(). Its builders make a spec with more set. A param is named by a tag, here param.damage as a type:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
let caster = Entity::PLACEHOLDER;
#[derive(Effect)]
struct Burning;

#[derive(Tag)]
struct Param;

#[derive(Tag)]
#[tag(parent = Param, name = "damage")]
struct DamageParam; // "param.damage"

let spec = Burning.level(3.0).param(DamageParam, -5.0).source(caster);
assert_eq!(spec.level, 3.0);
assert_eq!(spec.param_value(DamageParam), Some(-5.0));
assert_eq!(spec.context.source, Some(caster));
assert_eq!(EffectSpec::from(Burning), EffectSpec::of::<Burning>());

Setting a param again replaces it. A spec names its effect by type, never by name or number: an effect code applies has a type. Which effect a spec applies is resolved to its number as it’s applied.

Context

A spec’s EffectContext is what the applying code knew beyond numbers. It’s Unreal’s FGameplayEffectContext. Its one field is source, who’s responsible: the archer. Source tags, attribute captures and stacking by source read it, set with .source(archer).

Everything else is context data, one of each type, added with .with(..) and read with get::<T>(). Mettle’s own are:

TypeWhat it is
CauserWhat struck: the arrow, the trap. Abilities set their actor
SourceObjectWhat it came from: the bow, an item
AbilityRefThe ability that applied it: its id, and its level as it was activated. Set by abilities for what they apply
HitWhere it hit: the point, and the surface’s normal
OriginWhere it came from: what knockback pushes away from

Which run of an ability applied it is the spec’s activation.

A game adds its own with a ContextData type, Unreal’s context subclassed. It’s for structured data, which none of the rest says:

  • A flag, a critical or a headshot, is a tag, which conditions, blocking and cues see.
  • A number is a param.
  • What struck and what it came from are Causer and SourceObject.

What’s left is a game’s own shape: the targets a chain of lightning already struck, say. Any type deriving Reflect, Clone, PartialEq, Serialize, Deserialize and MapEntities is context data. Reflect it as ContextData, and register it with the app, as Bevy registers a component:

use bevy::prelude::*;
use bevy::ecs::entity::MapEntities;
use bevy_mettle::prelude::*;
use serde::{Deserialize, Serialize};
let (mage, bolt) = (Entity::PLACEHOLDER, Entity::PLACEHOLDER);
let struck = vec![Entity::PLACEHOLDER];
use bevy_mettle::effect::context::ContextDataRegistry;
#[derive(Effect)]
struct ChainLightning;

/// Who the lightning already struck, for the next jump to skip.
#[derive(Reflect, Clone, PartialEq, Serialize, Deserialize, MapEntities)]
#[reflect(ContextData)]
struct Chain {
    #[entities]
    struck: Vec<Entity>,
}

let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
    .register_type::<Chain>();
app.finish();
let registry = app.world().resource::<ContextDataRegistry>();
assert!(registry.id_of::<Chain>().is_some());

let spec = ChainLightning
    .source(mage)
    .with(Causer(bolt))
    .with(Hit { point: Vec3::new(2.0, 1.5, 0.0), normal: Vec3::NEG_X })
    .with(Chain { struck: struck.clone() });
assert_eq!(spec.context.get::<Causer>(), Some(&Causer(bolt)));
assert!(spec.context.get::<Chain>() == Some(&Chain { struck }));

One of each type goes on a context, read by type. Executions and calculations read it from their view: effect.get::<Causer>(), effect.get::<Hit>(), effect.get::<Chain>(). An effect it chains carries it too, and the cues it plays are told it.

It goes with an effect’s replica to clients, and with a one-shot cue’s, entities mapped, those in a game’s type too. A type goes by its number in the ContextDataRegistry, built as the app finishes from every type registered as context data, in order of type path: a server and its clients number it the same, whatever order they register it in. A type that isn’t registered stays in the world that applied it. A game type is serialized compactly, as lightyear’s messages are, so serde’s skip_serializing_if, flatten and untagged don’t round-trip in it.

Applying

Apply a spec through commands, from any system:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Component)] struct Mage;
#[derive(Effect)] struct Burning;
#[derive(Tag)] struct Param;
#[derive(Tag)] #[tag(parent = Param, name = "damage")] struct DamageParam;
/// Sets every mage within reach on fire.
fn ignite(mages: Query<Entity, With<Mage>>, mut commands: Commands) {
    for mage in &mages {
        commands
            .entity(mage)
            .apply_effect(Burning.param(DamageParam, -5.0));
    }
}

apply_effect takes the type itself too: apply_effect(Burning). Or apply on the world, at once, from an exclusive system or a test, with EffectWorld: world.apply_effect(mage, spec).

Applying checks, in order:

  1. There’s an EffectRegistry, and it has the effect: its type was added with add_effect, or its number is one content has. If not, nothing happens but a warning.
  2. The target owns tags. Any actor with an attribute’s Base does. Without OwnedTags, nothing happens but a warning.
  3. The effect’s require holds, and no effect on the target makes it immune. If not, EffectBlocked says why, and nothing else happens.
  4. Active effects it ends end.

Then:

  • An instant effect executes, changing the target’s bases, and chains its on_apply effects.
  • A stacking effect already on the target gains a stack. Stacking covers that.
  • Anything else spawns an active effect. Its duration and period are worked out from the spec. One that would last 0 seconds or less, or not a number, isn’t applied, with a warning. If its ongoing requirement holds, it starts counting, and executes at once if its period says on_apply. EffectApplied is triggered, and its on_apply effects are chained.

The burning mage

This mage burns for 5 every half second, gets wet, and dries off:

use bevy::prelude::*;
use bevy::time::TimeUpdateStrategy;
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(Tag)] struct State;
#[derive(Tag)] #[tag(parent = State, name = "burning")] struct OnFire;
#[derive(Tag)] #[tag(parent = State)] struct Wet;
#[derive(Tag)] struct Param;
#[derive(Tag)] #[tag(parent = Param, name = "damage")] struct DamageParam;
#[derive(Effect)] struct Burning;
fn tags() -> Vec<TagKey> {
    [
        "effect", "effect.affliction", "effect.affliction.burning",
        "immune", "immune.fire", "param", "param.damage",
        "state", "state.burning", "state.wet",
    ]
    .map(TagKey::named)
    .to_vec()
}
fn burning() -> EffectData {
    EffectData::lasting(Key::of::<Burning>(), 4.0)
        .tag("effect.affliction.burning")
        .period(0.5, true)
        .modifier(EffectModifier::new::<Life>(Operation::Flat, Magnitude::param("param.damage")))
        .owner_tag("state.burning")
        .require(!Condition::owner_has("immune.fire"))
        .ongoing(!Condition::owner_has("state.wet"))
}
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
    .add_attribute::<Life>()
    .add_attribute::<MaxLife>()
    .add_effect::<Burning>()
    .insert_resource(MettleContent { tags: tags(), effects: vec![burning()], ..default() })
    // One fixed step, 1/64 of a second, each update.
    .insert_resource(TimeUpdateStrategy::FixedTimesteps(1));
app.finish();
app.cleanup();
app.update();
/// Runs `seconds` of fixed steps.
fn run(app: &mut App, seconds: f32) {
    for _ in 0..(seconds * 64.0) as u32 {
        app.update();
    }
}
let mage = app
    .world_mut()
    .spawn((Base::<Life>::default(), Base::<MaxLife>::default()))
    .id();
let life = |app: &App| app.world().get::<Base<Life>>(mage).unwrap().get();

app.world_mut()
    .apply_effect(mage, Burning.param(DamageParam, -5.0));
assert_eq!(life(&app), 95.0, "once as it's applied");

run(&mut app, 1.0);
assert_eq!(life(&app), 85.0, "and twice a second");

// The burn is an entity of its own.
let burn = app.world().get::<ActiveEffects>(mage).unwrap().iter().next().unwrap();
let active = app.world().get::<ActiveEffect>(burn).unwrap();
assert_eq!(active.remaining(), Some(3.0));
assert!(app.world().get::<OwnedTags>(mage).unwrap().contains(OnFire));

// Wet, the burn is inhibited: no damage, and its time stands still.
app.world_mut().get_mut::<OwnedTags>(mage).unwrap().add(Wet);
run(&mut app, 1.0);
let active = app.world().get::<ActiveEffect>(burn).unwrap();
assert!(active.is_inhibited());
assert_eq!(active.remaining(), Some(3.0));
assert_eq!(life(&app), 85.0);
assert!(!app.world().get::<OwnedTags>(mage).unwrap().contains(OnFire));

// Dry again, it burns on for the three seconds it had left.
app.world_mut().get_mut::<OwnedTags>(mage).unwrap().remove(Wet);
run(&mut app, 3.0);
assert_eq!(life(&app), 55.0);
let left = app.world().get::<ActiveEffects>(mage).map_or(0, |effects| effects.iter().count());
assert_eq!(left, 0, "and it's gone");

The damage changes the mage’s base, so it stays taken after the burn ends.

Snapshots

A fireball takes a second to land. It should hit with its caster’s spell power as it was cast: not more for a buff gained in flight, and not nothing for a caster who died. Take the spec’s snapshot as it’s made, and carry the spec:

Either reads, from the spec’s source, every capture of its effect that’s a snapshot of the source’s, and keeps them in the spec’s snapshot. Applied later, it uses them, not the source as it is then. A spec applied without one takes its snapshot as it’s applied, and so does a capture its source couldn’t give yet (an attribute it doesn’t have). The target’s captures are taken as it’s applied, and live reads are read as they’re needed, either way.

use bevy::prelude::*;
use bevy::time::TimeUpdateStrategy;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Life;
#[derive(Attribute)]
struct SpellPower;

#[derive(Effect)]
#[effect(name = "fireball.hit")]
struct FireballHit;

/// As much damage as the caster has spell power.
fn hit() -> EffectData {
    EffectData::instant(Key::of::<FireballHit>()).modifier(EffectModifier::new::<Life>(
        Operation::Flat,
        Magnitude::source::<SpellPower>().times(-1.0),
    ))
}

let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
  .insert_resource(TimeUpdateStrategy::FixedTimesteps(1))
    .add_attribute::<Life>()
    .add_attribute::<SpellPower>()
    .add_effect::<FireballHit>()
    .insert_resource(MettleContent { effects: vec![hit()], ..default() });
app.update();
let caster = app.world_mut().spawn(Base::<SpellPower>::new(30.0)).id();
let mage = app.world_mut().spawn(Base::<Life>::default()).id();
app.update();

// Cast: the spec carries the caster's spell power.
let fireball = app.world().snapshot(FireballHit.source(caster));
// The caster dies before it lands.
app.world_mut().despawn(caster);

app.world_mut().apply_effect(mage, fireball);
assert_eq!(app.world().get::<Base<Life>>(mage).unwrap().get(), 70.0);

In a behaviour, ActiveAbility::effect_spec makes the spec, and Snapshots takes it. The projectile carries it until it lands:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Effect)] #[effect(name = "fireball.hit")] struct FireballHit;
#[derive(Component, Default, Behaviour)] struct Fireball { target: Option<Entity> }
/// A fireball in flight, and what it hits with.
#[derive(Component)]
struct Projectile {
    target: Entity,
    hit: EffectSpec,
}

fn cast(
    casting: Query<(&ActiveAbility, &Fireball), Added<Fireball>>,
    snapshots: Snapshots,
    mut commands: Commands,
) {
    for (active, fireball) in &casting {
        let Some(target) = fireball.target else { continue };
        let hit = snapshots.take(active.effect_spec::<FireballHit>());
        commands.spawn(Projectile { target, hit });
    }
}

fn land(projectiles: Query<(Entity, &Projectile)>, mut commands: Commands) {
    for (projectile, flying) in &projectiles {
        // Once it reaches its target.
        let hit = flying.hit.clone().with(Causer(projectile));
        commands.entity(flying.target).apply_effect(hit);
        commands.entity(projectile).despawn();
    }
}
App::new().add_systems(Update, (cast, land));

Active effects

A lasting effect on its target is an entity with an ActiveEffect and an EffectOf pointing at the target. The target has ActiveEffects, the list of them, in the order they were applied. These are a Bevy relationship: despawn the target, and its effects go with it.

ActiveEffect tells you how it stands:

  • spec(): what was applied.
  • def() and id(): its definition, and its number, an EffectId.
  • stacks(): how many stacks of it there are.
  • remaining(): the seconds it has left, or None if it’s infinite.
  • is_inhibited(): whether its ongoing requirement doesn’t hold.

While it counts, a lasting effect carries its modifiers as Modifiers ModifierOf the target, like an item would, and the target owns its owner tags. While it’s inhibited, it carries neither, and its time stands still. A periodic effect never carries modifiers: it executes instead.

Ticking

EffectPlugin ticks active effects in the first step of the Mettle schedule, MettleSet::Effects, each fixed step unless you run it elsewhere. Each tick, for each effect:

  1. If its end_if holds, it ends.
  2. If its ongoing requirement stopped holding, it’s inhibited. If it holds again, the effect counts again.
  3. If it’s counting, its period comes round as often as it’s due, and its time runs down.
  4. If its time ran out, it expires: it comes off, unless its stacking says otherwise.

Attributes fold the changes in the step after, MettleSet::Attributes, so they’re published the same tick.

Removing effects

Take effects off by their tags, or one by one, by despawning it:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Effect)]
#[effect(name = "fireball.cooldown")]
struct FireballCooldown;

/// Cleanses every affliction off whoever drinks a potion.
fn cleanse(drinkers: Query<Entity, Added<Drinking>>, mut commands: Commands) {
    let afflictions: Tags = ["effect.affliction"].into_iter().collect();
    for drinker in &drinkers {
        commands.entity(drinker).remove_effects(afflictions.clone());
    }
}

/// Ends fireball's cooldown early, as a reset rune does.
fn reset(
    effects: Query<(Entity, &ActiveEffect)>,
    registry: Res<EffectRegistry>,
    mut commands: Commands,
) {
    let cooldown = registry.id_of::<FireballCooldown>();
    for (effect, active) in &effects {
        if Some(active.id()) == cooldown {
            commands.entity(effect).despawn();
        }
    }
}
#[derive(Component)] struct Drinking;

remove_effects goes on the actor, and EffectWorld has it on the world; an active effect is taken off by despawning it, with commands or on the world. Either way, the effect’s tags and modifiers are taken back, EffectRemoved is triggered, and its on_remove effects are chained.

What’s told

Effects trigger entity events on their target, for cues, sounds and abilities to observe. Each names its effect by number, id, an EffectId; compare it with the registry’s number for a type, registry.id_of::<Burning>(), or look up its name with registry.name(id) for a log:

EventWhen
EffectAppliedA lasting effect was spawned on the target: effect is its entity
EffectStackedAn effect gained or lost a stack: stacks is how many now
EffectBlockedAn effect didn’t apply: reason is Requirement, Effect (another blocks it) or StackLimit
EffectExecutedAn effect changed bases: an instant one applied, or a period came round. changes says what it changed the target’s bases by, source_changes its source’s, and routed what it routed into each route
EffectRemovedAn active effect came off: expired says if it ran out of time

Observe them for every actor, or for one:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Effect)]
struct Burning;

#[derive(Effect)]
struct Ward;

/// Plays a sizzle whenever anything starts burning.
fn sizzle(applied: On<EffectApplied>, registry: Res<EffectRegistry>) {
    if registry.id_of::<Burning>() == Some(applied.id) {
        // Play the sound on `applied.entity`.
    }
}

fn spawn_mage(mut commands: Commands) {
    commands
        .spawn(Base::<Life>::default())
        // Only this mage's ward breaking.
        .observe(|removed: On<EffectRemoved>, registry: Res<EffectRegistry>| {
            if registry.id_of::<Ward>() == Some(removed.id) && !removed.expired {
                // Flash the broken ward.
            }
        });
}
#[derive(Attribute)] #[attribute(base = 100.0)] struct Life;
App::new().add_observer(sizzle).add_systems(Startup, spawn_mage);

Previewing

EffectWorld::preview_effect says what applying a spec as an instant effect would change, without changing it: a list of BaseChanges, each named by number. An ability checks it can pay its cost this way. A tooltip can show what a hit would do.

Pitfalls

  • Removing it is despawning it. Despawn an active effect, or take its ActiveEffect off, and it’s told as removed and its on_remove chained, once commands are applied: at once on a World, which flushes as it despawns. Effects despawned with their target tell nothing: there’s no one left to tell.
  • Commands wait. apply_effect on commands applies when commands are applied, later in the frame. Read the result on the next system, or use EffectWorld in an exclusive system.
  • An actor with no attributes owns no tags. Effects need the target’s OwnedTags. Every Base brings them; on an actor without one, add OwnedTags yourself.

Stacking

Apply burning to the mage twice, and by default the mage has two burns, each with its own time. Often you want something else: one burn that gets stronger, up to a limit, and does something worse once it’s at the limit. That’s stacking.

Stacks of burning

Burning stacks up to three times. A fourth application ignites the mage instead. Its stacking sets a limit of 3, has a stack run out one at a time, and applies ignited on overflow. In data, a stacking can say just those three, and the rest take their defaults.

A stacking effect applied to a target that already has it doesn’t spawn a second active effect. The one there gains a stack, and its amounts count once per stack:

use bevy::prelude::*;
use bevy::time::TimeUpdateStrategy;
use bevy_mettle::prelude::*;
#[derive(Attribute)]
#[attribute(base = 100.0, min = 0.0)]
struct Life;
fn tags() -> Vec<TagKey> {
    ["effect", "effect.affliction", "effect.affliction.burning", "param", "param.damage"]
        .map(TagKey::named)
        .to_vec()
}
fn effects() -> Vec<EffectData> {
    vec![
        EffectData::lasting(Key::of::<Burning>(), 4.0)
            .tag("effect.affliction.burning")
            .period(0.5, false)
            .modifier(EffectModifier::new::<Life>(
                Operation::Flat,
                Magnitude::param("param.damage"),
            ))
            .stacking(Stacking {
                by: StackBy::Target,
                limit: 3,
                reapply: Reapply::Refresh,
                reset_period: false,
                expire: Expire::RemoveOne,
                overflow: vec!["ignited".into()],
            }),
        EffectData::instant("ignited")
            .modifier(EffectModifier::new::<Life>(Operation::Flat, -20.0)),
    ]
}
#[derive(Effect)] struct Burning;
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
    .add_attribute::<Life>()
    .add_effect::<Burning>()
    .insert_resource(MettleContent { tags: tags(), effects: effects(), ..default() })
    .insert_resource(TimeUpdateStrategy::FixedTimesteps(1));
app.finish();
app.cleanup();
app.update();
fn run(app: &mut App, seconds: f32) {
    for _ in 0..(seconds * 64.0) as u32 {
        app.update();
    }
}
let mage = app.world_mut().spawn(Base::<Life>::default()).id();
let life = |app: &App| app.world().get::<Base<Life>>(mage).unwrap().get();
let burn = Burning.param("param.damage", -2.0);
for _ in 0..4 {
    app.world_mut().apply_effect(mage, burn.clone());
}
let effects = app.world().get::<ActiveEffects>(mage).unwrap();
assert_eq!(effects.iter().count(), 1, "one burn");
let burning = effects.iter().next().unwrap();
assert_eq!(app.world().get::<ActiveEffect>(burning).unwrap().stacks(), 3);
assert_eq!(life(&app), 80.0, "the fourth ignited the mage");

run(&mut app, 0.5);
assert_eq!(life(&app), 74.0, "2 damage, three times over");

// Four seconds in, the burn loses a stack and starts its time over.
run(&mut app, 3.5);
let active = app.world().get::<ActiveEffect>(burning).unwrap();
assert_eq!(active.stacks(), 2);
assert_eq!(active.remaining(), Some(4.0));

Settings

A Stacking has a limit, which has to be more than 0. Everything else has a default:

FieldWhat it doesUnless it says
byWhose applications stack together: Target, everyone’s on the target, or Source, each source’s apartTarget
limitThe most stacksrequired
reapplyWhat a new application does to the time left: Refresh starts it over, Keep leaves itRefresh
reset_periodWhether a new application starts the period overfalse
expireWhat running out of time does: ClearAll, RemoveOne, or RefreshClearAll
overflowEffects applied in its place when it’s at its limitnone

With by set to Source, two mages burning the same target each have their own stacks, each its own active effect. Set to Target, they share one.

A new stack

An application that stacks:

  • adds a stack;
  • starts the time over, if reapply is Refresh;
  • starts the period over, if reset_period is set;
  • works the effect’s modifiers out again for the new count;
  • triggers EffectStacked with how many stacks there are now;
  • chains the effect’s on_apply effects.

At the limit, an application adds no stack. It still starts the time and the period over as the settings say. Then it applies the overflow effects, with the same spec, and triggers EffectBlocked with the reason StackLimit. The effect’s own on_apply effects aren’t chained.

How stacks count

Each stack counts the effect’s amounts once more. Three stacks of +10% damage are +30%. Three stacks of burning do three times the damage each period. A Base or an Override is a value, not an amount, so it counts once however many stacks there are.

The active effect keeps the spec it was first applied with. A later application adds a stack, not its own level, params or source: a stronger second burn makes three stacks of the first burn’s damage. Its captures are likewise taken once, as the effect is first applied, unless they’re read live.

Effects in code see the count as stacks on their context, and scale by it themselves.

Running out

When a stacked effect’s time runs out, expire decides:

  • ClearAll takes it off, every stack at once. EffectRemoved says it expired, and its on_remove and on_expire effects are chained.
  • RemoveOne takes one stack off and starts the time over for the rest, telling EffectStacked. The last stack comes off as ClearAll would.
  • Refresh starts the time over and keeps every stack. The effect then stays until something takes it off.

Pitfalls

  • Instant effects don’t stack. They don’t last, so there’s nothing to stack onto. The registry rejects an instant effect with stacking.
  • Without stacking, every application is its own effect. Five applications of an effect without stacking are five active effects, each with its own time.
  • A stack of a stronger spec is still the first one’s. If later applications should be able to replace a weaker effect, give it no stacking, and have the stronger one ends the weaker one’s tag.

Effects in code

Most of an effect is data. Some numbers can’t be: fire damage that grows with the caster’s spell power and shrinks with the target’s fire resistance, or a burn that lasts longer the stronger its caster. Those are code, named in the data. Mettle has two kinds:

  • Executions are formulas run as an effect executes. They return the changes to make to the target’s bases. Unreal calls them execution calculations.
  • Calculations work out one number, a Magnitude of Custom. They can stand in for any magnitude: a modifier’s value, a duration, a period.

Registering code

Code names an execution or a calculation by its type, as it does an effect: derive Execution or Calculation on a struct, its name its type’s in snake_case unless #[execution(name = "..")] or #[calculation(name = "..")] says otherwise. What it runs is a trait on the same type: ExecutionFn::execute returns the changes, CalculationFn::calculate the number, each with &self, so the struct’s fields are what it’s set up with. Register a value of it with add_execution and add_calculation from EffectApp, as attributes and behaviours are; it’s numbered as it’s registered, in the ExecutionRegistry or the CalculationRegistry, and registering its type again replaces it. The derive also declares the attributes it captures, source(..) and target(..). Data names it by that name, a Key<AnyExecution> or a Key<AnyCalculation>; checking the content resolves the name to its number, and rejects a name that isn’t registered:

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(max = MaxResistance)]
struct Resistance;
#[derive(Attribute)]
#[attribute(base = 0.75)]
struct MaxResistance;
#[derive(Attribute)]
#[attribute(base = 10.0)]
struct SpellPower;
#[derive(Tag)]
struct Param;
#[derive(Tag)]
#[tag(parent = Param, name = "damage")]
struct DamageParam; // "param.damage", set by the code applying it

/// The spec's damage, more with the caster's spell power, less with the
/// target's fire resistance, scaled by the stacks of it.
#[derive(Execution)]
#[execution(source(SpellPower), target(Resistance, tagged("damage.elemental.fire")))]
struct FireDamage; // "fire_damage"

impl ExecutionFn for FireDamage {
    fn execute(&self, effect: &ExecutionContext) -> Vec<BaseChange> {
        let damage = effect.param(DamageParam).unwrap_or_default();
        let power = effect.captured::<SpellPower>(Side::Source).unwrap_or_default();
        let resisted = effect.captured::<Resistance>(Side::Target).unwrap_or_default();
        let dealt = damage * (1.0 + power / 100.0) * (1.0 - resisted) * effect.stacks as f32;
        vec![BaseChange::of::<Life>(Operation::Flat, -dealt)]
    }
}

/// Two seconds, and one more for every `per` spell power.
#[derive(Calculation)]
#[calculation(source(SpellPower))]
struct BurnTime { per: f32 } // "burn_time"

impl CalculationFn for BurnTime {
    fn calculate(&self, effect: &ExecutionContext) -> f32 {
        let power = effect.captured::<SpellPower>(Side::Source).unwrap_or_default();
        2.0 + power / self.per
    }
}

fn code(app: &mut App) {
    app.add_execution(FireDamage)
        .add_calculation(BurnTime { per: 25.0 });
}

let mut app = App::new();
code(&mut app);
let executions = app.world().resource::<ExecutionRegistry>();
let id = executions.id_of::<FireDamage>().unwrap();
assert_eq!(executions.id("fire_damage"), Some(id));
assert_eq!(executions.name(id), Some("fire_damage"));

The effects name them by name: an effect lists its executions, and a Custom magnitude names its calculation, as the next example’s effects do.

The fireball hits

A caster with 25 spell power hits a mage with 50% fire resistance for 20:

use bevy::prelude::*;
use bevy::time::TimeUpdateStrategy;
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(max = MaxResistance)]
struct Resistance;
#[derive(Attribute)]
#[attribute(base = 0.75)]
struct MaxResistance;
#[derive(Attribute)]
#[attribute(base = 10.0)]
struct SpellPower;
#[derive(Tag)] struct Param;
#[derive(Tag)] #[tag(parent = Param, name = "damage")] struct DamageParam;
#[derive(Execution)]
#[execution(source(SpellPower), target(Resistance, tagged("damage.elemental.fire")))]
struct FireDamage;
#[derive(Calculation)] #[calculation(source(SpellPower))] struct BurnTime { per: f32 }
#[derive(Effect)] #[effect(name = "fireball.hit")] struct FireballHit;
#[derive(Effect)] struct Burning;
fn code(app: &mut App) {
    app.add_execution(FireDamage)
        .add_calculation(BurnTime { per: 25.0 })
        .add_effect::<FireballHit>()
        .add_effect::<Burning>();
}
fn power(effect: &ExecutionContext) -> f32 {
    effect.captured::<SpellPower>(Side::Source).unwrap_or_default()
}
impl ExecutionFn for FireDamage {
    fn execute(&self, effect: &ExecutionContext) -> Vec<BaseChange> {
        let damage = effect.param(DamageParam).unwrap_or_default();
        let resisted = effect.captured::<Resistance>(Side::Target).unwrap_or_default();
        let dealt = damage * (1.0 + power(effect) / 100.0) * (1.0 - resisted) * effect.stacks as f32;
        vec![BaseChange::of::<Life>(Operation::Flat, -dealt)]
    }
}
impl CalculationFn for BurnTime {
    fn calculate(&self, effect: &ExecutionContext) -> f32 {
        2.0 + power(effect) / self.per
    }
}
fn tags() -> Vec<TagKey> {
    [
        "damage", "damage.elemental", "damage.elemental.fire",
        "effect", "effect.affliction", "effect.affliction.burning",
        "param", "param.damage",
    ]
    .into_iter()
    .map(TagKey::named)
    .collect()
}
fn effects() -> Vec<EffectData> {
    vec![
        EffectData::instant(Key::of::<FireballHit>())
            .tag("damage.elemental.fire")
            .execution(Key::of::<FireDamage>()),
        EffectData::lasting(Key::of::<Burning>(), Magnitude::Custom(Key::of::<BurnTime>()))
            .tag("effect.affliction.burning")
            .period(0.5, false)
            .execution(Key::of::<FireDamage>()),
    ]
}
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
    .add_attribute::<Life>()
    .add_attribute::<MaxLife>()
    .add_attribute::<Resistance>()
    .add_attribute::<MaxResistance>()
    .add_attribute::<SpellPower>()
    .insert_resource(TimeUpdateStrategy::FixedTimesteps(1));
code(&mut app);
app.insert_resource(MettleContent { tags: tags(), effects: effects(), ..default() });
app.finish();
app.cleanup();
app.update();
let caster = app.world_mut().spawn(Base::<SpellPower>::new(25.0)).id();
let mage = app
    .world_mut()
    .spawn((
        Base::<Life>::default(),
        Base::<MaxLife>::default(),
        Base::<Resistance>::new(0.5),
        Base::<MaxResistance>::default(),
    ))
    .id();
// One fixed step, for their attributes to be worked out.
app.update();

let hit = FireballHit.param("param.damage", 20.0).source(caster);
app.world_mut().apply_effect(mage, hit);
// 20, 25% more, half resisted.
assert_eq!(app.world().get::<Base<Life>>(mage).unwrap().get(), 87.5);

let burn = Burning.param("param.damage", 2.0).source(caster);
app.world_mut().apply_effect(mage, burn);
let effect = app.world().get::<ActiveEffects>(mage).unwrap().iter().next().unwrap();
let remaining = app.world().get::<ActiveEffect>(effect).unwrap().remaining();
assert_eq!(remaining, Some(3.0), "two seconds, and one for 25 spell power");

What code sees

An execution or a calculation gets an ExecutionContext: the world, read only, and what’s being applied.

What it is
worldThe world, read only: the raw escape hatch, for what a capture can’t read
targetWho the effect is applied to
defThe effect’s checked definition: its number, name and tags
effect()The effect’s number, an EffectId
specThe EffectSpec applied
stacksHow many stacks of it there are: 1 unless it stacks
level(), param(..)The spec’s level, and a param it set
context(), source()What the spec carries, and its source: see Context
get::<C>()What the game put on the context of type C, a ContextData
captured::<A>(from)What it declared it captures of the source’s or the target’s A
owned(entity)What an entity owns: its OwnedTags
breakdown::<A>(entity, read)An entity’s attribute’s modifiers, summed but not applied: read now, never snapshotted or predicted

Reading in context explains reads in context.

Captures

What an execution, a calculation or a router reads of the source’s and the target’s attributes, it declares on its derive, as Unreal’s execution declares its capture definitions:

use bevy_mettle::prelude::*;
#[derive(Attribute)] struct SpellPower;
#[derive(Attribute)] struct Armour;
#[derive(Execution)]
#[execution(source(SpellPower, bonus), target(Armour, live, tagged("damage.fire")))]
struct FireDamage;
assert_eq!(FireDamage::captures(), vec![
    Magnitude::source::<SpellPower>().bonus(),
    Magnitude::target::<Armour>().live().tagged("damage.fire"),
]);

Each is an attribute’s type, then the options of an AttributeRead: base or bonus for which of its values, live to read it live, and tagged(..) for the tags it’s read in. An effect running the code captures them with its own magnitudes’ captures, as a snapshot or live. The code reads them with effect.captured::<A>(Side::Source), as Unreal’s AttemptCalculateCapturedAttributeMagnitude does: None for one it didn’t declare.

Declared, a capture is the effect’s like any other. A snapshot of the source’s goes in the spec’s snapshot, so a fireball landing late hits with its caster’s spell power as it was cast. It goes with the effect’s replica to clients. Its tags are checked with the content. Captures are how effect code reads attributes. breakdown reads the world as it is, captured by nothing; so does world, for what no capture can name, such as a third actor’s.

A periodic burn that reads its target’s resistance live deals more the moment the resistance goes:

use bevy::prelude::*;
use bevy::time::TimeUpdateStrategy;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Life;
#[derive(Attribute)]
struct Resistance;

/// 10 a second, less what the target resists as it ticks.
#[derive(Execution)]
#[execution(target(Resistance, live))]
struct Sear;

impl ExecutionFn for Sear {
    fn execute(&self, effect: &ExecutionContext) -> Vec<BaseChange> {
        let resisted = effect.captured::<Resistance>(Side::Target).unwrap_or_default();
        vec![BaseChange::of::<Life>(Operation::Flat, -10.0 * (1.0 - resisted))]
    }
}

#[derive(Effect)]
struct Searing;

let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
  // One fixed step, 1/64 of a second, each update.
  .insert_resource(TimeUpdateStrategy::FixedTimesteps(1))
    .add_attribute::<Life>()
    .add_attribute::<Resistance>()
    .add_execution(Sear)
    .add_effect::<Searing>()
    .insert_resource(MettleContent {
        effects: vec![EffectData::lasting(Key::of::<Searing>(), 10.0).period(1.0, true).execution(Key::of::<Sear>())],
        ..default()
    });
app.update();
let mage = app
    .world_mut()
    .spawn((Base::<Life>::default(), Base::<Resistance>::new(0.5)))
    .id();
app.update();
let life = |app: &App| app.world().get::<Base<Life>>(mage).unwrap().get();

app.world_mut().apply_effect(mage, Searing);
assert_eq!(life(&app), 95.0, "half resisted");

// The resistance is stripped: the next tick reads it as it is.
app.world_mut().get_mut::<Base<Resistance>>(mage).unwrap().set(0.0);
for _ in 0..64 {
    app.update();
}
assert_eq!(life(&app), 85.0, "none resisted");

Without live, the target’s resistance would be captured as the burn applied, and every tick would deal 5.

Executions

An execution runs whenever its effect executes: as an instant effect applies, or as a periodic effect’s period comes round. It runs after the effect’s own modifiers have been worked out, and returns a list of BaseChanges. BaseChange::of::<Life>(op, value) changes the target’s life’s base as an executing modifier would: Flat adds, Additive scales, Override sets. The base is then rounded and kept within its limits.

A change is to the target’s base, unless .on_source() says it’s to the source’s: life stolen back. An execution can also route into a route it declares, #[execution(routes(IncomingDamage))], with BaseChange::route::<IncomingDamage>(damage), for its router to route.

A change named by type is numbered as it’s applied. One to an attribute or a route the app hasn’t added is warned of once, and dropped; so is one into a route the execution doesn’t declare.

Executions don’t scale with stacks by themselves, since they work out their own numbers. Multiply by stacks where the effect should count once per stack, as FireDamage does.

A breakdown lets an execution combine a caster’s modifiers with a target’s. Damage increased by 30% on the caster, and damage taken increased by 20% on the target, add up to 50% more, as they would if they were one attribute’s:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Life;
#[derive(Attribute)] #[attribute(base = 10.0)] struct Damage;
#[derive(Attribute)] #[attribute(base = 0.0)] struct DamageTaken;
#[derive(Tag)] #[tag(name = "damage")] struct DamageTag;
#[derive(Tag)] #[tag(parent = DamageTag)] struct Elemental;
#[derive(Tag)] #[tag(parent = Elemental)] struct Fire;
#[derive(Execution)] struct Strike;
impl ExecutionFn for Strike {
    fn execute(&self, effect: &ExecutionContext) -> Vec<BaseChange> {
        let read = AttributeContext::new().tag(Fire);
        let (Some(caster), Some(taken)) = (
            effect.source().and_then(|caster| effect.breakdown::<Damage>(caster, &read)),
            effect.breakdown::<DamageTaken>(effect.target, &read),
        ) else {
            return Vec::new();
        };
        let mut hit = caster;
        hit.additive += taken.additive;
        vec![BaseChange::of::<Life>(Operation::Flat, -hit.total())]
    }
}
App::new().add_execution(Strike);

Routes

Every hit takes the shield’s share first, then life’s. Written into each execution, that’s the same code many times over. A route puts it in one place: hits route into incoming_damage, and its router decides what that does to the attributes actors have.

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(min = 0.0)]
struct Shield;

/// Damage dealt, before the shield takes its share: the target's shield
/// read as each hit's routed.
#[derive(Route)]
#[route(target(Shield, live))]
struct IncomingDamage;

#[derive(Effect)]
struct Hit;

/// The shield takes what it can and life the rest; the attacker steals back a
/// fifth of what life lost.
impl RouteFn for IncomingDamage {
    fn route(&self, effect: &ExecutionContext, damage: f32) -> Vec<BaseChange> {
        let held = effect.captured::<Shield>(Side::Target).unwrap_or_default();
        let absorbed = damage.min(held);
        let taken = damage - absorbed;
        vec![
            BaseChange::of::<Shield>(Operation::Flat, -absorbed),
            BaseChange::of::<Life>(Operation::Flat, -taken),
            BaseChange::of::<Life>(Operation::Flat, taken / 5.0).on_source(),
        ]
    }
}

use bevy::time::TimeUpdateStrategy;
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
  // One fixed step, where mettle runs, each update.
  .insert_resource(TimeUpdateStrategy::FixedTimesteps(1))
    .add_attribute::<Life>()
    .add_attribute::<MaxLife>()
    .add_attribute::<Shield>()
    .add_route(IncomingDamage)
    .add_effect::<Hit>()
    .insert_resource(MettleContent {
        effects: vec![EffectData::instant(Key::of::<Hit>()).modifier(EffectModifier::route::<IncomingDamage>(40.0))],
        ..default()
    });
app.update();
let base = |app: &App, entity| app.world().get::<Base<Life>>(entity).unwrap().get();
let mage = app
    .world_mut()
    .spawn((Base::<Life>::default(), Base::<MaxLife>::default(), Base::<Shield>::new(30.0)))
    .id();
let attacker = app
    .world_mut()
    .spawn((Base::<Life>::new(50.0), Base::<MaxLife>::default()))
    .id();
app.update();

app.world_mut().apply_effect(mage, Hit.source(attacker));
assert_eq!(base(&app, mage), 90.0, "30 to the shield, 10 to life");
assert_eq!(base(&app, attacker), 52.0, "a fifth stolen back");

A route’s router is RouteFn::route on its type, and a value of it is registered with add_route, numbered in the RouteRegistry. In data, a modifier routes into a route by naming it, "incoming_damage", a Key<AnyRoute>, in place of an attribute. An execution routes into the routes it declares on its derive, by type:

use bevy_mettle::prelude::*;
#[derive(Attribute)] struct SpellPower;
#[derive(Attribute)] struct Resistance;
#[derive(Route)] struct IncomingDamage;
#[derive(Execution)]
#[execution(source(SpellPower), target(Resistance), routes(IncomingDamage))]
struct FireDamage;

impl ExecutionFn for FireDamage {
    fn execute(&self, effect: &ExecutionContext) -> Vec<BaseChange> {
        let power = effect.captured::<SpellPower>(Side::Source).unwrap_or_default();
        let resisted = effect.captured::<Resistance>(Side::Target).unwrap_or_default();
        vec![BaseChange::route::<IncomingDamage>(30.0 * (1.0 + power / 100.0) * (1.0 - resisted))]
    }
}
assert_eq!(FireDamage::routes(), vec![Key::of::<IncomingDamage>()]);

A router gets all that an effect routed into its route, summed: its modifiers and executions both. What it reads it captures, on #[route(..)] as an execution does: an effect captures them for each route it routes into, those its modifiers name and its executions declare. A route whose captures can’t be, one of an attribute the app hasn’t added, say, rejects only the effects routing into it. An execution routing into a route it doesn’t declare has nothing captured for its router: the change is dropped, warned of once. Declaring a route the app hasn’t added rejects the effects running it. Its changes come after the effect’s own, and are rounded and kept within limits like any other.

  • It’s the same everywhere. Routing happens as the effect works out its changes, so a preview sees it, and an ability’s cost paid through a route, “blood magic” paying mana costs in life, is checked as routed. A client predicting an ability routes its cost as the server will, and undoes it if the server refuses.
  • Routing is single-pass. What a router routes into a route is dropped, with a warning. One router can do the whole pipeline.
  • It’s routed, not held. A route takes flat amounts, from effects that execute: instant, or periodic. It has no base, limits or rounding, nothing holds it, and nothing reads it. What it’s routed into is replicated as ever.
  • A modifier changes one or the other. A modifier names an attribute or a route, never both, and the registry says so.

EffectExecuted says what came of it: changes to the target’s bases after routing and limits, source_changes to its source’s, and routed, what it routed into each route. A combat log reads “40 dealt, 30 absorbed” from them.

Calculations

A calculation is a Custom magnitude. Anywhere a Magnitude goes, so can Magnitude::Custom("burn_time".into()), naming the calculation by name. In data, it’s written the same: the variant, with the calculation’s name.

When a calculation runs depends on what it stands for:

  • A duration or a period is worked out once, as the effect applies.
  • An executing modifier’s value is worked out each time the effect executes.
  • A lasting modifier’s value is worked out as the effect starts counting, and again whenever its stacks change or it’s reloaded.

What a calculation declares it captures is captured with the effect’s other captures: a snapshot is kept, and a live read followed, so a lasting modifier’s calculation is worked out again as what it reads live changes. What it reads otherwise, with breakdown or world, is read as it’s worked out.

Pitfalls

  • An execution declares its routes. What it routes into a route it doesn’t declare, routes(..) on its derive, is dropped: its router’s captures weren’t taken.
  • A router can’t apply effects. Like executions, it reads the world and returns changes. To apply something as a hit lands, observe EffectExecuted.
  • Executions need something to run on. An effect with neither Instant duration nor a period never executes, so the registry rejects executions on it. Use a modifier, or give the effect a period.
  • Code has no side effects. It reads the world and returns numbers. To spawn something or play a sound as an effect executes, observe EffectExecuted.
  • Data names code by string. The types are for code; content still says "fire_damage". A name that doesn’t match a registered type’s is caught as content is checked.
  • Register code before content. Content is checked against the code the app has: content naming code registered later is rejected. Registered code stays, for every reload.
  • Previews run code too. EffectWorld::preview_effect, which abilities use to check a cost, runs executions and calculations without applying anything.

Defining abilities

An ability is something an actor can do: the mage’s fireball. It’s granted to the actor, activated, paid for, cooled down, and ended. As with effects, the rules are data: what the ability is, what it costs, and what blocks it. What it actually does is code, a behaviour: a component of yours that your systems run while the ability is active.

The fireball

An ability is content, an AbilityData, named by a string as effects are. Here is the fireball, built with its builders:

use bevy_mettle::prelude::*;
let fireball = AbilityData::new("fireball", "cast_fireball")
    .tag("ability.spell.fire")
    .cost("fireball.cost")
    .cooldown("fireball.cooldown")
    .require(Condition::all([
        !Condition::owner_has("state.silenced"),
        !Condition::owner_has("state.stunned"),
    ]))
    .ends("ability.channel")
    .blocks("ability.spell")
    .owner_tag("state.casting")
    .trigger(Trigger::Event(TagKey::named("event.parried")));
assert_eq!(fireball.commit, Commit::OnActivate);

Only name and behaviour are needed. Everything else has a default: no tags, no cost, no cooldown, nothing blocking it, and Commit::OnActivate. AbilityData is a serde type, so the same abilities can come from files in any serde format: a struct with the fields below, its tags, effects and behaviour written as their names, strings, and fields left out taking their defaults.

Naming abilities in code

Content names abilities by string, a Key<AnyAbility>. Code names an ability by its type, as it does an effect or a tag. Derive Ability on a unit struct, and add it to the app:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Ability)]
struct Fireball;                   // "fireball"

#[derive(Ability)]
#[ability(name = "fireball.charged")]
struct ChargedFireball;            // "fireball.charged"

let mut app = App::new();
app.add_plugins(MettlePlugins)
    .add_ability::<Fireball>()
    .add_ability::<ChargedFireball>();

Its name is its type’s in snake_case, unless name says otherwise. Code then grants and activates it by the type: grant_ability(Fireball), or grant_ability(Fireball.level(2.0)) at a level, and activate_ability(Fireball), as Granting and activating shows. A misspelled type doesn’t compile.

add_ability is a promise that content defines the ability: content that doesn’t is rejected with “code names ability fireball, which content doesn’t define”. Abilities only content names, such as those an effect grants, needn’t be types.

The fields

FieldWhat it does
tagsWhat the ability is. Blocking, ending and activating by tag match on these.
behaviourThe component in code that runs it, added with add_behaviour.
costAn instant effect it pays as it commits. It can’t activate if paying would take an attribute below 0.
cooldownA lasting effect applied as it commits. It can’t activate while its owner owns one of that effect’s owner tags.
requireA condition that has to hold for it to activate.
end_ifA condition that interrupts it: while it’s active, it’s cancelled as soon as this holds. See Granting and activating.
endsActive abilities tagged any of these are cancelled as it activates.
blocksOther abilities tagged any of these can’t activate while it’s active.
owner_tagsTags its owner holds while it’s active.
cuesCues held on its owner while it’s active, such as a cast’s glow. See Cues.
triggersWhat activates it besides being asked. See Grants and triggers.
commitWhen its cost and cooldown are applied: OnActivate or Manual.
netHow it runs over a network: Predicted (the default), ServerOnly, ServerInitiated or LocalOnly. See Prediction.

A tag in ends or blocks matches the tags under it too. .blocks("ability.spell") keeps every spell from activating, fire or not.

require is weighed in the ability’s own tags and its owner’s. ReadTagged asks the ability’s tags. OwnerHas, TargetHas and SourceHas all ask the owner, since an ability is its own source and target. It has no facts, so a fact term never holds. An ability kept from activating while its owner owns a tag requires its absence: .require(!Condition::owner_has("state.stunned")).

Behaviours

A behaviour is a component with #[derive(Behaviour)], added to the app with add_behaviour. Data names it by its name, a Key<AnyBehaviour>: its type’s in snake_case, unless it says. Adding it gives it its number in the BehaviourRegistry, as attributes get theirs.

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Component, Default, Behaviour)]
struct CastFireball {
    timer: f32,
}

let mut app = App::new();
app.add_plugins(MettlePlugins)
    .add_behaviour::<CastFireball>();
let behaviours = app.world().resource::<BehaviourRegistry>();
let id = behaviours.id_of::<CastFireball>().unwrap();
assert_eq!(behaviours.id("cast_fireball"), Some(id));
assert_eq!(behaviours.name(id), Some("cast_fireball"));

When the fireball activates, CastFireball::default() is put on the ability’s entity. When it ends, it’s taken off. Your systems find it there, next to ActiveAbility. Granting and activating shows such a system.

The component has to be Default, since mettle makes a fresh one each time. Its fields are the cast’s run state, such as timer. Several abilities can share one behaviour: a cast_projectile behaviour can run the fireball and the frostbolt alike, telling them apart by the ability.

Its settings go on #[behaviour(..)]:

SettingMeaningUnless it says
name = ".."The name data names it byits type’s, in snake_case
input = TypeWhat its abilities are activated with: an aim, a targetnone

What a player chooses, such as where the fireball is aimed, isn’t run state: it’s the behaviour’s input. Input and targets covers it.

Cost and cooldown

The fireball’s cost and cooldown are effects:

#[derive(Attribute)]
#[attribute(base = 100.0, min = 0.0)]
struct Mana;
use bevy_mettle::prelude::*;
let cost = EffectData::instant("fireball.cost")
    .modifier(EffectModifier::new::<Mana>(Operation::Flat, -20.0));
let cooldown = EffectData::lasting("fireball.cooldown", 2.0).owner_tag("cooldown.fireball");
let _ = (cost, cooldown);
  • The cost has to be instant. Before the ability activates, its changes are worked out one after another on the owner’s values. If any attribute would end below 0, the ability fails with Failure::Cost. A mage with 15 mana can’t cast a fireball that costs 20.
  • The cooldown has to last and grant at least one tag. While the owner owns any tag it grants, the ability fails with Failure::Cooldown. The cooldown’s tag is how mettle knows it’s still cooling down, so a cooldown that grants nothing would never cool down at all.

Both are applied to the owner at the ability’s level, with the owner as their source, under the activation (see Prediction). A cost or cooldown scaled by a curve reads it at the ability’s level.

When it commits

Committing is paying the cost and starting the cooldown.

  • Commit::OnActivate, the default, commits as the ability activates.
  • Commit::Manual leaves it to the behaviour. A spell with a cast time commits when the cast finishes, with commit_ability. If the owner can’t pay then, the ability is cancelled. Until it commits, the cooldown hasn’t started, so cancelling a cast costs nothing.

The cost and the cooldown commit apart too, with commit_cost and commit_cooldown: a charged shot starts its cooldown as it’s pressed and pays as it’s let go. Each commits once an activation; committing again does nothing.

Checking them

Abilities reach mettle in MettleContent, with the effects and tags they name. As it’s inserted, mettle checks every ability against the tags, the effects and the behaviours there are, and every grant (see Grants and triggers), and turns them into the AbilityRegistry. It reports everything wrong at once:

  • an ability defined twice;
  • an ability code names with add_ability that content doesn’t define;
  • a tag, effect or behaviour named that doesn’t exist;
  • a cost that isn’t instant;
  • a cooldown that doesn’t last, or grants no tag;
  • a grant of an effect that doesn’t exist or doesn’t last, or of an ability that doesn’t exist.

Abilities are checked once the effects they name are, so an ability’s errors show once its effects’ are fixed.

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

#[derive(Component, Default, Behaviour)]
struct CastFireball;

#[derive(Ability)]
struct Fireball;

let mut app = App::new();
app.add_plugins(MettlePlugins)
    .add_attribute::<Mana>()
    .add_behaviour::<CastFireball>()
    .add_ability::<Fireball>();

let tags: Vec<TagKey> = [
    "state", "state.stunned", "state.casting",
    "cooldown", "cooldown.fireball",
    "ability", "ability.spell", "ability.spell.fire",
]
.into_iter()
.map(TagKey::named)
.collect();

let effects = vec![
    EffectData::instant("fireball.cost")
        .modifier(EffectModifier::new::<Mana>(Operation::Flat, -20.0)),
    EffectData::lasting("fireball.cooldown", 2.0).owner_tag("cooldown.fireball"),
];

let fireball = AbilityData::new(Key::of::<Fireball>(), Key::of::<CastFireball>())
    .tag("ability.spell.fire")
    .cost("fireball.cost")
    .cooldown("fireball.cooldown")
    .require(!Condition::owner_has("state.stunned"))
    .owner_tag("state.casting");

let content = MettleContent {
    tags,
    effects,
    abilities: vec![fireball],
    ..default()
};
content.check(app.world()).unwrap();

// A typo in the behaviour, and a cooldown that's instant:
let mut broken = content.clone();
broken
    .abilities
    .push(AbilityData::new("firebal", "cast_firebal").cooldown("fireball.cost"));
let errors = broken.check(app.world()).unwrap_err();
assert_eq!(errors.0.len(), 2);

// Content without the fireball code names:
let mut missing = content.clone();
missing.abilities.clear();
let errors = missing.check(app.world()).unwrap_err();
assert_eq!(
    errors.to_string(),
    "code names ability `fireball`, which content doesn't define"
);

app.insert_resource(content);
app.update();

// Checked, the fireball has its number.
let registry = app.world().resource::<AbilityRegistry>();
let id = registry.id_of::<Fireball>().unwrap();
assert_eq!(registry.id("fireball"), Some(id));
assert_eq!(registry.name(id), Some("fireball"));

As content is checked, each ability is numbered: an AbilityId, in order of name, as effects are. Granted abilities, events and the network go by the number; the name is for content, debug output and what a client shows. AbilityRegistry goes between them, as every registry does: get(id), id("fireball"), id_of::<Fireball>(), name(id) and iter().

Change the content later, a hot reload, and every granted ability takes its new definition. Abilities keep their numbers, and new ones are numbered after them. One whose ability is gone is taken away from its actor.

Mettle doesn’t load anything. Your game reads its files and hands over the content: see Loading data.

Pitfalls

  • Behaviours first. Content is checked against the behaviours the app added, so add them all before you insert it. The same goes for the abilities and effects code names with add_ability and add_effect.
  • A cooldown is a tag. Two abilities whose cooldowns grant the same tag share a cooldown. That’s useful for a shared “global cooldown”, and a bug otherwise.

Granting and activating

An actor can only use abilities it’s been granted. Granting gives it an entity for the ability. Activating runs it. Your behaviour system does the work and ends it.

Granting

Code names an ability by its type, #[derive(Ability)] struct Fireball;, added with app.add_ability::<Fireball>(): see Defining abilities. Grant the fireball to the mage:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Ability)]
struct Fireball;

fn spawn_mage(mut commands: Commands) {
    commands
        .spawn((Base::<Life>::default(), Base::<Mana>::default()))
        .grant_ability(Fireball);
}
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Life;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Mana;

Granting takes an AbilitySpec: which ability, and at what level. The ability type is its own spec, at level 1, so grant_ability(Fireball) grants it at level 1 and grant_ability(Fireball.level(2.0)) at level 2.

Activating takes an Ability type: activate_ability(Fireball). Code that doesn’t know the type, such as a hotbar listing whatever the actor was granted, activates the granted ability’s entity: activate(ability).

The granted ability is an entity of its own, with a GrantedAbility and an AbilityOf pointing at the mage. The mage gets Abilities, the list of what it’s been granted. Despawning the mage despawns its abilities.

GrantedAbility tells you which ability it is (id(), its number, and def()), its level (level()), and whether this activation has paid its cost (is_cost_paid()), started its cooldown (is_cooldown_started()), or both (is_committed()). The level is what the cost’s and cooldown’s curves are read at. The effect that granted it, if one did, is its GrantedBy.

Granting the same ability twice gives the actor two entities. Activating by type or number uses the first.

Activating

activate_ability(Fireball) on the mage activates its first granted fireball. An ability whose behaviour takes input, such as an aim, is activated with it: activate_ability_with::<CastFireball>(Fireball, Aim(at)). Input and targets covers that. Mettle checks, in this order:

  1. Granted. The mage has a fireball, and it isn’t active already. Otherwise Failure::NotGranted or Failure::Active.
  2. Not cooling down. The mage owns none of the cooldown effect’s owner tags. Otherwise Failure::Cooldown.
  3. Affordable. Paying the cost leaves no attribute below 0. Otherwise Failure::Cost.
  4. Not blocked. No other active ability blocks the fireball’s tags. Otherwise Failure::Blocked.
  5. Required. require holds. Otherwise Failure::Requirement.
  6. Not interrupted. end_if doesn’t hold. Otherwise Failure::Interrupted.

Then it activates:

  • with Commit::OnActivate, its cost and cooldown are applied first, so its behaviour starts paid for;
  • active abilities it ends are cancelled;
  • the mage owns its owner_tags;
  • ActiveAbility is put on the ability’s entity, with its behaviour’s component;
  • AbilityActivated is triggered on the mage.

If a check fails, nothing happens but AbilityFailed, triggered on the mage with the reason.

Here’s the whole round, run on the world directly:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Tag)] struct State;
#[derive(Tag)] #[tag(parent = State)] struct Casting;
#[derive(Tag)] struct Cooldown;
#[derive(Tag)] #[tag(parent = Cooldown, name = "fireball")] struct FireballCooldown;
#[derive(Attribute)]
#[attribute(base = 100.0, min = 0.0)]
struct Mana;
#[derive(Component, Default, Behaviour)]
struct CastFireball;
#[derive(Ability)] struct Fireball;
use core::time::Duration;
use bevy::time::TimeUpdateStrategy;
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
    .insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_millis(100)))
    .add_attribute::<Mana>()
    .add_behaviour::<CastFireball>()
    .add_ability::<Fireball>();
let fireball = AbilityData::new(Key::of::<Fireball>(), Key::of::<CastFireball>())
    .cost("fireball.cost")
    .cooldown("fireball.cooldown")
    .owner_tag("state.casting");
app.insert_resource(MettleContent {
    tags: ["state", "state.casting", "cooldown", "cooldown.fireball"].map(TagKey::named).to_vec(),
    effects: vec![
        EffectData::instant("fireball.cost").modifier(EffectModifier::new::<Mana>(Operation::Flat, -20.0)),
        EffectData::lasting("fireball.cooldown", 2.0).owner_tag("cooldown.fireball"),
    ],
    abilities: vec![fireball],
    ..default()
});
let mage = app.world_mut().spawn(Base::<Mana>::default()).id();
// A tick, so its attributes are worked out.
app.update();
app.update();
let world = app.world_mut();

// Not granted yet.
assert_eq!(world.activate_ability(mage, Fireball), Err(Failure::NotGranted));

world.grant_ability(mage, Fireball);
let ability = world.activate_ability(mage, Fireball).unwrap().ability();

// Paid, casting, cooling down, and running its behaviour.
assert_eq!(world.get::<Base<Mana>>(mage).unwrap().get(), 80.0);
let owned = world.get::<OwnedTags>(mage).unwrap();
assert!(owned.contains(Casting));
assert!(owned.contains(FireballCooldown));
assert!(world.get::<CastFireball>(ability).is_some());

// Once is enough.
assert_eq!(world.activate_ability(mage, Fireball), Err(Failure::Active));

// Ended: no longer casting, but still cooling down.
world.end_ability(ability);
assert!(world.get::<CastFireball>(ability).is_none());
let owned = world.get::<OwnedTags>(mage).unwrap();
assert!(!owned.contains(Casting));
assert_eq!(world.activate_ability(mage, Fireball), Err(Failure::Cooldown));

// Two and a half seconds later, the cooldown has run out.
for _ in 0..25 {
    app.update();
}
assert!(app.world_mut().activate_ability(mage, Fireball).is_ok());

Running the behaviour

Your system finds the ability by its behaviour’s component, and ends it when it’s done. The fireball takes half a second to cast:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Component, Default, Behaviour)]
struct CastFireball {
    timer: f32,
}

fn cast_fireball(
    mut casting: Query<(Entity, &ActiveAbility, &mut CastFireball)>,
    time: Res<Time>,
    mut commands: Commands,
) {
    for (ability, active, mut cast) in &mut casting {
        cast.timer += time.delta_secs();
        if cast.timer > 0.5 {
            // Spawn the projectile from `active.actor` here.
            commands.entity(ability).end_ability();
        }
    }
}

ActiveAbility says which ability it is, who it’s active for (actor), at what level, under which activation, and the gameplay event that triggered it, if one did.

A behaviour applies effects through it, with effect_spec, Unreal’s MakeOutgoingGameplayEffectSpec. The spec it makes is from the actor, struck by it, at the ability’s level, under this activation, its context told the ability, an AbilityRef. A projectile says it struck instead, with .with(Causer(..)):

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Effect)] #[effect(name = "fireball.hit")] struct FireballHit;
#[derive(Component, Default, Behaviour)]
struct Fireball {
    target: Option<Entity>,
    projectile: Option<Entity>,
}

fn hit(fireballs: Query<(&ActiveAbility, &Fireball)>, mut commands: Commands) {
    for (active, fireball) in &fireballs {
        if let (Some(target), Some(projectile)) = (fireball.target, fireball.projectile) {
            commands
                .entity(target)
                .apply_effect(active.effect_spec::<FireballHit>().with(Causer(projectile)));
        }
    }
}

It takes the effect by type, and the builders set more after: another level, params, what struck. Apply what an ability does this way, not with a spec of your own. Under the activation, a client’s prediction of it is taken back if the server rejects the activation (Prediction); one applied without it stays. The ability’s cost and cooldown are applied the same way.

A projectile that lands later should hit with its caster’s attributes as it was cast. Take the spec’s snapshot as it’s made, snapshots.take(spec), and carry it: see Snapshots.

Add it like any other system:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Component, Default, Behaviour)] struct CastFireball;
fn cast_fireball() {}
fn main() {
    App::new()
        .add_plugins((DefaultPlugins, MettlePlugins))
        .add_behaviour::<CastFireball>()
        .add_systems(Update, cast_fireball)
        .run();
}

Committing, ending, cancelling, revoking

These are commands on the ability’s entity:

CommandWhat it does
commit_ability()Pays the cost and starts the cooldown, for a Commit::Manual ability. One that can’t pay is cancelled.
commit_cost()Pays the cost alone. One that can’t is cancelled.
commit_cooldown()Starts the cooldown alone.
end_ability()Ends it: its behaviour’s component taken off, the tags it granted taken back. On a client, its own prediction alone.
cancel_ability()The same, but told as cancelled. On a client, the server’s copy is cancelled too.

Ending an ability doesn’t refund anything. The cost is paid and the cooldown runs its course.

Revoking an ability is despawning it: commands.entity(ability).despawn(), or world.despawn(ability). One that’s active is cancelled first, as cancel_ability ends it: its behaviour’s component and the tags and cues it held taken back, AbilityEnded told, and on a server, the client running it told too. So is one whose entity loses its GrantedAbility some other way, such as replication taking it away. One despawned with its actor tells nothing: there’s no one left to tell.

On the actor, the commands are grant_ability, activate_ability, activate_tagged, and cancel_abilities(tags) and cancel_all_abilities(): every active ability tagged any of tags cancelled, or every one, as the actor dies. In an exclusive system or a test, AbilityWorld does the same on the World at once, and says how it went: activate_ability returns the ability or the Failure, commit_ability whether it could pay; end_ability and cancel_ability end an ability as the commands do.

Asking

A UI greys out what can’t be cast and sweeps cooldowns; an AI picks what it can cast. AbilityWorld asks about a granted ability with nothing done, from a system taking &World:

  • can_activate(ability): every check activating it makes, Ok or the Failure it would fail with;
  • can_afford(ability): whether its owner could pay its cost;
  • cooldown_remaining(ability): the seconds left on its cooldown, None if it isn’t cooling down, and f32::INFINITY if nothing that cools it runs out. It says cooling down exactly when can_activate does.
use bevy::prelude::*;
use bevy_mettle::prelude::*;
fn buttons(abilities: Query<Entity, With<GrantedAbility>>, world: &World) {
    for ability in &abilities {
        let usable = world.can_activate(ability).is_ok();
        let sweep = world.cooldown_remaining(ability).unwrap_or(0.0);
        // Grey the button out unless usable; sweep it by what's left.
      let _ = (usable, sweep);
    }
}
App::new().add_systems(Update, buttons);

It reads the whole world, so it doesn’t run alongside systems that change abilities, effects or tags.

Interrupted

A stun landing mid-cast should stop the cast. Say what interrupts an ability with end_if, a condition weighed as require is: in the ability’s own tags and its owner’s.

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Tag)] struct State;
#[derive(Tag)] #[tag(parent = State)] struct Stunned;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Mana;
#[derive(Component, Default, Behaviour)] struct CastFireball;
#[derive(Ability)]
struct Fireball;

let fireball = AbilityData::new(Key::of::<Fireball>(), Key::of::<CastFireball>())
    .end_if(Condition::owner_has("state.stunned"));

use std::time::Duration;
use bevy::time::TimeUpdateStrategy;
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
  // Each update moves time on by one fixed step.
  .insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f64(1.0 / 64.0)))
    .add_attribute::<Mana>()
    .add_behaviour::<CastFireball>()
    .add_ability::<Fireball>()
    .insert_resource(MettleContent {
        tags: ["state", "state.stunned"]
            .into_iter()
            .map(TagKey::named)
            .collect(),
        abilities: vec![fireball],
        ..default()
    });
app.update();
let mage = app.world_mut().spawn(Base::<Mana>::default()).id();
app.world_mut().grant_ability(mage, Fireball).unwrap();
let cast = app.world_mut().activate_ability(mage, Fireball).unwrap().ability();

// Stunned mid-cast: interrupted.
app.world_mut()
    .get_mut::<OwnedTags>(mage)
    .unwrap()
    .add(Stunned);
app.update();
app.update();
assert!(app.world().get::<ActiveAbility>(cast).is_none());

// Stunned still: it can't start again.
assert_eq!(
    app.world_mut().activate_ability(mage, Fireball),
    Err(Failure::Interrupted)
);

It’s weighed whenever its owner’s tags change, once however many changed, so an end_if of !Condition::owner_has("state.raging") ends a rage as the raging stops. It’s weighed as the ability activates too, past its owner tags and what it ends, and as its definition is replaced. An interrupted ability is cancelled: AbilityEnded says so, its cost stays paid and its cooldown runs. Over a network, the server interrupts: see Prediction.

end_if keeps an ability from starting as well as stopping it once it has, so an interruption needs nothing more. A require only keeps it from starting: for what shouldn’t stop a cast already under way.

Told

Three entity events are triggered on the actor. Each names its ability by number, id, an AbilityId:

Observe them on the mage, or globally, for sounds, animations and UI:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
fn out_of_mana(failed: On<AbilityFailed>, registry: Res<AbilityRegistry>) {
    if failed.reason == Failure::Cost
        && let Some(name) = registry.name(failed.id)
    {
        info!("{} can't afford {name}", failed.entity);
    }
}
App::new().add_observer(out_of_mana);

Pitfalls

  • Owned tags. An actor needs OwnedTags to activate anything. Any Base<A> brings it, so an actor with attributes has it.
  • Freshly spawned. The cost is checked against the actor’s values as they were last worked out. An actor spawned this tick has none yet, so its bases count, as its Currents do until then: modifiers from what it spawned with count only once its attributes are worked out.
  • Commands run later. activate_ability as a command runs when commands are applied, and says nothing back. Observe AbilityFailed to hear why one didn’t activate.
  • Ending isn’t refunding. Cancel a Commit::Manual ability before it commits if you don’t want it to cost anything.

Input and targets

The fireball goes where the player aims it. Where it’s aimed isn’t data and isn’t the cast’s own state: it’s what the player chose as they cast it, the ability’s input. This chapter gives the mage an aim.

The input

Input is a component of yours. It holds what the player chose, and nothing the cast keeps for itself:

use bevy::prelude::*;
/// Where the fireball is aimed: a point in the world.
#[derive(Component, Clone, Copy, Debug, PartialEq)]
struct Aim(Vec2);

A behaviour says which input it takes, with input on #[behaviour(..)]. Its own fields stay its run state:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Component, Clone, Copy, Debug, PartialEq)] struct Aim(Vec2);
#[derive(Component, Default, Behaviour)]
#[behaviour(input = Aim)]
struct CastFireball {
    timer: f32,
}

A target entity is input too: struct Target(Entity). So is a charge time, or the step of a combo: whatever the player chose that the cast runs on.

Activating with it

An ability whose behaviour takes input is activated with it, by activate_ability_with, naming the behaviour: its input is the type the behaviour says, so giving the fireball anything but an Aim doesn’t compile. The input is put on the ability’s entity next to the behaviour, and taken off with it as the ability ends. Your systems read both:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Component, Clone, Copy, Debug, PartialEq)] struct Aim(Vec2);
#[derive(Component, Default, Behaviour)] #[behaviour(input = Aim)] struct CastFireball { timer: f32 }
#[derive(Ability)] struct Fireball;
/// Where the player clicked this frame, if they did: kept by your input code.
#[derive(Resource)]
struct Clicked(Option<Vec2>);

#[derive(Component)]
struct Player;

/// Casts the fireball where the player clicked.
fn cast_at(clicked: Res<Clicked>, player: Single<Entity, With<Player>>, mut commands: Commands) {
    if let Some(at) = clicked.0 {
        commands.entity(*player).activate_ability_with::<CastFireball>(Fireball, Aim(at));
    }
}

/// Runs each cast, along its aim.
fn cast(mut casts: Query<(&ActiveAbility, &mut CastFireball, &Aim)>, time: Res<Time>) {
    for (active, mut cast, aim) in &mut casts {
        cast.timer += time.delta_secs();
        let _ = (active.actor, aim.0);
    }
}

Which behaviour runs an ability is data, so that the fireball is run by CastFireball is checked as it activates. If it’s run by another, or it’s activated with activate_ability and no input, it fails with Failure::Input, and nothing is paid.

Checking it

A player can ask for anything: a fireball aimed across the map, at a target behind a wall. Register a check for the input with check_input. It’s a system, given an InputCheck: the input, the actor and the ability. It can ask anything a system can, a spatial query for line of sight too:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Mana;
#[derive(Component, Clone, Copy, Debug, PartialEq)] struct Aim(Vec2);
#[derive(Component, Default, Behaviour)] #[behaviour(input = Aim)] struct CastFireball { timer: f32 }
#[derive(Ability)] struct Fireball;
/// Whether the aim is within 12 of the mage.
fn in_reach(In(check): In<InputCheck<Aim>>, mages: Query<&Transform>) -> bool {
    mages
        .get(check.actor)
        .is_ok_and(|at| at.translation.truncate().distance(check.input.0) <= 12.0)
}

let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
    .add_attribute::<Mana>()
    .add_behaviour::<CastFireball>()
    .check_input::<Aim, _>(in_reach)
    .add_ability::<Fireball>()
    .insert_resource(MettleContent {
        effects: vec![EffectData::instant("fireball.cost")
            .modifier(EffectModifier::new::<Mana>(Operation::Flat, -20.0))],
        abilities: vec![AbilityData::new(Key::of::<Fireball>(), Key::of::<CastFireball>()).cost("fireball.cost")],
        ..default()
    });
app.update();

let world = app.world_mut();
let mage = world.spawn((Base::<Mana>::default(), Transform::default())).id();
world.grant_ability(mage, Fireball).unwrap();

// Too far: rejected before anything's paid.
assert_eq!(
    world.activate_ability_with::<CastFireball>(mage, Fireball, Aim(Vec2::new(40.0, 0.0))),
    Err(Failure::Input)
);
// Within reach: cast.
let fireball = world
    .activate_ability_with::<CastFireball>(mage, Fireball, Aim(Vec2::new(8.0, 0.0)))
    .unwrap().ability();
assert_eq!(world.get::<Aim>(fireball), Some(&Aim(Vec2::new(8.0, 0.0))));

The check runs as the ability activates, before its cost is paid or its behaviour put on, wherever it activates: a client predicting, the server deciding, a world on its own. Your game’s AI is held to the same check.

New input while it runs

Some abilities take input as they run: a beam that follows the cursor, a charge released at a spot. Give the active ability new input with set_input, naming its behaviour again. It replaces what it had:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Component, Clone, Copy, Debug, PartialEq)] struct Aim(Vec2);
/// A beam being channelled, along its aim.
#[derive(Component, Default, Behaviour)]
#[behaviour(input = Aim)]
struct Beam;

/// Where the cursor is: kept by your input code.
#[derive(Resource)]
struct Cursor(Vec2);

/// Keeps each beam on the cursor.
fn follow(beams: Query<Entity, With<Beam>>, cursor: Res<Cursor>, mut commands: Commands) {
    for beam in &beams {
        commands.entity(beam).set_input::<Beam>(Aim(cursor.0));
    }
}

New input is checked the same way, where the ability is decided: on a server, or a world on its own. Input that doesn’t pass cancels the ability, and AbilityFailed says Failure::Input.

Over a network

A client predicts with its own input. As it activates with activate_ability_with, it runs the cast at once, and asks the server with the input, as an AbilityRequest carrying it (AbilityRequest<Aim>). New input from set_input is predicted and sent as an AbilityInput. Both go in order, after the activation they’re for.

The server checks the input before it activates anything. If it doesn’t hold, the activation is rejected, and the client undoes what it predicted, its cost and cooldown too. New input that doesn’t hold cancels the ability on the server, and the rejection cancels it on the client. So a client never gets to run a cast the server wouldn’t. Prediction has the rest of how the two agree, and Over lightyear how input is sent.

Other clients aren’t sent input. What they see of the cast comes from what it applies.

Pitfalls

  • Input isn’t run state. Keep what the cast tracks (its timer, how many bolts it has fired) on the behaviour, not the input. The server takes the input from the client as it is, after its check.
  • Triggers give no input. An ability whose behaviour takes input can’t have triggers: a gameplay event or a tag has no aim to give it. Content with one is rejected. Activating such an ability by tag fails with Failure::Input.
  • A check is the server’s word. On a client, a check only spares a prediction the server would refuse. Write it from what the server knows: where actors are on the server, not where the client last saw them.

Grants and triggers

So far the mage was granted the fireball by hand, and activated it by asking. Abilities can also come from effects, and be activated by events and tags.

Granted by effects

The mage’s staff lets it channel. Equipping the staff applies the staff effect, and a Grant says what that effect grants: Grant::new("staff", ["channel"]). Grant is a serde type: in data, a grant names its effect and its abilities by name.

While the staff’s effect lasts, the mage has channel. When the effect goes, however it goes, the ability goes with it, cancelled first if it’s active. That’s how gear gives skills.

Grants are the ability crate’s part of an effect, as Unreal’s abilities component is. An effect knows nothing of abilities, so grants are a list of their own: grants in MettleContent, next to the abilities. Your game can still keep an item’s effect and its grant in one file of its own, and add each to its list.

Each grant is checked as the content is: its effect exists and lasts, since an instant effect would grant nothing for any while, and its abilities exist.

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)]
#[attribute(base = 100.0, min = 0.0)]
struct Mana;
#[derive(Component, Default, Behaviour)]
struct Channel;
#[derive(Effect)]
struct Staff;

let mut app = App::new();
app.add_plugins(MettlePlugins)
    .add_attribute::<Mana>()
    .add_behaviour::<Channel>();
app.add_effect::<Staff>().insert_resource(MettleContent {
    effects: vec![EffectData::infinite(Key::of::<Staff>())],
    abilities: vec![AbilityData::new("channel", Key::of::<Channel>())],
    grants: vec![Grant::new(Key::of::<Staff>(), ["channel"])],
    ..default()
});
app.update();
let world = app.world_mut();
let mage = world.spawn(Base::<Mana>::default()).id();

// Equipping the staff grants channel.
world.apply_effect(mage, Staff);
world.flush();
let granted: Vec<Entity> = world.get::<Abilities>(mage).unwrap().iter().collect();
assert_eq!(granted.len(), 1);

// What the staff grants, by number.
let channel = world.resource::<AbilityRegistry>().id("channel").unwrap();
let staff_id = world.resource::<EffectRegistry>().id_of::<Staff>().unwrap();
assert_eq!(world.resource::<AbilityRegistry>().granted_by(staff_id), [channel]);
let staff = world.get::<GrantedBy>(granted[0]).unwrap().0;

// Taking it off takes channel away.
world.despawn(staff);
world.flush();
assert!(world.get_entity(granted[0]).is_err());

The granted ability is GrantedBy the effect, and the effect lists what it granted in GrantedAbilities. It’s granted at the effect’s level. AbilityRegistry::granted_by lists what an effect grants, by its number.

Grants are applied by an observer of the effect being applied, so they land when that observer’s commands do: at once in a system’s commands, after world.flush() on the world directly.

Triggers

A trigger activates an ability without anyone asking. There are three:

TriggerActivates it
Event(tag)When a gameplay event with that tag, or one under it, is sent to its owner.
TagAdded(tag)When its owner comes to own that tag.
TagPresent(tag)When its owner comes to own that tag, and cancels it when it no longer does.

A triggered ability goes through the same checks as any other. A trigger that fires while the fireball is cooling down does nothing, apart from AbilityFailed.

An ability whose behaviour takes input can’t have triggers: an event or a tag has no aim to give it. Content with one is rejected.

Gameplay events

A GameplayEvent is a tag sent to an actor, with who caused it, who it’s about, and how much: an entity event, triggered on the actor with commands.trigger(..) or world.trigger(..). The mage ripostes with a fireball when it parries: its fireball has the trigger Trigger::Event(TagKey::named("event.parried")).

use bevy::prelude::*;
use bevy_mettle::prelude::*;
fn parried(commands: &mut Commands, mage: Entity, attacker: Entity) {
    commands.trigger(GameplayEvent {
        instigator: Some(attacker),
        target: Some(attacker),
        magnitude: 1.0,
        ..GameplayEvent::new(mage, "event.parried")
    });
}
let _ = parried;

An event tagged event.parried.perfect triggers it too, since it’s under event.parried. The event reaches the behaviour in ActiveAbility’s event, so the fireball knows whom to aim at:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Component, Default)] struct CastFireball;
fn aim(casting: Query<&ActiveAbility, Added<CastFireball>>) {
    for active in &casting {
        let target = active.event.as_ref().and_then(|event| event.target);
        // Aim at `target`, if there is one.
        let _ = target;
    }
}

The abilities it triggers activate as it’s triggered, whether or not anything else is listening. Triggered on a World in an exclusive system or a test, they activate as the world’s commands are applied: call world.flush() to have them at once. Observe it for anything else a parry should do.

Sent by an ability

A behaviour sending an event marks it with its own activation: GameplayEvent::new(mage, "event.parried").under(active.activation). That matters over a network. A predicted ability it triggers then runs under an activation derived from it, the same on the client and the server, so the client predicts the riposte at once and the server confirms it, without asking. Undone with its parent, if the server rejects that. An event sent by no ability, from the game’s own code, is the server’s to act on: see On a client.

Tags

The mage lashes out with smolder while it’s burning: its trigger is Trigger::TagPresent(TagKey::named("state.burning")).

use core::time::Duration;
use bevy::prelude::*;
use bevy::time::TimeUpdateStrategy;
use bevy_mettle::prelude::*;
#[derive(Attribute)]
#[attribute(base = 100.0, min = 0.0)]
struct Life;
#[derive(Component, Default, Behaviour)]
struct Smolder;
#[derive(Effect)] struct Burning;
#[derive(Ability)] #[ability(name = "smolder")] struct SmolderAbility;
let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
    .insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_millis(50)))
    .add_attribute::<Life>()
    .add_behaviour::<Smolder>()
    .add_effect::<Burning>()
    .add_ability::<SmolderAbility>()
    .insert_resource(MettleContent {
        tags: ["state", "state.burning"].map(TagKey::named).to_vec(),
        effects: vec![EffectData::lasting(Key::of::<Burning>(), 4.0).owner_tag("state.burning")],
        abilities: vec![AbilityData::new(Key::of::<SmolderAbility>(), Key::of::<Smolder>())
            .trigger(Trigger::TagPresent(TagKey::named("state.burning")))],
        ..default()
    });
app.update();
let mage = app.world_mut().spawn(Base::<Life>::default()).id();
app.world_mut().grant_ability(mage, SmolderAbility);

let smoldering = |app: &mut App| {
    app.world_mut()
        .query_filtered::<(), With<Smolder>>()
        .iter(app.world())
        .count()
};

// Set alight: once its owned tags are told, smolder activates.
app.world_mut().apply_effect(mage, Burning);
app.update();
app.update();
assert_eq!(smoldering(&mut app), 1);

// Put out: smolder is cancelled.
let burning: Vec<Entity> = app.world().get::<ActiveEffects>(mage).unwrap().iter().collect();
for effect in burning {
    app.world_mut().despawn(effect);
}
app.update();
app.update();
assert_eq!(smoldering(&mut app), 0);

TagAdded and TagPresent name a tag exactly. They fire when that tag comes to be owned, and owning a tag under it counts: a TagAdded of state fires when the mage first owns any state.* tag at all. They fire when TagAdded and TagRemoved are told, which is when attributes are next worked out, not the moment the tag is added.

Activating by tag

activate_tagged activates every ability granted to the actor that’s tagged any of the tags given. Since an ability’s tags include those above them, ability.spell activates every spell the mage has:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
fn cast_everything(commands: &mut Commands, mage: Entity) {
    let spells: Tags = ["ability.spell"].into_iter().collect();
    commands.entity(mage).activate_tagged(spells);
}
let _ = cast_everything;

Each goes through its checks on its own: those that can activate do.

On a client

A client leaves triggers, and abilities effects grant, to the server, with one exception: a predicted ability triggered by an event sent under an activation is predicted there too. Other events are still told to observers on a client, but activate nothing, and tags owned never trigger anything there. What the server activates and grants arrives by replication, and a ServerInitiated ability it activates is run on the controlling client too. See Prediction.

activate_tagged, and activate on a granted ability, go through each ability’s policy on a client, as activate_ability does: a predicted one is predicted and asked of the server.

Cues

A burning mage should look like it’s burning. A fireball that lands should flash, and say how much it dealt. Effects and abilities change numbers; cues are what’s shown of them, for your game to play: flames, sparks, sounds, damage numbers. Mettle tells you when, and on whom. What’s played is yours.

Two kinds

A cue is a gameplay tag, under a branch of your own such as cue:

  • A lasting cue holds while something lasts: an effect while it counts, an ability while it’s active. Flames while burning, a glow while casting.
  • A one-shot cue plays at a moment: an effect executing, a hit. Sparks, a damage number, a puff each time a burn ticks.

Effects and abilities name the cues they play, in their cues, by tag:

use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Life;
let burning = EffectData::lasting("burning", 4.0)
    .period(0.5, false)
    .modifier(EffectModifier::new::<Life>(Operation::Flat, -3.0))
    .cue("cue.burning")
    .cue("cue.hit.fire");
assert_eq!(burning.cues.written().count(), 2);

An effect holds its cues on its target while it counts, and plays them once each time it executes. So burning shows flames while it lasts, and a puff with each tick of damage. An ability holds its cues on its owner while it’s active. Cue tags are checked as content is: one that isn’t in the tag registry is an error.

Lasting cues are entities

Each lasting cue on an actor is an entity of its own: an ActiveCue with its tag, CueOf the actor. It’s spawned as the cue starts and despawned as it ends, or as the actor is. Observe it being added and removed, or query it, and hang what you play on it: it goes when the cue does.

use bevy::prelude::*;
use bevy_mettle::prelude::*;
/// Flames on every burning actor, for as long as it burns.
fn flames(added: On<Add<ActiveCue>>, cues: Query<&ActiveCue>, mut commands: Commands) {
    let Ok(cue) = cues.get(added.entity) else {
        return;
    };
    if cue.tag.is_within("cue.burning") && !cue.joined {
        commands.entity(added.entity).insert(Name::new("flames"));
    }
}

/// A last puff of smoke as the burning ends: the cue's still readable.
fn smoke(removed: On<Remove<ActiveCue>>, cues: Query<(&ActiveCue, &CueOf)>) {
    if let Ok((cue, of)) = cues.get(removed.entity)
        && cue.tag.is_within("cue.burning")
    {
        info!("smoke on {}", of.0);
    }
}
App::new().add_observer(flames).add_observer(smoke);

Nothing has to remember to stop a looping effect: despawning the cue’s entity does. The actor’s ActiveCues lists them.

An ActiveCue carries its tag; joined, whether it was going already as this world came to see the actor; and unreplicated, whether only sources a server doesn’t replicate hold it. Its actor is its CueOf.

A cue held by two things, two burns or a burn and your own, is one entity, held until both let go.

One-shots trigger

CueExecuted is triggered on the actor as a one-shot plays, with what the effect playing it changed, the level of what played it, what played it carried (its context: who caused it, and the game’s own data), the activation it was played under, and the effect, if one did.

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Life;
/// A fire hit: embers and a damage number. Any other hit: a spark.
fn hits(played: On<CueExecuted>) {
    if played.tag.is_within("cue.hit.fire") {
        info!("embers on {}: {}", played.entity, -played.magnitude());
    } else if played.tag.is_within("cue.hit") {
        info!("spark on {}", played.entity);
    }
}

#[derive(Effect)]
#[effect(name = "fireball.hit")]
struct FireballHit;

let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
    .add_attribute::<Life>()
    .add_effect::<FireballHit>()
    .add_observer(hits)
    .insert_resource(MettleContent {
        tags: ["cue", "cue.hit", "cue.hit.fire"]
            .into_iter()
            .map(TagKey::named)
            .collect(),
        effects: vec![EffectData::instant(Key::of::<FireballHit>())
            .modifier(EffectModifier::new::<Life>(Operation::Flat, -15.0))
            .cue("cue.hit.fire")],
        ..default()
    });
app.update();
let mage = app.world_mut().spawn(Base::<Life>::default()).id();
app.world_mut().apply_effect(mage, FireballHit);

is_within matches a tag and every tag under it: cue.hit matches a fire hit too. Every observer sees every cue, so to play only the most specific, check the child tag first, as hits does.

magnitude() is what the execution changed the first attribute it changes by, the one the effect is about: a fireball’s hit for 15 is -15.0. Every attribute it changed is in changes, in the order it first changed each, for a drain that shows the life it took and the mana it gave. Where it hit is carried if the applying code put a Hit on the context: played.context.get::<Hit>(). Otherwise, read positions from the actor and the source in the world showing the cue, so the spark lands where that world shows the target.

Cues from code

Not everything shown is an effect. Play cues yourself, on an actor, with execute_cue, add_cue and remove_cue:

use bevy::prelude::*;
use bevy::ecs::entity::MapEntities;
use bevy_mettle::prelude::*;
use serde::{Deserialize, Serialize};
/// How hard something landed: what a landing's cue plays by.
#[derive(Reflect, Clone, PartialEq, Serialize, Deserialize, MapEntities)]
#[reflect(ContextData)]
struct Impact(f32);

/// Called as the mage lands hard.
fn land(commands: &mut Commands, mage: Entity) {
    // A one-shot, with how hard it landed as its own data.
    commands.entity(mage).execute_cue(Cue::new("cue.land").with(Impact(8.0)));
    // A lasting cue, held until removed as often as it was added.
    commands.entity(mage).add_cue("cue.dust");
}

A one-shot from code changes nothing, so its magnitude() is 0: what it plays by is its context data, registered (app.register_type::<Impact>()) so a server can send it.

remove_cue(tag) lets go of one source of the tag, whichever holds it, and warns if none does. add_unreplicated_cue and Cue::unreplicated keep a cue in the world that plays it: a server doesn’t send it to its clients. On a client, cues from code are always its own: a client can’t send them.

Over a network

A server’s cues reach the clients that see the actor, with the actor:

  • Lasting cues, the server’s ServedCues, are replicated: a handful of tags, each sent as its number, a TagId, in the tag registry, not the effects. A client shows them next to its own, so what it predicts and what the server serves are one entity, with no flicker between.
  • One-shots the server plays are replicated as CueBursts: those of the last half second, numbered. A client plays each once. One it played itself, predicting under its activation, isn’t played again, however many times it played it; if the server rejects the activation, or never plays it, the client forgets it. A burst names its cue by its tag’s number too, and its effect, if one played it, by its EffectId.

A cue whose tag isn’t in the tag registry has no number to send, so it isn’t replicated: the server warns, and plays it only for itself. Cues from code are tags like any other, so define them in content or add them with add_tag.

A client coming to see an actor joins what’s going: its lasting cues are spawned with joined set, for a quiet start rather than a burst, and it plays the one-shots of the last half second, what’s just happened: a projectile’s launch, spawned with it. Older ones are past.

Since other clients see cues, they needn’t see the effects themselves. Over lightyear sends an actor’s effect replicas to its owner alone, unless you say, following control as it changes hands.

Pitfalls

  • One tag, both kinds. An effect’s cue is held while it counts and played as it executes. A periodic burn with one cue gets both: give it two tags if they should look different.
  • A cue has to be a tag in the registry. One named in content is checked as content loads. One from code that isn’t defined is played where it’s made, but never sent.
  • Cues are shown late in the frame. Lasting cues are worked out in PostUpdate, in CueSystems. One-shots play at once.
  • Cue observers are for showing. They run where the cue is shown, a client too. Change gameplay in effects and abilities, not in cues.

Prediction

In a multiplayer game the server decides. But a fireball that waits a round trip before it casts feels sluggish. So a client predicts its own player’s abilities: it casts at once, pays, starts the cooldown, and asks the server. The server answers. If it agrees, the prediction gives way to the server’s own state. If not, everything predicted is undone. This is how Unreal’s Gameplay Ability System does it too.

Mettle sends nothing itself. It says what to send, as messages and components, and your transport ships them. Over lightyear does all of that for lightyear.

Roles

NetRole is a resource that says what this world is:

RoleWhat it does
StandalonePlays alone: decides everything, replicates nothing. The default.
ServerDecides everything, and keeps what clients need to show it.
ClientShows what the server sends, and predicts its own activations.

Insert it on each side, before the game starts:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
let mut server = App::new();
server.add_plugins(MettlePlugins).insert_resource(NetRole::Server);

let mut client = App::new();
client.add_plugins(MettlePlugins).insert_resource(NetRole::Client);

Values the server serves

An actor’s attributes are worked out on both sides, from the same modifiers. But only the server knows everything that counts: a buff from an item the client never saw, say. So the server serves each value, and the client shows it.

  • On the server, an actor with a Served<A> keeps it equal to its Current<A>. That’s the value to replicate.
  • On a client, an actor marked Remote shows its Served<A> as its Current<A>, and as its reads with nothing in play, rather than folding the modifiers replicated to it.
  • While it predicts, it shows its own fold instead. A source of modifiers marked Provisional is a prediction. While one counts for a Remote actor, the actor shows its own values, so the player sees the mana they just spent.
  • Reads in context, like a fire hit’s resistance, are worked out where they’re read. On a client they’re advisory: good for a tooltip, never for deciding.

AttributeChanged is told as the value shown changes, whichever it comes from.

use core::time::Duration;
use bevy::prelude::*;
use bevy::time::TimeUpdateStrategy;
use bevy_mettle::prelude::*;
#[derive(Attribute)]
#[attribute(base = 100.0, min = 0.0)]
struct Life;
let mut client = App::new();
client
    .add_plugins((MinimalPlugins, MettlePlugins))
    .insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_millis(50)))
    .insert_resource(NetRole::Client)
    .add_attribute::<Life>();
// A mage the server says has 60 life, though its base says 100.
let mage = client
    .world_mut()
    .spawn((Base::<Life>::default(), Served::<Life>::new(60.0), Remote))
    .id();
client.update();
client.update();
assert_eq!(client.world().get::<Current<Life>>(mage).unwrap().get(), 60.0);

Who adds Served<A> and Remote is up to your transport. The lightyear adapter adds Served<A> to every server actor with Base<A>, and Remote to every actor a client receives.

Effects from the server

  • On the server, each active effect keeps an EffectReplica: its spec, stacks, how long it lasts and has left, whether it’s inhibited, what its source owned as it applied, and the attribute values it captured. Replicate it with its EffectOf, mapping its context’s entities. The effect’s definition is data both sides have, so it isn’t sent: the replica carries the effect’s number, an EffectId, and its params and its source’s tags as tag numbers, TagIds. The replica is sent again as it changes, but not as its time runs down.
  • On a client, an effect whose replica arrives is shown as a Proxy: it counts, tags its owner, and its time runs down, but it isn’t ticked otherwise. Its replica says how it stands. Its despawn takes it off.

Modifier sources from the server

An effect’s modifiers come with its replica. A source the game makes in code, an item or a passive, has nothing like it unless the game opts in: put a ModifiersReplica on the source and replicate it. The server keeps it in step with the source’s Modifiers, each modifier’s condition naming its tags by number, as a Modifier<TagId>, and the actor it’s ModifierOf; a client gives the source its Modifiers and ModifierOf from it. Modifiers shows it at work.

Numbers on the wire

Everything mettle sends names what it’s about by number, never by name: effects by EffectId, abilities by AbilityId, tags by TagId. That’s effect replicas (the effect, its params and its source’s tags), ability requests, replies and replicas, served cues and cue bursts, and modifier replicas (each condition’s tags). A number is a few bytes where a name is a string, and both sides know it already: content is numbered in order of name as it’s checked, so a server and its clients that load the same content number it the same. A reload keeps the numbers things had and numbers new ones after them, so a reload on both sides keeps them agreeing. Names stay for content, debug output and what a client shows.

Predicting an ability

On a client, activate_ability predicts:

  1. The fireball activates at once, under a new Activation. Its behaviour runs, and its cost and cooldown are applied as predictions.
  2. An AbilityRequest is written, as a message, for your transport to send to the server: the actor, the ability by its number, the activation, and its input, NoInput for a behaviour that takes none (the default, AbilityRequest<NoInput>). New input as it runs is an AbilityInput.

What’s applied under a predicted activation is marked:

  • A lasting effect, the cooldown, gets PredictedUnder and Provisional. It stays until the server’s own replaces it: the same effect, from the same source, under the same activation.
  • A stack predicted onto one of the server’s effects is kept apart from the server’s stacks.
  • An instant change, the cost, is never written to a base. The server owns bases, and replicates them. The change is worked out on the base as shown, kept within its limits, and the difference shown as a flat modifier until the server answers. A cost routed into a route is routed first, as the server routes it, and what’s routed to the source is shown on the source the same way.

Execution policies

How an ability runs over a network is its own: NetExecution, the net field of its definition, as in Unreal.

PolicyOn the clientOn the server
Predicted (default)Runs at once, and asks the serverRuns it, and confirms or rejects
ServerOnlyAsks the server, running nothingRuns it; the client sees what it does through replicas and cues
ServerInitiatedRuns its behaviour as the server says it started, not paying or checking againRuns it, however it started, and tells the controlling client: AbilityStarted
LocalOnlyRuns it, and sends nothingRuns it only if it’s the server’s own
use bevy_mettle::prelude::*;
// A random proc: the client can't know the roll.
let proc = AbilityData::new("chaos_bolt", "cast_bolt").net(NetExecution::ServerOnly);
// Channelled from the server, aimed on the client.
let beam = AbilityData::new("beam", "channel_beam").net(NetExecution::ServerInitiated);
// Zoom: the player's own view.
let zoom = AbilityData::new("zoom", "zoom").net(NetExecution::LocalOnly);
let _ = (proc, beam, zoom);
  • Predicted is what a player’s own abilities want: what follows is how it’s predicted and resolved.
  • ServerOnly suits outcomes a client can’t know: a random proc, a hidden target, a teleport that would look wrong rejected. Activating one on a client comes to Activated::Asked: nothing runs there yet. Rejected, the client is told by AbilityFailed, with nothing to undo; a client can’t cancel it.
  • ServerInitiated suits client-side logic on what the server decides: a channel the client aims, a camera following a dash the server started. The server pays; the client mirrors its activation, and its end. Asked for on a client, it comes to Activated::Asked until the server’s start arrives. A client cancelling it asks the server to cancel it too. A predicted ability its mirrored behaviour triggers is the server’s to decide: a client predicts only under its own activations.
  • LocalOnly suits what’s only the player’s: zoom, an aim preview, a menu. It can’t have a cost or a cooldown, which content checking rejects: what it paid, the server’s replicated values would undo. Triggered, it runs where its trigger happened, by an event or a tag, on a client too. A server rejects a client asking for one.

The server decides

On the server, each AbilityRequest is activated under the client’s activation, and answered with an AbilityReply: the same actor, ability and activation, and rejected with a Failure if it didn’t activate. Input with a request is checked before anything’s activated. New input to a running activation is checked as it comes: if it doesn’t hold, the server cancels the ability and replies once more, rejecting the activation, and the client cancels it too.

Each world counts its own activations: Activation::client(n) on a client or a standalone world, Activation::server(n) on the server. The server’s own never clash with its clients’. A request under the server’s activation, or a derived one, is rejected, with a warning: a client can’t pass its activation off as the server’s. Two clients’ activations may be the same. They’re told apart by the actor they’re for, the source of what they apply.

The client resolves

When the client gets the reply back, as an AbilityReply message:

  • Confirmed: what was predicted stays until the server’s own effects replace it. The predicted cost and any stacks on the server’s effects give way to the server’s state. So hand the reply over only once the server’s state as of its answer has arrived, or the cost shows unpaid until it does.
  • Rejected: the ability is cancelled, everything predicted under it is undone, last first, and AbilityFailed says why. What its behaviour applied is undone only if it was applied under the activation: through ActiveAbility::effect_spec.

Once an activation is answered, nothing more is predicted under it. What it does from then on arrives from the server.

Here’s a client predicting the fireball, and the server saying no:

use core::time::Duration;
use bevy::ecs::message::Messages;
use bevy::prelude::*;
use bevy::time::TimeUpdateStrategy;
use bevy_mettle::prelude::*;
#[derive(Attribute)]
#[attribute(base = 100.0, min = 0.0)]
struct Mana;
#[derive(Component, Default, Behaviour)]
struct CastFireball;
#[derive(Ability)] struct Fireball;
let mut client = App::new();
client
    .add_plugins((MinimalPlugins, MettlePlugins))
    .insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_millis(50)))
    .insert_resource(NetRole::Client)
    .add_attribute::<Mana>()
    .add_behaviour::<CastFireball>()
    .add_ability::<Fireball>();
client.insert_resource(MettleContent {
    tags: ["cooldown", "cooldown.fireball"].map(TagKey::named).to_vec(),
    effects: vec![
        EffectData::instant("fireball.cost").modifier(EffectModifier::new::<Mana>(Operation::Flat, -20.0)),
        EffectData::lasting("fireball.cooldown", 2.0).owner_tag("cooldown.fireball"),
    ],
    abilities: vec![AbilityData::new(Key::of::<Fireball>(), Key::of::<CastFireball>()).cost("fireball.cost").cooldown("fireball.cooldown")],
    ..default()
});
client.update();
let mana = |app: &App, mage| app.world().get::<Current<Mana>>(mage).unwrap().get();
// The client's mage, as the server serves it.
let mage = client
    .world_mut()
    .spawn((Base::<Mana>::default(), Served::<Mana>::new(100.0), Remote))
    .id();
client.world_mut().grant_ability(mage, Fireball);
client.update();
client.update();

// Cast at once: paid, as a prediction, and asked of the server.
client.world_mut().activate_ability(mage, Fireball).unwrap();
let request = client
    .world_mut()
    .resource_mut::<Messages<AbilityRequest>>()
    .drain()
    .next()
    .unwrap();
// The request names the fireball by its number.
let fireball = client.world().resource::<AbilityRegistry>().id_of::<Fireball>();
assert_eq!(Some(request.ability), fireball);
client.update();
client.update();
assert_eq!(mana(&client, mage), 80.0);
assert_eq!(client.world().get::<Base<Mana>>(mage).unwrap().get(), 100.0);

// The server says no: the mana comes back, and the cooldown goes.
client.world_mut().write_message(AbilityReply {
    actor: request.actor,
    ability: request.ability,
    activation: request.activation,
    rejected: Some(Failure::Cooldown),
});
client.update();
client.update();
assert_eq!(mana(&client, mage), 100.0);
assert!(client.world().get::<ActiveEffects>(mage).is_none_or(|effects| effects.iter().count() == 0));

confirm_prediction and reject_prediction on the world are what the client does with a reply. Abilities call them for you as replies arrive. Call them yourself only for effects you predict under an activation of your own.

Unanswered

A reply can go missing with the connection. A prediction still waiting after PredictionTimeout (5 seconds by default) is taken back as a rejection is, with what it triggered, and AbilityFailed says Failure::Unanswered. A reply that comes after that is ignored: the server’s state still arrives, as it would anyway. abandon_pending takes back every prediction waiting at once, for a transport that knows its connection is gone. Over lightyear, a disconnect does that for you.

Endings, both ways

A predicted ability runs on both sides, and the server decides how it ends:

  • The server ends it, for a client’s activation: done, interrupted by a stun only the server sees yet, its cost unpaid at commit, or revoked. It tells that client with an AbilityEnd, and the client ends its copy.
  • The client cancels it, its own prediction, with cancel_ability or cancel_abilities: a channel let go of, a cast called off. It asks the server to cancel its copy with an AbilityCancelRequest.
  • The client ends it any other way, and only its own copy ends. A cast that ends itself when its time is up ends on the client first, as the client runs ahead: the server’s copy runs its course, to its last frame.
  • The client leaves to the server what it would work out from what it predicts, which may be wrong. It doesn’t interrupt: a stun it predicts may never land. A commit it can’t pay from the mana it predicts returns false but leaves the ability running. The server weighs both, and its AbilityEnd ends the client’s copy if it agrees.

Either side ignores an end of what isn’t running any more. A cancel that gets to the server before the request it cancels is held for it, up to a second, and up to 16 for one actor. An end isn’t a rejection: the cost stays paid and the cooldown runs, on both sides. A server’s own activations aren’t told: a client never runs them.

Cues

A client predicts cues with what plays them: a predicted cooldown’s cue is held at once, a predicted cost’s one-shot played at once. When the server’s arrive, the lasting ones are one with the client’s, with no flicker, and a one-shot played under the client’s own activation isn’t played again. Cues has the rest.

Granted abilities

On the server, each granted ability keeps an AbilityReplica, its number, an AbilityId, and its level. Replicate it with its AbilityOf. On a client, an ability whose replica arrives is granted from the client’s own registry, and kept as its replica changes, a level gained, say.

A client leaves triggers, and abilities effects grant, to the server. They arrive as replicas like any other.

Ordering

Ability requests, replies and replicas are handled in AbilitySystems, in PreUpdate. Hand your transport’s messages to mettle before it.

Pitfalls

  • One content, both sides. Effects, abilities and tags are sent as numbers, not definitions. The server and its clients need the same content, loaded the same way, or the same number names different things. Over lightyear, a client that doesn’t is told: see Checking content.
  • Confirm late, not early. A confirmed prediction gives way to the server’s state at once. Confirm before that state arrives and the cost flickers back for a moment.
  • Only your own. A client predicts what it activates itself. Everything else comes from the server, so don’t trigger abilities on a client and expect them to run.

Over lightyear

Prediction said what to send. bevy_mettle_lightyear sends it over lightyear: replicas replicated, requests and replies carried, and who may ask checked.

Turn on the lightyear feature:

[dependencies]
bevy_mettle = { version = "0.1", features = ["lightyear"] }

It’s then bevy_mettle::lightyear, its names are in the prelude, and MettlePlugins adds MettleNetPlugin too.

Setting up

Both sides add the same plugins, in the same order:

  1. lightyear’s own plugins: ServerPlugins on the server, ClientPlugins on a client;
  2. MettlePlugins, with NetRole set to Server or Client;
  3. your attributes, and replicate_attribute for each one clients should see;
  4. your behaviours, and replicate_input for each input they take;
  5. the effects and abilities code names, and the same content, as Loading data shows.
use core::time::Duration;
use bevy::prelude::*;
use bevy::state::app::StatesPlugin;
use bevy_mettle::prelude::*;
use lightyear::prelude::client::ClientPlugins;
use lightyear::prelude::server::ServerPlugins;
#[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;

/// Where the fireball is aimed: sent from the client as it is.
#[derive(Component, Clone, Copy, serde::Serialize, serde::Deserialize, bevy::ecs::entity::MapEntities)]
struct Aim(Vec2);

#[derive(Component, Default, Behaviour)]
#[behaviour(input = Aim)]
struct CastFireball;

#[derive(Ability)]
struct Fireball;

const TICK: Duration = Duration::from_millis(16);

/// What both sides add, after lightyear's plugins.
fn mettle(app: &mut App, role: NetRole) {
    app.insert_resource(role)
        .add_plugins(MettlePlugins)
        .add_attribute::<Life>()
        .add_attribute::<MaxLife>()
        .add_attribute::<Mana>()
        .replicate_attribute::<Life>()
        .replicate_attribute::<MaxLife>()
        .replicate_attribute::<Mana>()
        .add_behaviour::<CastFireball>()
        .replicate_input::<Aim>()
        .add_ability::<Fireball>();
}

let mut server = App::new();
server.add_plugins((MinimalPlugins, StatesPlugin, ServerPlugins { tick_duration: TICK }));
mettle(&mut server, NetRole::Server);

let mut client = App::new();
client.add_plugins((MinimalPlugins, StatesPlugin, ClientPlugins { tick_duration: TICK }));
mettle(&mut client, NetRole::Client);

MettleNetPlugin registers its channel, messages and components with lightyear, which only works once lightyear’s plugins are there. With the lightyear feature on, adding MettlePlugins to an app without them panics. If the same game also runs offline, leave the net plugin out there:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
let mut offline = App::new();
offline.add_plugins(MettlePlugins.build().disable::<MettleNetPlugin>());

Set the NetRole before the game starts. The plugin does what that role needs, and nothing for the other.

Your actors

Your game replicates its actors. Mettle replicates what’s on them. On the server, the mage its player controls:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
use lightyear::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Life;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Mana;
#[derive(Ability)] struct Fireball;
fn spawn_mage(commands: &mut Commands, player: Entity) -> Entity {
    commands
        .spawn((
            Base::<Life>::default(),
            Base::<Mana>::default(),
            Replicate::to_clients(NetworkTarget::All),
            ControlledBy {
                owner: player,
                lifetime: Lifetime::default(),
            },
        ))
        .grant_ability(Fireball)
        .id()
}
let _ = spawn_mage;

player is the client’s link on the server. ControlledBy is what lets that client cast the mage’s abilities. Without it, the client sees the mage but can’t act for it.

What’s replicated

From the server to its clients:

  • every active effect’s EffectReplica and every granted ability’s AbilityReplica, with their EffectOf and AbilityOf, to the client ControlledBy the actor alone: it predicts with them. They go and come with the actor (ReplicateLike it), and a visibility filter keeps them to its owner, following control as it comes, changes hands or goes. An actor no client controls sends them to none. Set MettleNetPlugin { replicas: Replicas::Everyone } to send them to every client that sees the actor, for a party frame showing allies’ buffs;
  • each actor’s cues, lasting and one-shot, to every client that sees it: what other clients see of its effects and abilities, while its cooldowns and buffs stay its owner’s;
  • each actor’s Base<A> and Served<A>, for the attributes you named with replicate_attribute. Base<A> is what the client works predictions out on. Served<A> is what it shows. On the server, every actor with Base<A> serves A;
  • an effect’s context, with its replica and its one-shot cues: its entities mapped to the client’s, those in the game’s own types too. One the client isn’t sent (an arrow only the server has) is an empty entity there, the one it becomes if it’s sent later: check it has what you read;
  • each modifier source’s ModifiersReplica, carrying the actor it’s ModifierOf, for the sources your game replicates and opts in (below).

All of it names content by number: effects by EffectId, abilities by AbilityId, tags by TagId. Nothing carries a name.

On a client, every actor replicated from the server is marked Remote, so it shows the server’s values rather than its own fold, except while it predicts. A client shows all this. It never decides it.

Mettle’s own state on an actor, its folded modifiers, isn’t replicated. It’s worked out on each side. If clients read an actor’s owned tags that your game sets, replicate those yourself.

An item’s or a passive’s modifiers reach clients through a ModifiersReplica: the adapter registers it for replication, and the game opts each source in by putting an empty one on it, and replicating the source. The server keeps the replica in step with the source’s Modifiers, its conditions’ tags by number, and the actor it’s ModifierOf; each client gives the source its Modifiers and its ModifierOf from it (Modifiers). ModifierOf itself isn’t replicated: a client keeps its own on the effects it shows.

use bevy::prelude::*;
use bevy_mettle::prelude::*;
use lightyear::prelude::*;
#[derive(Attribute)] #[attribute(base = 10.0)] struct SpellPower;
/// The mage's ring, on the server: its modifiers reach every client.
fn equip_ring(commands: &mut Commands, mage: Entity) -> Entity {
    commands
        .spawn((
            ModifierOf(mage),
            ModifiersReplica::default(),
            Replicate::to_clients(NetworkTarget::All),
        ))
        .modifiers([ModifierSpec::flat::<SpellPower>(5.0).owner_has("state.casting")])
        .id()
}
let _ = equip_ring;

A client’s reads in context, a hit’s tags say, are its own, worked out from what it has. They’re advisory.

Requests and replies

  • Asking. A client’s AbilityRequests go to the server on MettleChannel, reliable and ordered, and with replicate_input its requests with input and its new input, in order after them. An input is sent as it is, so it derives Serialize, Deserialize and MapEntities (a target entity is mapped to the server’s).
  • Checking. The server takes a request, or input, only for an actor ControlledBy the client sending it. A request for any other is rejected back to that client as Failure::NotGranted, with a warning, so its prediction is undone; input for any other is dropped.
  • Answering. The server’s AbilityReply goes to the client controlling the actor, the only one it takes requests from, with the replication tick its state goes out at. Later replies, rejecting a running activation’s input, go the same way.
  • Ending. A client’s cancels of its own activations go to the server after the requests they cancel, taken only for actors it controls; the server’s ends of a client’s activations go to the client controlling the actor. Both on MettleChannel.

A reply isn’t ordered with replication: they travel different ways. So a client holds a reply that confirms its prediction until the actor’s state as of that tick has arrived. Then the prediction gives way to the server’s state in the same frame. The cost is never paid twice, or not at all. A confirming reply whose actor never changed has nothing to wait for, so it’s held for 30 frames at most. A rejecting reply is handed over at once, since nothing changed on the server.

A client that disconnects drops the replies it holds and takes back every prediction still waiting at once, each failing Failure::Unanswered, without waiting out PredictionTimeout.

Replies and requests are heard in PreUpdate, after lightyear receives its messages and replication, and before AbilitySystems.

Checking content

Effects, abilities and tags go as their numbers, so a client has to number content as its server does. The server tells each client how it numbers its content, a ContentFingerprint of every registry, as it connects, and again whenever its content changes (a hot reload). The game’s context data types are in it too: they go by number, in order of name, so add the same ones on both. A client numbering anything otherwise logs an error and triggers ContentMismatch: load the server’s content the way the server did, or leave.

use bevy::prelude::*;
use bevy_mettle::prelude::*;
/// Leaves a server whose content isn't this client's.
fn leave(mismatch: On<ContentMismatch>, mut exit: MessageWriter<AppExit>) {
    warn!("the server's content isn't ours: {:?}", mismatch.server);
    exit.write(AppExit::error());
}

let mut app = App::new();
app.add_observer(leave);

Matching content is told nothing. A server that reloads its content alone tells every client it no longer matches; reloaded alike, they match again.

Pitfalls

  • Plugin order. lightyear’s plugins, then MettlePlugins. The other way round panics.
  • Time. A server holds a client’s early cancels a while, by Bevy’s Time: a server app without MinimalPlugins or DefaultPlugins (a bare test app) panics as that system first runs. Add TimePlugin.
  • The same protocol. Both sides register the same attributes with replicate_attribute, and the same components, in the same order.
  • The same content. Effects, abilities and tags go as their numbers, never their definitions or names. Both sides need the same content, loaded the same way, or a number names different things: a client that doesn’t is told, by ContentMismatch. Observe it.
  • A modifier source without its replica. An item replicated without a ModifiersReplica shows no modifiers on a client.
  • Control. A mage without ControlledBy its player’s link can’t be cast for: every request is rejected.

Loading data

Tags, curves, effects, abilities and grants are data. Reading files isn’t mettle’s business. Its data types are serde types, so your game loads them however it likes: from any serde format, as Bevy assets, from a mod folder or a server’s download, or built in code. Then it hands them over as one resource, MettleContent.

Mettle has no file format of its own yet. A designed one is issue #9.

The content

MettleContent has a list for each kind of thing: tags, curves, effects, abilities, and grants (see Grants and triggers). Here is the mage’s, built in code with the data builders:

use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Mana;
fn content() -> MettleContent {
    MettleContent {
        tags: ["cooldown", "cooldown.fireball", "state", "state.stunned"]
            .map(TagKey::named)
            .to_vec(),
        effects: vec![
            EffectData::instant("fireball.cost")
                .modifier(EffectModifier::new::<Mana>(Operation::Flat, -20.0)),
            EffectData::lasting("fireball.cooldown", 2.0).owner_tag("cooldown.fireball"),
        ],
        abilities: vec![
            AbilityData::new("fireball", "cast_fireball")
                .cost("fireball.cost")
                .cooldown("fireball.cooldown")
                .require(!Condition::owner_has("state.stunned")),
        ],
        ..Default::default()
    }
}
assert_eq!(content().effects.len(), 2);

Code with a type for something can name it by type instead, AbilityData::new(Key::of::<Fireball>(), Key::of::<CastFireball>()), and a typo is a compile error (see Data and code).

Its serde shape

MettleContent is Serialize and Deserialize, and so is everything in it. Whatever format your game reads, the shape is the same:

  • The content is a struct of those five lists. Each can be left out, and is empty if it is. Unknown fields are errors, so a typo in a field’s name doesn’t pass quietly.
  • Each definition is a struct with the fields its chapter lists: CurveData, EffectData, AbilityData and Grant. Fields that have a default can be left out. Unknown fields are errors here too.
  • Names are strings. A tag is written as its name, "cooldown.fireball", and a set of tags as a list of names. So is what a definition names: an effect’s name, the attributes, curves, effects, executions, calculations and routes it names, an ability’s behaviour. Each of these is a key, a Key of its kind, such as Key<AnyEffect> for an effect. Attributes are named as their types are, in snake_case unless they say otherwise, so Mana is "mana".
  • Choices are enums, written as the format writes an enum’s variant: an effect’s duration is Instant, Infinite or For with a Magnitude, and a modifier’s op is one of the Operations.

Numbers never appear in data. Content names things; mettle numbers them as it’s checked (see Names and numbers).

Handing it over

Insert it as a resource. Mettle checks all of it at once, against the attributes, behaviours and effect code the app added and the tags, effects and abilities code names, before any part runs. If it all holds, the TagRegistry, CurveRegistry, EffectRegistry and AbilityRegistry are replaced, and ContentLoaded is triggered.

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Mana;
#[derive(Component, Default, Behaviour)] struct CastFireball;
fn content() -> MettleContent {
    MettleContent {
        tags: ["cooldown", "cooldown.fireball", "state", "state.stunned"].map(TagKey::named).to_vec(),
        effects: vec![
            EffectData::instant("fireball.cost").modifier(EffectModifier::new::<Mana>(Operation::Flat, -20.0)),
            EffectData::lasting("fireball.cooldown", 2.0).owner_tag("cooldown.fireball"),
        ],
        abilities: vec![AbilityData::new(Key::of::<Fireball>(), Key::of::<CastFireball>()).cost("fireball.cost").cooldown("fireball.cooldown").require(!Condition::owner_has("state.stunned"))],
        ..Default::default()
    }
}
#[derive(Ability)]
struct Fireball;

/// The mage, granted the fireball: its content is already loaded.
fn spawn_mage(mut commands: Commands) {
    commands.spawn(Base::<Mana>::default()).grant_ability(Fireball);
}

let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins));
app.add_attribute::<Mana>()
    .add_behaviour::<CastFireball>()
    .add_ability::<Fireball>()
    .insert_resource(content())
    .add_systems(Startup, spawn_mage);
app.update();
assert_eq!(app.world_mut().query::<&Abilities>().iter(app.world()).count(), 1);

Inserted as the app is built, content is loaded in PreStartup, so Startup systems can grant and apply what it defines. Inserted later, by a system once your files have loaded, it’s loaded in the next PreUpdate.

Content split over many files, one per item or one per mod, is merged by your game: extend each list, then hand over the whole.

use bevy_mettle::prelude::*;
/// Every file's content, as one.
fn merged(files: impl IntoIterator<Item = MettleContent>) -> MettleContent {
    let mut all = MettleContent::default();
    for file in files {
        all.tags.extend(file.tags);
        all.curves.extend(file.curves);
        all.effects.extend(file.effects);
        all.abilities.extend(file.abilities);
        all.grants.extend(file.grants);
    }
    all
}
let all = merged([
    MettleContent { effects: vec![EffectData::infinite("staff")], ..Default::default() },
    MettleContent { effects: vec![EffectData::infinite("ring")], ..Default::default() },
]);
assert_eq!(all.effects.len(), 2);

As a Bevy asset, the same goes: a loader of yours deserializes each file, and a system inserts the merged content as the assets load or change.

What code names stays

An effect or ability code names (add_effect, add_ability) is one code counts on: something applies or grants it. Content has to define it, or it’s rejected: a mod can’t take it away. A mod that wants it gone redefines it doing nothing instead, and code goes on applying it, to no effect:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Effect)]
struct Haste;

let mut app = App::new();
app.add_plugins(MettlePlugins).add_effect::<Haste>();

// The game's haste, and a mod's take on it: haste, doing nothing.
let game = MettleContent {
    effects: vec![EffectData::lasting("haste", 5.0)],
    ..Default::default()
};
let modded = MettleContent {
    effects: vec![EffectData::instant("haste")],
    ..Default::default()
};
assert!(game.check(app.world()).is_ok());
assert!(modded.check(app.world()).is_ok());

// Without haste at all, it's rejected.
let errors = MettleContent::default().check(app.world()).unwrap_err();
assert_eq!(
    errors.to_string(),
    "code names effect `haste`, which content doesn't define"
);

Merging files, let a later file’s definition take an earlier one’s place by name, so a mod’s haste replaces the game’s rather than clashing with it.

Everything wrong, at once

Content that doesn’t hold is rejected as a whole: each error is logged, ContentRejected is triggered with all of them, and the content before stays. Observe it to show whoever wrote the data:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
fn report(rejected: On<ContentRejected>) {
    for error in &rejected.errors.0 {
        eprintln!("{error}");
    }
}
App::new().add_observer(report);

Tags and curves are checked first, then effects against them, then abilities against the effects. An effect’s errors show once the tags it names hold, and an ability’s once its effects do.

To ask without loading anything, such as a mod before it’s enabled, call MettleContent::check with the app’s world:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Mana;
let mut app = App::new();
app.add_plugins(MettlePlugins).add_attribute::<Mana>();
let content = MettleContent {
    tags: vec![TagKey::named("cooldown")],
    effects: vec![
        // A tag that isn't defined.
        EffectData::lasting("fireball.cooldown", 2.0).owner_tag("cooldown.fireball"),
        // A curve that isn't there.
        EffectData::instant("fireball.cost").modifier(EffectModifier::new::<Mana>(
            Operation::Flat,
            Magnitude::scaled(-20.0, "fireball.cost"),
        )),
    ],
    ..default()
};
let errors = content.check(app.world()).unwrap_err();
assert_eq!(errors.0.len(), 2);

Reloading

Insert a changed MettleContent, or change the one there, and mettle loads it again, checked as a whole. What was there keeps its number: every tag, curve, effect and ability already numbered keeps it, and new ones are numbered after them, so whatever holds a number still names the same thing. Active effects take their new definitions, and granted abilities theirs.

use bevy::prelude::*;
use bevy_mettle::prelude::*;
#[derive(Effect)]
struct Staff;

let mut app = App::new();
app.add_plugins((MinimalPlugins, MettlePlugins))
    .add_effect::<Staff>()
    .insert_resource(MettleContent {
        effects: vec![EffectData::infinite("staff")],
        ..default()
    });
app.update();
let staff = app.world().resource::<EffectRegistry>().id_of::<Staff>();

// A mod adds an amulet, whose name comes first.
app.insert_resource(MettleContent {
    effects: vec![EffectData::infinite("amulet"), EffectData::infinite("staff")],
    ..default()
});
app.update();
let effects = app.world().resource::<EffectRegistry>();
assert_eq!(effects.id_of::<Staff>(), staff, "the staff keeps its number");
assert!(effects.id("amulet").unwrap() > staff.unwrap(), "the amulet's is new");

A fresh app numbers the same content in order of name. So a server and its clients agree as long as each loads the same content the same way: start from the same content, and reload the same changes.

Replace it as your files change:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
fn reread() -> Option<MettleContent> { None }
fn reload(mut commands: Commands) {
    if let Some(content) = reread() {
        commands.insert_resource(content);
    }
}

A reload with a mistake in it is rejected, and the game keeps running on the content before.

Pitfalls

  • Code before content. Content naming an attribute, a behaviour or effect code the app hasn’t added yet is rejected. Add them all as the app is built, before content is loaded. The same goes for the effects and abilities code names with add_effect and add_ability: content that doesn’t define one is rejected.
  • What code names, content keeps. A mod can’t delete an effect or ability code adds; it redefines it doing nothing. See What code names stays.
  • Nothing until it’s loaded. Applying an effect or granting an ability before its content is loaded finds nothing, and warns.
  • One reload, everywhere. A server that reloads alone numbers new things its clients don’t know. Reload both sides alike. Over lightyear, a client that numbers content otherwise is told: see Checking content.

Testing

Gameplay is easiest to test headless, one tick at a time. You give the mage mana and the fireball, cast it, tick, and check what it paid and what was told. bevy_harness does the ticking. Each tick moves time by one fixed step, so tests are repeatable.

Add it as a dev-dependency:

[dev-dependencies]
bevy_harness = { git = "https://github.com/declaredlost/bevy_harness" }

The fireball costs mana, and cools down

This test casts the fireball twice. The first cast pays 20 mana; the second fails, since the first’s cooldown still lasts. Two seconds on, it can be cast again:

use bevy::prelude::*;
use bevy_mettle::prelude::*;
use bevy_harness::TestApp;

#[derive(Attribute)] #[attribute(base = 100.0, min = 0.0)] struct Mana;
#[derive(Component, Default, Behaviour)] struct CastFireball;
#[derive(Ability)]
struct Fireball;

/// The mage's content: the fireball, its cost and its cooldown.
fn content() -> MettleContent {
    MettleContent {
        tags: ["cooldown", "cooldown.fireball"]
            .into_iter()
            .map(TagKey::named)
            .collect(),
        effects: vec![
            EffectData::instant("fireball.cost")
                .modifier(EffectModifier::new::<Mana>(Operation::Flat, -20.0)),
            EffectData::lasting("fireball.cooldown", 2.0).owner_tag("cooldown.fireball"),
        ],
        abilities: vec![
            AbilityData::new(Key::of::<Fireball>(), Key::of::<CastFireball>())
                .cost("fireball.cost")
                .cooldown("fireball.cooldown"),
        ],
        ..default()
    }
}

/// An app with the mage, granted the fireball.
fn mage_app() -> (TestApp, Entity) {
    let mut app = TestApp::builder()
        .plugins(MettlePlugins)
        .with(|app| {
            app.add_attribute::<Mana>()
                .add_behaviour::<CastFireball>()
                .add_ability::<Fireball>()
                .insert_resource(content());
        })
        .watch_triggered::<ContentRejected>()
        .watch_triggered::<AbilityFailed>()
        .build();
    app.assert_triggered_none::<ContentRejected>(|_| true);
    let mage = app.spawn(Base::<Mana>::default());
    app.world_mut().grant_ability(mage, Fireball).unwrap();
    app.tick();
    (app, mage)
}

fn main() {
// #[test]
// fn the_fireball_costs_mana_and_cools_down() {
let (mut app, mage) = mage_app();

// Cast: 20 mana paid.
let fireball = app.world_mut().activate_ability(mage, Fireball).unwrap().ability();
app.tick();
app.entity(mage).assert::<Current<Mana>>(|mana| mana.get() == 80.0);

// It's running, as a `CastFireball` on the ability.
app.entity(fireball).assert_has::<CastFireball>();

// End it, and cast again: still cooling down.
app.world_mut().end_ability(fireball);
app.world_mut().activate_ability(mage, Fireball).unwrap_err();
app.assert_triggered::<AbilityFailed>(|failed| failed.reason == Failure::Cooldown);

// Two seconds on, it can be cast again.
app.secs(2.0);
app.world_mut().activate_ability(mage, Fireball).unwrap();
app.tick();
app.entity(mage).assert::<Current<Mana>>(|mana| mana.get() == 60.0);
// }
}

In your crate, the body is a #[test] function. activate_ability on the world returns what activating came to, an Activated, or why it didn’t activate: running here, the usual, or only asked of the server, a client’s server-run ability. .ability() is the granted ability’s entity either way, and its behaviour’s component is on it while it’s active.

What helps

  • Content in code. The definitions’ builders (EffectData::instant, AbilityData::new) make a test’s content short. Check the game’s real content in a test of its own: load it into MettleContent as the game does, and assert check holds against an app with the game’s code added, its add_effect and add_ability types too.
  • Watch what’s told. watch_triggered::<AbilityFailed>() records every failure, so assert_triggered can say why a cast didn’t happen. The same works for AttributeChanged<A>, EffectApplied and the rest.
  • Tick before reading. A cost lowers Base<Mana> as it’s paid; Current<Mana> follows the next time Mettle runs. Tick once before asserting on Current.
  • Seconds, not ticks. app.secs(2.0) runs as many ticks as two seconds take, so a test doesn’t depend on the tick rate.

Feature flags

bevy_mettle

FeatureDefaultEnables
attributeYesAttributes: bevy_mettle_attribute as bevy_mettle::attribute. Adds AttributePlugin to MettlePlugins.
effectYesEffects: bevy_mettle_effect as bevy_mettle::effect. Adds EffectPlugin to MettlePlugins. Turns on attribute.
abilityYesAbilities: bevy_mettle_ability as bevy_mettle::ability. Adds AbilityPlugin to MettlePlugins. Turns on effect.
lightyearNoPrediction over lightyear: bevy_mettle_lightyear as bevy_mettle::lightyear. Adds MettleNetPlugin to MettlePlugins, which then goes after lightyear’s plugins. Turns on ability.

bevy_mettle_core is always there, as bevy_mettle::core: gameplay tags, curves and conditions, and the Mettle schedule. MettleCorePlugin and MettleContentPlugin are always in MettlePlugins, and MettleContent holds what the enabled parts define: tags and curves always, effects with effect, abilities and grants with ability.

The prelude brings in every enabled part’s prelude, with MettlePlugins, MettleContent, ContentLoaded and ContentRejected. Core’s brings the schedule: Mettle, MettleSet, MettleSystems and MettleCorePlugin. That’s the derives and the app traits (AttributeApp, TagApp, EffectApp, BehaviourApp, AbilityApp); the specs (EffectSpec, AbilitySpec); every registry and its numbers (TagRegistry and TagId, CurveRegistry and CurveId, AttributeRegistry, EffectRegistry and EffectId, AbilityRegistry and AbilityId, and the rest); the data types with their AnyEffect and AnyAbility names; and the replicas, ModifiersReplica among them. AnyCurve, AnyAttribute, AnyBehaviour, AnyExecution, AnyCalculation and AnyRoute are in their crates’ modules, for code that writes those ids out.

The parts

The parts’ crates have no features of their own. Each depends on the parts before it: bevy_mettle_effect on bevy_mettle_attribute, bevy_mettle_ability on both, and bevy_mettle_lightyear on all three.