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,ManaandResistanceare marker types with their rules derived: a base, limits, rounding. Each actor keeps aBaseand aCurrentof 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.
| Crate | Contains |
|---|---|
bevy_mettle | Every part, with one prelude and one plugin group |
bevy_mettle_core | Gameplay tags, curves and conditions |
bevy_mettle_attribute | Attributes, modifiers and reads in context |
bevy_mettle_effect | Effects |
bevy_mettle_ability | Abilities |
bevy_mettle_lightyear | Prediction over lightyear |
bevy_mettle_macros | The 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.
| Feature | Adds |
|---|---|
attribute | Attributes and modifiers (default) |
effect | Effects; turns on attribute (default) |
ability | Abilities; turns on effect (default) |
lightyear | Prediction 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:
Effects: active effects ticked. Requirements weighed, periods come round, time run out.Attributes: bases synced, modifiers folded, values worked out.Settle: effects reading another actor’s attribute live follow it, and what that changed is folded again, until nothing moves.Publish:Current<A>written,AttributeChangedtold.
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_attributebefore the plugins.add_attributeaddsAttributePluginitself if it isn’t there yet.MettlePluginsadded after it adds it again, and Bevy panics. AddMettlePluginsfirst.- Time. Effects run out by Bevy’s time.
DefaultPluginsandMinimalPluginsboth includeTimePlugin. - 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 inUpdate. - Lightyear. With the
lightyearfeature,MettlePluginsalso addsMettleNetPlugin, which needs lightyear’s plugins. AddMettlePluginsafter 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,
LifeandMana, so a system asks forCurrent<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>()givesLifethe nextAttributeId; behaviours, routes, executions and calculations get theirs the same way. Code names each by type: reads and changes, such asvalue::<Life>(),Current<Life>andBaseChange::of::<Life>, find its number for you.world.attribute_id::<Life>()asks for it, as Bevy hasworld.component_id::<T>(), to match what an event names. - Content is numbered as it’s checked. Tags, curves, effects and
abilities get a
TagId, aCurveId, anEffectIdand anAbilityId, 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, itsEffectDeforAbilityDef, 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
| Part | Writes | Reads |
|---|---|---|
| Attributes | Current<A>, from bases and modifiers | Base<A>, Modifiers of the actor’s sources, its OwnedTags |
| Effects | Modifiers and granted tags while they last; bases as they execute | Their registry, attributes, owned tags |
| Abilities | Their behaviour’s component; costs and cooldowns as effects | Their registry, attributes, owned tags |
| Your systems | Bases, OwnedTags, effects applied, abilities activated | Current<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:
- Effects: requirements weighed, periods come round, time run out.
- Attributes: changed bases synced in, changed modifiers folded, owned tags told, values worked out.
- Settle: effects whose live reads moved are worked out again, and attributes folded again, until nothing moves.
- 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 inUpdatesees what the frame’s last fixed step published. - A change made after mettle ran. A base changed after
MettleSystemsreachesCurrentthe 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 ofatoz,0to9and_.Fireanddamage..firearen’t tags. - A gap. Every tag’s parent has to be a tag of its own. Defining
damage.elemental.firewithoutdamage.elementalis 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_tagsruns inAttributeFold. WithoutAttributePlugin, 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:
interpolation | Between two keys |
|---|---|
Linear | A straight line from one to the next. The default. |
Constant | Each 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
Constantcurve 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:
| Term | Weight |
|---|---|
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 onlydamage.elemental. Notof a ramp is a ramp.Not(FactRamp { .. })weighs one less the ramp, so it holds in part too.- An empty
Allalways holds; an emptyAnynever 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.
| Setting | Meaning | Unless it says |
|---|---|---|
base = 50.0 | What its base starts at | 0.0 |
min = 0.0, min = Other | The least it can be: a number, or another attribute’s current value | none |
max = .. | The most it can be, like min | none |
rounding = Down, Up, Nearest | It’s a whole number, rounded that way | none |
on_limit = Clamp, Ratio | What its base does as a limit that’s another attribute moves | Clamp |
name = ".." | The name data uses for it | the 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 whoseMaxLifedrops 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 asMaxLiferises 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.Currentis overwritten the next time attributes are kept. - Reading too early. A base set this tick shows in
CurrentonceMettlehas run. Order your systems afterMettleSystems. - The same name twice. Two attribute types named alike (by the
namesetting 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.
| Operation | Builder | What it does |
|---|---|---|
Base | ModifierSpec::base | Replaces the base. The one applied last counts |
Flat | ModifierSpec::flat | Added to the base |
Additive | ModifierSpec::additive | A fraction, summed with the others of its kind: +20% and +30% are +50% (“increased”) |
Multiplicative | ModifierSpec::multiplicative | A fraction, multiplied in on its own: +20% and +30% are +56% (“more”) |
Divide | ModifierSpec::divide | A fraction, summed, then divided by: +100% halves it, and more never reaches 0 (cooldown recovery) |
Unscaled | ModifierSpec::unscaled | Added after everything else, scaled by nothing |
Override | ModifierSpec::overriding | The 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
ModifierOfwas last set: an effect applied, an item equipped; - the actor’s own modifiers come as its
Modifierswas 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’sModifierOf: 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
Modifiersand theModifierOfthe 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%. Write0.2. - Expecting two overrides to combine. Only the last applied counts.
- A modifier on the wrong entity. A source needs
ModifierOf. Without it, itsModifierscount for the source itself, if it has attributes. - A source replicated without its replica. Replicating a source’s
ModifierOfalone shows nothing on a client: itsModifiersaren’t replicated. Put aModifiersReplicaon it. - A limit that reads itself. If an attribute’s limit or
perleads 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:
| Builder | What 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
Currentfor a hit.Currentleaves 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 ownscooldown.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.
| Field | What it does |
|---|---|
name | What content and an Effect type call it by |
tags | What it is: what ending, blocking and requirements match on |
duration | Instant, Infinite, or For(..) so many seconds |
period | Executes every so often while it lasts, instead of its modifiers counting all along |
modifiers | What it changes: modifiers while it lasts, or the base as it executes |
executions | Formulas in code, run as it executes: see Effects in code |
owner_tags | Tags the target, its owner, holds while it counts |
cues | Cues held on the target while it counts, and played each time it executes: see Cues |
require | What has to hold for it to apply at all |
ongoing | What has to hold for it to count; while it doesn’t, it’s inhibited |
end_if | What ends it as soon as it holds |
ends | Active effects with any of these tags, ended as it applies |
blocks | Effects with any of these tags can’t apply while it counts |
stacking | How applying it again stacks: see Stacking |
on_apply, on_remove, on_expire | Effects applied to the target as it applies, comes off, or runs out of time |
Durations
Instantexecutes 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.Infinitelasts 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:
| Op | What executing does to the base |
|---|---|
Flat, Unscaled | Adds the value |
Additive, Multiplicative | Scales by 1 + value |
Divide | Divides by 1 + value |
Base, Override | Sets 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:
ReadTaggedmatches the read’s tags: +20% damage with fire hits.OwnerHasmatches the target’s owned tags.TargetHasmatches the owned tags of the read’s target.SourceHasmatches 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:
| Term | How much |
|---|---|
Value(f32) | That number. A plain f32 converts to it. |
Level | The 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:
| Combination | How 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:
| Unreal | Mettle |
|---|---|
| Scalable float | Product[Value, Curve]: Magnitude::scaled(10.0, "fireball.damage") |
Attribute based, coef × (attr + pre) + post | Sum and Product of an Attribute, each part any magnitude, a curve too |
| Attribute based, by an attribute curve | Curve { at: Attribute } |
| Custom calculation class, its coefficient | Product[Custom, Value] |
| Source and target tag filters | The 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:
| Field | What it is |
|---|---|
attribute | The attribute, by name |
from | Whose: Source unless it says, or Target |
value | Which of its values: Current unless it says, Base, or Bonus (its current value less its base) |
snapshot | Captured and kept, unless it says false: then it’s read live |
tags | What 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.
requireis checked once, as it’s applied. If it doesn’t hold, the effect doesn’t apply, andEffectBlockedsays why. Burning doesn’t apply to a mage who ownsimmune.fire.ongoingis 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_ifis 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_applyas it applies, and again as it gains a stack.on_removeas it comes off, however that happens.on_expireas it runs out of time, afteron_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_effectthat 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
perof 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
Flatmodifier adds. Burning’s spec setsparam.damageto 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:
| Field | What it is |
|---|---|
level | What curves are read at: 1 unless it says |
params | Numbers the applying code sets, for Param magnitudes |
context | What else it carries: who applied it, what struck, where. See Context |
snapshot | Its source’s attributes as it was made, if the code making it took them: see Snapshots |
activation | The 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:
| Type | What it is |
|---|---|
Causer | What struck: the arrow, the trap. Abilities set their actor |
SourceObject | What it came from: the bow, an item |
AbilityRef | The ability that applied it: its id, and its level as it was activated. Set by abilities for what they apply |
Hit | Where it hit: the point, and the surface’s normal |
Origin | Where 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
CauserandSourceObject.
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:
- There’s an
EffectRegistry, and it has the effect: its type was added withadd_effect, or its number is one content has. If not, nothing happens but a warning. - The target owns tags. Any actor with an attribute’s
Basedoes. WithoutOwnedTags, nothing happens but a warning. - The effect’s
requireholds, and no effect on the target makes it immune. If not,EffectBlockedsays why, and nothing else happens. - Active effects it
endsend.
Then:
- An instant effect executes, changing the target’s bases, and chains its
on_applyeffects. - 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
ongoingrequirement holds, it starts counting, and executes at once if its period sayson_apply.EffectAppliedis triggered, and itson_applyeffects 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:
EffectWorld::snapshoton the world:world.snapshot(spec).Snapshots, a system param:snapshots.take(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()andid(): its definition, and its number, anEffectId.stacks(): how many stacks of it there are.remaining(): the seconds it has left, orNoneif it’s infinite.is_inhibited(): whether itsongoingrequirement 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:
- If its
end_ifholds, it ends. - If its
ongoingrequirement stopped holding, it’s inhibited. If it holds again, the effect counts again. - If it’s counting, its period comes round as often as it’s due, and its time runs down.
- 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:
| Event | When |
|---|---|
EffectApplied | A lasting effect was spawned on the target: effect is its entity |
EffectStacked | An effect gained or lost a stack: stacks is how many now |
EffectBlocked | An effect didn’t apply: reason is Requirement, Effect (another blocks it) or StackLimit |
EffectExecuted | An 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 |
EffectRemoved | An 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
ActiveEffectoff, and it’s told as removed and itson_removechained, once commands are applied: at once on aWorld, which flushes as it despawns. Effects despawned with their target tell nothing: there’s no one left to tell. - Commands wait.
apply_effecton commands applies when commands are applied, later in the frame. Read the result on the next system, or useEffectWorldin an exclusive system. - An actor with no attributes owns no tags. Effects need the target’s
OwnedTags. EveryBasebrings them; on an actor without one, addOwnedTagsyourself.
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:
| Field | What it does | Unless it says |
|---|---|---|
by | Whose applications stack together: Target, everyone’s on the target, or Source, each source’s apart | Target |
limit | The most stacks | required |
reapply | What a new application does to the time left: Refresh starts it over, Keep leaves it | Refresh |
reset_period | Whether a new application starts the period over | false |
expire | What running out of time does: ClearAll, RemoveOne, or Refresh | ClearAll |
overflow | Effects applied in its place when it’s at its limit | none |
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
reapplyisRefresh; - starts the period over, if
reset_periodis set; - works the effect’s modifiers out again for the new count;
- triggers
EffectStackedwith how many stacks there are now; - chains the effect’s
on_applyeffects.
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:
ClearAlltakes it off, every stack at once.EffectRemovedsays it expired, and itson_removeandon_expireeffects are chained.RemoveOnetakes one stack off and starts the time over for the rest, tellingEffectStacked. The last stack comes off asClearAllwould.Refreshstarts 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
stackingare 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
endsthe 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
MagnitudeofCustom. 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 | |
|---|---|
world | The world, read only: the raw escape hatch, for what a capture can’t read |
target | Who the effect is applied to |
def | The effect’s checked definition: its number, name and tags |
effect() | The effect’s number, an EffectId |
spec | The EffectSpec applied |
stacks | How 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
Instantduration 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
| Field | What it does |
|---|---|
tags | What the ability is. Blocking, ending and activating by tag match on these. |
behaviour | The component in code that runs it, added with add_behaviour. |
cost | An instant effect it pays as it commits. It can’t activate if paying would take an attribute below 0. |
cooldown | A lasting effect applied as it commits. It can’t activate while its owner owns one of that effect’s owner tags. |
require | A condition that has to hold for it to activate. |
end_if | A condition that interrupts it: while it’s active, it’s cancelled as soon as this holds. See Granting and activating. |
ends | Active abilities tagged any of these are cancelled as it activates. |
blocks | Other abilities tagged any of these can’t activate while it’s active. |
owner_tags | Tags its owner holds while it’s active. |
cues | Cues held on its owner while it’s active, such as a cast’s glow. See Cues. |
triggers | What activates it besides being asked. See Grants and triggers. |
commit | When its cost and cooldown are applied: OnActivate or Manual. |
net | How 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(..)]:
| Setting | Meaning | Unless it says |
|---|---|---|
name = ".." | The name data names it by | its type’s, in snake_case |
input = Type | What its abilities are activated with: an aim, a target | none |
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::Manualleaves it to the behaviour. A spell with a cast time commits when the cast finishes, withcommit_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_abilitythat 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_abilityandadd_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:
- Granted. The mage has a fireball, and it isn’t active already.
Otherwise
Failure::NotGrantedorFailure::Active. - Not cooling down. The mage owns none of the cooldown effect’s owner
tags.
Otherwise
Failure::Cooldown. - Affordable. Paying the cost leaves no attribute below 0. Otherwise
Failure::Cost. - Not blocked. No other active ability
blocksthe fireball’s tags. OtherwiseFailure::Blocked. - Required.
requireholds. OtherwiseFailure::Requirement. - Not interrupted.
end_ifdoesn’t hold. OtherwiseFailure::Interrupted.
Then it activates:
- with
Commit::OnActivate, its cost and cooldown are applied first, so its behaviour starts paid for; - active abilities it
endsare cancelled; - the mage owns its
owner_tags; ActiveAbilityis put on the ability’s entity, with its behaviour’s component;AbilityActivatedis 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:
| Command | What 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,Okor theFailureit would fail with;can_afford(ability): whether its owner could pay its cost;cooldown_remaining(ability): the seconds left on its cooldown,Noneif it isn’t cooling down, andf32::INFINITYif nothing that cools it runs out. It says cooling down exactly whencan_activatedoes.
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:
AbilityActivated: which ability, its entity, and the activation.AbilityFailed: which ability, and why, as aFailure.AbilityEnded: which ability, its entity, and whether it was cancelled.
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
OwnedTagsto activate anything. AnyBase<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_abilityas a command runs when commands are applied, and says nothing back. ObserveAbilityFailedto hear why one didn’t activate. - Ending isn’t refunding. Cancel a
Commit::Manualability 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:
| Trigger | Activates 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, aTagId, 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 itsEffectId.
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, inCueSystems. 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:
| Role | What it does |
|---|---|
Standalone | Plays alone: decides everything, replicates nothing. The default. |
Server | Decides everything, and keeps what clients need to show it. |
Client | Shows 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 itsCurrent<A>. That’s the value to replicate. - On a client, an actor marked
Remoteshows itsServed<A>as itsCurrent<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
Provisionalis a prediction. While one counts for aRemoteactor, 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 itsEffectOf, 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, anEffectId, 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:
- The fireball activates at once, under a new
Activation. Its behaviour runs, and its cost and cooldown are applied as predictions. - An
AbilityRequestis written, as a message, for your transport to send to the server: the actor, the ability by its number, the activation, and its input,NoInputfor a behaviour that takes none (the default,AbilityRequest<NoInput>). New input as it runs is anAbilityInput.
What’s applied under a predicted activation is marked:
- A lasting effect, the cooldown, gets
PredictedUnderandProvisional. 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.
| Policy | On the client | On the server |
|---|---|---|
Predicted (default) | Runs at once, and asks the server | Runs it, and confirms or rejects |
ServerOnly | Asks the server, running nothing | Runs it; the client sees what it does through replicas and cues |
ServerInitiated | Runs its behaviour as the server says it started, not paying or checking again | Runs it, however it started, and tells the controlling client: AbilityStarted |
LocalOnly | Runs it, and sends nothing | Runs 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);
Predictedis what a player’s own abilities want: what follows is how it’s predicted and resolved.ServerOnlysuits 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 toActivated::Asked: nothing runs there yet. Rejected, the client is told byAbilityFailed, with nothing to undo; a client can’t cancel it.ServerInitiatedsuits 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 toActivated::Askeduntil 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.LocalOnlysuits 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
AbilityFailedsays why. What its behaviour applied is undone only if it was applied under the activation: throughActiveAbility::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_abilityorcancel_abilities: a channel let go of, a cast called off. It asks the server to cancel its copy with anAbilityCancelRequest. - 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
falsebut leaves the ability running. The server weighs both, and itsAbilityEndends 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:
- lightyear’s own plugins:
ServerPluginson the server,ClientPluginson a client; MettlePlugins, withNetRoleset toServerorClient;- your attributes, and
replicate_attributefor each one clients should see; - your behaviours, and
replicate_inputfor each input they take; - 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
EffectReplicaand every granted ability’sAbilityReplica, with theirEffectOfandAbilityOf, to the clientControlledBythe actor alone: it predicts with them. They go and come with the actor (ReplicateLikeit), 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. SetMettleNetPlugin { 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>andServed<A>, for the attributes you named withreplicate_attribute.Base<A>is what the client works predictions out on.Served<A>is what it shows. On the server, every actor withBase<A>servesA; - 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’sModifierOf, 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 onMettleChannel, reliable and ordered, and withreplicate_inputits requests with input and its new input, in order after them. An input is sent as it is, so it derivesSerialize,DeserializeandMapEntities(a target entity is mapped to the server’s). - Checking. The server takes a request, or input, only for an actor
ControlledBythe client sending it. A request for any other is rejected back to that client asFailure::NotGranted, with a warning, so its prediction is undone; input for any other is dropped. - Answering. The server’s
AbilityReplygoes 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’sTime: a server app withoutMinimalPluginsorDefaultPlugins(a bare test app) panics as that system first runs. AddTimePlugin.- 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
ModifiersReplicashows no modifiers on a client. - Control. A mage without
ControlledByits 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,AbilityDataandGrant. 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’sname, the attributes, curves, effects, executions, calculations and routes it names, an ability’s behaviour. Each of these is a key, aKeyof its kind, such asKey<AnyEffect>for an effect. Attributes are named as their types are, insnake_caseunless they say otherwise, soManais"mana". - Choices are enums, written as the format writes an enum’s variant:
an effect’s
durationisInstant,InfiniteorForwith aMagnitude, and a modifier’sopis one of theOperations.
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_effectandadd_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 intoMettleContentas the game does, and assertcheckholds against an app with the game’s code added, itsadd_effectandadd_abilitytypes too. - Watch what’s told.
watch_triggered::<AbilityFailed>()records every failure, soassert_triggeredcan say why a cast didn’t happen. The same works forAttributeChanged<A>,EffectAppliedand the rest. - Tick before reading. A cost lowers
Base<Mana>as it’s paid;Current<Mana>follows the next timeMettleruns. Tick once before asserting onCurrent. - 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
| Feature | Default | Enables |
|---|---|---|
attribute | Yes | Attributes: bevy_mettle_attribute as bevy_mettle::attribute. Adds AttributePlugin to MettlePlugins. |
effect | Yes | Effects: bevy_mettle_effect as bevy_mettle::effect. Adds EffectPlugin to MettlePlugins. Turns on attribute. |
ability | Yes | Abilities: bevy_mettle_ability as bevy_mettle::ability. Adds AbilityPlugin to MettlePlugins. Turns on effect. |
lightyear | No | Prediction 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.