Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

bevy_arbor is AI for Bevy. It gives your agents behavior trees to decide what to do, and perception to tell them what they sense. Environment queries help them pick a place or a target. Smart objects let them find and use things in the world. Navigation connects them to your game’s movement.

This book builds one agent from start to finish: a guard in a 2D side-scroller. Here it is after the first chapter:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)] struct Guard;
#[derive(Component, Clone)] struct Patrol { from: f32, to: f32 }
#[derive(Component, Clone)] struct Chase;
fn sees_intruder(seen: &Perceived<Sight>) -> bool {
    seen.active().any(Percept::is_hostile)
}

fn spawn_guard(mut commands: Commands) {
    commands.spawn((
        Guard,
        Sight::new(12.0).with_lose_range(15.0),
        Team(1),
        Facing(Dir3::X),
        Transform::from_xyz(0.0, 0.0, 0.0),
        BehaviorTree::new(behavior_tree! {
            Forever => {
                Select => {
                    when(sees_intruder) => task(Chase),
                    task(Patrol { from: -10.0, to: 10.0 }),
                }
            }
        }),
    ));
}

The guard patrols until it sees the intruder. Then it chases. When the intruder gets away, the guard goes back to its patrol. Later chapters teach it to shoot, reload, take cover, call for help and man a turret.

What you get

  • Reactive behavior trees. A branch switches the moment its condition changes. What was running is canceled.
  • Trees that sleep. A tree is ticked only when something it waits on changes. An idle tree costs almost nothing.
  • Tasks as entities. Each task the tree starts is an entity. Your own systems do the work.
  • Perception. Sight, hearing, touch, and allies sharing what they know. It works in 2D and 3D, and you can add senses of your own.
  • Environment queries. Score places or things around an agent and pick the best one, such as cover or a target.
  • Smart objects. Things in the world with slots an agent can claim and use, such as a turret with a gunner’s seat.
  • Navigation, your way. A MoveTo task and path costs for queries, over whatever pathfinding and movement your game has.
  • No physics required. Line of sight is a system you can replace. With avian, line of sight and touch come ready-made.

The crates

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

CrateContains
bevy_arborEvery part, with one prelude and one plugin group
bevy_arbor_btBehavior trees, tasks and decorators
bevy_arbor_perceptionSenses, sensors and memory
bevy_arbor_eqsEnvironment queries
bevy_arbor_smartSmart objects
bevy_arbor_navNavigation: MoveTo and path costs
bevy_arbor_gizmosDebug drawing
bevy_arbor_avian2d, bevy_arbor_avian3dLine of sight and touch with avian
bevy_arbor_macrosThe behavior_tree! macro

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

Installation and features

Add bevy_arbor to your Cargo.toml. The guard needs trees and perception:

[dependencies]
bevy_arbor = { version = "0.1", features = ["perception"] }

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

Behavior trees (bt) are on by default. Everything else is a feature.

FeatureAdds
btBehavior trees (default)
perceptionSenses and memory
eqsEnvironment queries; turns on bt and perception
smartSmart objects; turns on eqs
navNavigation: MoveTo and path costs, over your pathfinding; turns on bt
gizmosDebug drawing for perception, queries, smart objects and navigation
avian2d, avian3dLine of sight and touch with avian

Feature flags has the full list, including each crate’s own features.

Adding the plugins

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

use bevy::prelude::*;
use bevy_arbor::prelude::*;
fn main() {
    App::new()
        .add_plugins((DefaultPlugins, ArborPlugins))
        .run();
}

Trees and perception run in FixedUpdate. Perception works in 3D by default. The guard lives in a side-scroller, so switch it to 2D with with_space:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
fn main() {
    App::new()
        .add_plugins((
            DefaultPlugins,
            ArborPlugins.with_space(Space::Planar),
        ))
        .run();
}

To change a plugin, use set, as with Bevy’s own plugin groups. To run trees in another schedule, set TreePlugin::new: ArborPlugins.with_space(Space::Planar).set(TreePlugin::new(Update)). with_space sets PerceptionPlugin, so to change perception’s schedule as well, set that one with both: ArborPlugins.set(PerceptionPlugin::new(Update).with_space(Space::Planar)).

Using one part alone

The parts don’t depend on each other. Perception writes components on the agent, and trees read them. To use one part alone, depend on its crate, such as bevy_arbor_bt or bevy_arbor_perception, and add its plugin.

If you add the plugins one by one, also add ArborSystemsPlugin. It runs the parts in the right order. How it fits together explains that order.

Pitfalls

  • Time. Trees need Bevy’s TimePlugin for waits, timeouts and cooldowns. DefaultPlugins and MinimalPlugins both include it. TreePlugin warns if it’s missing.
  • Gizmos. ArborGizmosPlugin isn’t in ArborPlugins, because it needs a renderer. Add it yourself. Debugging shows how.
  • Both avian features. With avian2d and avian3d both on, neither is in the prelude, since their names clash. Use bevy_arbor::avian2d::... or bevy_arbor::avian3d::... instead.

Your first agent

In this chapter the guard learns to patrol. Then it learns to chase the intruder when it sees one.

The app

Start with the plugins from Installation. Perception is set to 2D, and the task systems run after ArborSystems:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
fn spawn(_: Commands) {}
fn patrol() {}
fn main() {
    App::new()
        .add_plugins((
            DefaultPlugins,
            ArborPlugins.with_space(Space::Planar),
        ))
        .add_systems(Startup, spawn)
        .add_systems(FixedUpdate, patrol.after(ArborSystems))
        .run();
}

Patrolling

A tree decides what to do. A task does it. Patrol is a task: a component of yours, with what the guard needs to know.

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)]
struct Guard;

#[derive(Component, Clone)]
struct Patrol {
    from: f32,
    to: f32,
}

fn guard() -> Behavior {
    behavior_tree! {
        Forever => {
            task(Patrol { from: -10.0, to: 10.0 })
        }
    }
}

fn spawn(mut commands: Commands) {
    commands.spawn((
        Guard,
        Facing(Dir3::X),
        Transform::from_xyz(0.0, 0.0, 0.0),
        BehaviorTree::new(guard()),
    ));
}

When the tree reaches task(Patrol { .. }), it spawns a task entity with a Patrol component. The task is linked to the guard. The tree keeps it alive until the branch ends.

Forever restarts its child whenever it finishes. Here, the patrol never finishes, so it just keeps going.

Doing the work

Your systems do the work of a task. Tasks pairs each task with components of its agent. Here it pairs each Patrol with the guard’s transform and facing:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Patrol { from: f32, to: f32 }
fn patrol(
    mut tasks: Tasks<&Patrol, (&mut Transform, &mut Facing)>,
    time: Res<Time>,
) {
    tasks.for_each(|patrol, _task, (mut transform, mut facing)| {
        let x = transform.translation.x;
        // Only a turn changes `Facing`, so the guard's tree isn't woken
        // every tick.
        if x >= patrol.to {
            facing.set_if_neq(Facing(Dir3::NEG_X));
        } else if x <= patrol.from {
            facing.set_if_neq(Facing(Dir3::X));
        }
        transform.translation += facing.0 * 3.0 * time.delta_secs();
    });
}

The guard walks to one end, turns, and walks back. It keeps Facing up to date, so it looks where it walks. In a side-scroller the sprite flips but the transform doesn’t turn, so sight reads Facing instead.

The second argument is the task’s Task component. A task that should end reports through it, with task.succeed() or task.fail(). The patrol never ends, so it ignores it.

Seeing the intruder

Now give the guard eyes, and something to see:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)] struct Guard;
#[derive(Component, Clone)] struct Patrol { from: f32, to: f32 }
#[derive(Component, Clone)]
struct Chase;

#[derive(Component)]
struct Intruder;

fn sees_intruder(seen: &Perceived<Sight>) -> bool {
    seen.active().any(Percept::is_hostile)
}

fn guard() -> Behavior {
    behavior_tree! {
        Forever => {
            Select => {
                when(sees_intruder) => task(Chase),
                task(Patrol { from: -10.0, to: 10.0 }),
            }
        }
    }
}

fn spawn(mut commands: Commands) {
    commands.spawn((
        Guard,
        Sight::new(12.0).with_lose_range(15.0),
        Team(1),
        Facing(Dir3::X),
        Transform::from_xyz(0.0, 0.0, 0.0),
        BehaviorTree::new(guard()),
    ));
    commands.spawn((
        Intruder,
        Perceivable,
        Team(2),
        Transform::from_xyz(30.0, 0.0, 0.0),
    ));
}

The guard sees 12 units ahead. Once it sees something, it keeps seeing it up to 15 units away. The intruder is Perceivable and on another team, so to the guard it is hostile.

Select runs the first branch whose condition passes. While the guard sees the intruder, that’s Chase. Otherwise it’s Patrol.

sees_intruder is a typed condition: a plain function of components on the agent. The tree asks it again only when Perceived<Sight> changes. That happens only when something comes into view or leaves it. In between, the tree sleeps.

Chasing

The chase system finds the intruder among what the guard sees, and walks toward it:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Chase;
fn chase(
    mut tasks: Tasks<&Chase, (&Perceived<Sight>, &mut Transform)>,
    targets: Query<&GlobalTransform>,
    time: Res<Time>,
) {
    tasks.for_each(|_, _, (seen, mut transform)| {
        let target = seen
            .active()
            .find(|percept| percept.is_hostile())
            .and_then(|percept| targets.get(percept.entity()).ok());
        if let Some(target) = target {
            let toward = (target.translation() - transform.translation).normalize_or_zero();
            transform.translation += toward * 4.0 * time.delta_secs();
        }
    });
}

Add chase next to patrol, after ArborSystems.

When the intruder comes into view, the tree wakes. sees_intruder passes. The Patrol task is despawned and a Chase task is spawned, in the same tick. When the intruder gets away, the opposite happens.

Pitfalls

  • Live positions. Perceived doesn’t hold where things are now. That would change it every frame and wake the tree for nothing. Read the target’s transform, as chase does.
  • System order. Run task systems after ArborSystems. Otherwise a task spawned this tick may wait a tick before it moves.
  • Task components must be Clone. The tree spawns a fresh copy each time the branch starts.

Next, How it fits together shows how these parts share a tick. Building trees teaches the guard to fight.

How it fits together

The parts of bevy_arbor don’t know about each other. They meet in components on the agent. This chapter shows who writes what, and in which order.

Who writes what

PartWritesReads
PerceptionPerceived<S> on the agent, one per senseTransforms, Perceivable, noises, touches
TreesTask entitiesComponents on the agent, through decorators
QueriesFound<Q> on the agentTransforms, Perceived<S>, your components
Your systemsFacts on the agent, and the work of tasksTasks, and anything else

For the guard, perception writes Perceived<Sight>. The tree reads it through sees_intruder, and spawns a Chase task. Your chase system moves the guard.

One tick

Each part runs inside the ArborSystems set, in FixedUpdate by default:

  1. Perception gathers what each agent might sense. Your systems can block or classify it. What’s left is written to Perceived<S>.
  2. Trees that something woke are ticked. A condition’s component changed, a task reported, or a timer ran out. A woken tree gets one pass, which runs until a task is under way. Forever restarts its child at most twice in a tick, so a tree that does nothing can’t hang.
  3. Queries the trees asked for are run, within a budget.
  4. Navigation: each MoveTo task a tree started sets its agent’s Destination, and each reports as the agent’s Travel says.

Then your task systems and movement run. Put them after ArborSystems:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
fn patrol() {}
fn chase() {}
fn keep_ammo_count() {}
let mut app = App::new();
app.add_systems(FixedUpdate, (patrol, chase).after(ArborSystems))
    .add_systems(FixedUpdate, keep_ammo_count.before(ArborSystems));

Systems that keep facts on agents, such as ammo or health, go before ArborSystems. Then trees see a change the same tick it happens.

Facts are components

Many behavior tree libraries keep an agent’s facts on a blackboard. In Bevy, the ECS already is one. The guard’s ammo is an Ammo component. The systems that keep it up to date are ordinary systems.

A typed condition such as out_of_ammo(ammo: &Ammo) reads the agent’s own components. The tree watches those components, so it doesn’t need to ask again until they change.

A fact about another entity, such as how far away the intruder is, needs more. Either keep it on the agent with a system of yours, or use a system condition. System conditions are asked every tick. Decorators covers both.

Trees sleep

A tree that nothing wakes isn’t ticked. A guard patrolling with nothing in view costs a check of the components it watches. Its conditions don’t run. Sleeping trees has what wakes a tree, and what keeps one awake.

Pitfalls

  • Different schedules. If you move trees and perception to different schedules, they no longer share a tick. Perception’s changes then reach the tree on its next run.
  • Facts changed after trees. A fact changed after ArborSystems wakes the tree on the next tick, not this one.
  • Task systems in another schedule. .after(ArborSystems) orders systems only within one schedule. Put task systems in the trees’ schedule, FixedUpdate unless you changed it.

Trees

A behavior tree says what the guard does, and in what order of priority. You write its shape with behavior_tree! and put it on the guard as a BehaviorTree.

A guard that fights

The guard chases and shoots an intruder it sees, searches for one it remembers, and patrols otherwise:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Patrol { from: f32, to: f32 }
#[derive(Component, Clone)] struct Chase;
#[derive(Component, Clone)] struct Search;
#[derive(Component, Clone)] struct Shoot;
fn sees_intruder(seen: &Perceived<Sight>) -> bool { seen.active().any(Percept::is_hostile) }
fn remembers_intruder(seen: &Perceived<Sight>) -> bool { seen.iter().any(|p| p.is_hostile() && !p.is_active()) }
fn guard() -> Behavior {
    behavior_tree! {
        Forever => {
            Select => {
                when(sees_intruder) => Sequence => {
                    task(Chase),
                    task(Shoot),
                    Wait(0.8),
                },
                when(remembers_intruder) => task(Search),
                task(Patrol { from: -10.0, to: 10.0 }),
            }
        }
    }
}

Read it from the top:

  • Forever runs its child again each time it ends, so the guard never stops.
  • Select runs the first branch whose decorator passes. A branch without when always passes, so patrolling is the fallback.
  • Sequence runs its children in turn: chase, shoot, then wait 0.8 seconds.
  • task(..) is work your systems do. See Tasks.

Put the tree on the guard as you spawn it:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)] struct Guard;
fn guard() -> Behavior { behavior_tree! { Wait(1.0) } }
fn spawn_guard(mut commands: Commands) {
    commands.spawn((Guard, Transform::default(), BehaviorTree::new(guard())));
}

Nodes

Composites have several children:

NodeWhat it does
Select => { .. }Runs the first branch whose decorator passes, and switches when that changes
Fallback => { .. }Like Select, but never interrupts what it started
Scored => { .. }Tries branches from the highest score(system) down, until one succeeds
Random(roll) => { .. }Tries children in a random order, until one succeeds
Sequence => { .. }Runs children in turn, until one fails
All => { .. }Runs children together. Fails at the first failure, succeeds once all succeed
Any => { .. }Runs children together. Succeeds at the first success, fails once all fail
Race => { .. }Runs children together. The first to end decides

Modifiers have one child:

NodeWhat it does
Forever => { .. }Runs its child again each time it ends
Repeat(times) => { .. }Runs its child times times. Fails at its first failure
RepeatWhile(decorator) => { .. }Runs its child over and over while the decorator passes
Invert => { .. }Reverses its child’s result
Succeed => { .. }Succeeds whatever its child did
Commit => { .. }Can’t be interrupted once started. See Interruption
Timeout(length) => { .. }Cancels and fails its child after length
State(bundle) => { .. }Puts bundle on the agent while its child runs. See Tasks
Socket(key)Runs the subtree plugged into the agent. See Large trees

Leaves do the work:

NodeWhat it does
task(bundle)Spawns a task entity and waits for it to report. See Tasks
act(system)Runs a system once. Its bool is the result
Wait(length)Succeeds after length
@ exprPuts in a subtree built elsewhere. See Large trees

A length is seconds, like Wait(0.8), or any Duration. A number is checked as the tree compiles, so a negative wait is a compile error. If you give a one-child node several children, they run as a Sequence.

Select and Fallback

Select keeps asking its decorators. When the guard spots an intruder, the Select cancels the patrol and starts the chase. When the intruder is gone, it cancels the chase. This makes the guard react at once.

A branch that fails falls through to the next one. It stays out until its decorator stops passing, so it can’t restart itself in a loop. A branch that succeeds ends the Select.

Fallback asks each decorator only when it gets to it. What it started runs to the end. Use it where reacting isn’t wanted. Interruption compares the two.

Running things together

All, Any and Race run their children at the same time. The guard shouts for help while it chases:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Chase;
#[derive(Component, Clone)] struct Shout;
fn tree() -> Behavior {
behavior_tree! {
    All => {
        task(Chase),
        task(Shout),
    }
}
}

When a parallel node settles, it cancels the children still running.

Scored and Random

Scored asks a score for each branch as it starts, and tries them from the highest down. A score is a system that takes the agent and returns an f32. Random shuffles its children with rolls from a system of yours, so you choose the randomness:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Patrol { from: f32, to: f32 }
#[derive(Component, Clone)] struct Search;
fn suspicion(In(_guard): In<Entity>) -> f32 {
    0.3
}

fn boredom(In(_guard): In<Entity>) -> f32 {
    0.6
}

fn roll(In(_guard): In<Entity>) -> f32 {
    // A number from 0 up to 1, from your random source.
    0.5
}

fn tree() -> Behavior {
behavior_tree! {
    Scored => {
        score(suspicion) => task(Search),
        score(boredom) => Random(roll) => {
            task(Patrol { from: -10.0, to: 0.0 }),
            task(Patrol { from: 0.0, to: 10.0 }),
        },
    }
}
}
  • A score or roll can return Result<f32>, so you can use ?. An error goes to Bevy’s error handler.
  • A branch whose score is an error or NaN goes last.

Without the macro

The macro calls Behavior’s builders. You can call them yourself, for trees built from data:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Patrol { from: f32, to: f32 }
#[derive(Component, Clone)] struct Chase;
fn sees_intruder(seen: &Perceived<Sight>) -> bool { seen.active().any(Percept::is_hostile) }
use bevy_arbor::bt::behavior::Branch;

fn guard() -> Behavior {
    Behavior::forever(Behavior::select(vec![
        Branch::when(sees_intruder, Behavior::task(Chase)),
        Branch::always(Behavior::task(Patrol { from: -10.0, to: 10.0 })),
    ]))
}

The tree on the guard

  • TreeFinished is triggered on the guard when its tree ends. BehaviorTree::outcome says how it ended. A tree under Forever never ends.
  • BehaviorTree::restart starts a finished tree again.
  • TreePaused on the guard holds its tree where it is: no decorators asked, nothing started, and its task skipped by Tasks. Time still passes, so a Wait may be over when you remove it.
  • Removing the tree, replacing it, or despawning the guard cancels what the tree was running.

Pitfalls

  • A Select ends when a branch succeeds. Without Forever around it, the guard stops after its first chase.
  • Order matters in a Select. A branch without when catches everything, so put it last.

Decorators

A decorator sits on a branch, when(..) => .., and decides whether it may run. The guard uses them to reload when it’s out of ammo, to shoot only in range, and to call for help no more than once in a while.

Reloading

A plain function of the guard’s components is a decorator:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Patrol { from: f32, to: f32 }
#[derive(Component, Clone)] struct Chase;
#[derive(Component, Clone)] struct Shoot;
#[derive(Component, Clone)] struct Reload;
fn sees_intruder(seen: &Perceived<Sight>) -> bool { seen.active().any(Percept::is_hostile) }
#[derive(Component, PartialEq)]
struct Ammo(u32);

fn out_of_ammo(ammo: &Ammo) -> bool {
    ammo.0 == 0
}

fn tree() -> Behavior {
behavior_tree! {
    Forever => {
        Select => {
            when(out_of_ammo) => task(Reload),
            when(sees_intruder) => Sequence => { task(Chase), task(Shoot) },
            task(Patrol { from: -10.0, to: 10.0 }),
        }
    }
}
}

When the last shot empties Ammo, the Select cancels the fight and the guard reloads.

Typed conditions

out_of_ammo is a typed condition. It takes &T or Option<&T> of up to four of the agent’s components, and returns bool.

  • It’s asked again only when one of those components changes, comes or goes. Until then, the tree can sleep. See Sleeping.
  • If the agent lacks a component it takes as &T, it doesn’t pass.
  • It reads only the agent. For anything else, use a system condition, or keep what you need on the agent with a system of your own.

System conditions

A system condition is a read-only system that takes the agent. It can read anything. The guard shoots only when the intruder is in range:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Chase;
#[derive(Component, Clone)] struct Shoot;
fn within(
    In((guard, range)): In<(Entity, f32)>,
    guards: Query<(&Perceived<Sight>, &GlobalTransform)>,
    places: Query<&GlobalTransform>,
) -> bool {
    let Ok((seen, at)) = guards.get(guard) else {
        return false;
    };
    seen.active().filter(|p| p.is_hostile()).any(|p| {
        places
            .get(p.entity())
            .is_ok_and(|there| there.translation().distance(at.translation()) <= range)
    })
}

fn tree() -> Behavior {
behavior_tree! {
    Select => {
        when(within, 6.0) => task(Shoot),
        task(Chase),
    }
}
}
  • A parameter goes after the system: when(within, 6.0). The system gets it in In with the agent.
  • A system condition is asked every tick, because it could read anything. While one could matter, the tree doesn’t sleep.
  • It can’t take ResMut or a &mut query, as with a Bevy run condition. It can queue Commands. Use act to change things.
  • It can return Result<bool>, so you can use ?. An error doesn’t pass, and goes to Bevy’s error handler.
  • Each system is one instance, shared by every tree and agent. Its Locals are shared too, so keep per-agent state on the agent.
  • For the same reason, a Changed filter in it sees what changed since it was last asked about any agent, not this one.
  • act(system, param) takes a parameter the same way.

Combining

Decorators combine with &&, || and !:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Shoot;
#[derive(Component)] struct Ammo(u32);
fn sees_intruder(seen: &Perceived<Sight>) -> bool { seen.active().any(Percept::is_hostile) }
fn out_of_ammo(ammo: &Ammo) -> bool { ammo.0 == 0 }
fn tree() -> Behavior {
behavior_tree! {
    Select => {
        when(sees_intruder && !out_of_ammo) => task(Shoot),
    }
}
}

A system condition with a parameter can’t go straight into &&. Build it with Decorator::with, then combine with .and(..) and .or(..): when(Decorator::with(within, 6.0).and(sees_intruder)).

Cooldowns

cooldown(seconds) keeps its branch from running again until that long after it last ended. The guard calls for help at most every ten seconds:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Chase;
#[derive(Component, Clone)] struct Shout;
fn sees_intruder(seen: &Perceived<Sight>) -> bool { seen.active().any(Percept::is_hostile) }
fn tree() -> Behavior {
behavior_tree! {
    Forever => {
        Select => {
            when(sees_intruder && cooldown(10.0)) => task(Shout),
            when(sees_intruder) => task(Chase),
        }
    }
}
}
  • It cools however the branch ended: success, failure or cancellation.
  • The tree sleeps while it cools, and wakes when it’s done.
  • Each agent cools on its own.
  • It’s told when its branch ends, even when && or || skipped asking it.

Shared cooldowns

cooldown_of::<K>(seconds) shares one cooldown between every branch keyed by K on the agent. Running one cools them all. The cooldown is the Cooling<K> component on the agent. Insert one yourself to start it from elsewhere. Here, a guard that hears another’s alarm doesn’t raise its own:

use std::time::Duration;
use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Shout;
#[derive(Component)] struct Guard;
fn sees_intruder(seen: &Perceived<Sight>) -> bool { seen.active().any(Percept::is_hostile) }
/// What every alarm shares.
struct Alarm;

fn tree() -> Behavior {
behavior_tree! {
    Select => {
        when(sees_intruder && cooldown_of::<Alarm>(10.0)) => task(Shout),
    }
}
}
fn alarm_heard(time: Res<Time>, guards: Query<Entity, With<Guard>>, mut commands: Commands) {
    let over = time.elapsed() + Duration::from_secs(10);
    for guard in &guards {
        commands.entity(guard).insert(Cooling::<Alarm>::until(over));
    }
}

Decorators of your own

A decorator of your own implements Decorate. passes answers. start and end hear its branch start and end, if it keeps anything.

At most two guards may chase at once. They count chasers on a shared entity:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
use bevy_arbor::bt::behavior::Context;
use bevy_arbor::bt::decorator::Decorate;

/// How many guards are chasing.
#[derive(Component)]
struct Chasers(u32);

/// At most `max` agents in its branch, counted on `pool`.
struct Limit {
    max: u32,
    pool: Entity,
}

impl Decorate for Limit {
    fn passes(&mut self, cx: &mut Context) -> bool {
        // Ask again when the count changes.
        cx.watch::<Chasers>(self.pool);
        cx.world()
            .get::<Chasers>(self.pool)
            .is_some_and(|chasers| chasers.0 < self.max)
    }

    fn start(&mut self, cx: &mut Context) {
        if let Some(mut chasers) = cx.world_mut().get_mut::<Chasers>(self.pool) {
            chasers.0 += 1;
        }
    }

    fn end(&mut self, cx: &mut Context, _outcome: Option<Outcome>) {
        if let Some(mut chasers) = cx.world_mut().get_mut::<Chasers>(self.pool) {
            chasers.0 -= 1;
        }
    }
}

Then when(sees_intruder && Limit { max: 2, pool }) lets two guards in.

Say what the decorator waits on each time it’s asked:

  • cx.watch::<T>(entity) to be asked again when a component changes.
  • cx.wake_at(time) to be asked again at a time.
  • cx.wait_on_nothing() when only its own branch starting or ending changes its answer.

A decorator that says nothing is asked every tick.

Pitfalls

  • A component written every frame wakes every typed condition reading it. Write with set_if_neq. See Sleeping.
  • A system condition can’t capture variables. Pass what varies as its parameter.

Tasks

A task is work the tree hands to your systems: chasing, shooting, reloading. The tree spawns the task as an entity. Your system does the work and reports how it went.

Chasing and shooting

task(Chase) spawns an entity with Chase, a Task, and a TaskOf pointing at the guard. A system finds it with Tasks, which pairs each task with what you ask of its agent:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)]
struct Chase;

#[derive(Component, Clone)]
struct Shoot;

#[derive(Component, PartialEq)]
struct Ammo(u32);

fn chase(
    mut tasks: Tasks<&Chase, (&Perceived<Sight>, &mut Transform)>,
    places: Query<&GlobalTransform>,
    time: Res<Time>,
) {
    tasks.for_each(|_, mut task, (seen, mut at)| {
        let target = seen.active().find(|p| p.is_hostile());
        let Some(there) = target.and_then(|p| places.get(p.entity()).ok()) else {
            // Lost sight of it.
            task.fail();
            return;
        };
        let gap = there.translation().x - at.translation.x;
        if gap.abs() < 6.0 {
            // In range.
            task.succeed();
            return;
        }
        at.translation.x += gap.signum() * 4.0 * time.delta_secs();
    });
}

fn shoot(mut tasks: Tasks<&Shoot, &mut Ammo>) {
    tasks.for_each(|_, mut task, mut ammo| {
        if ammo.0 == 0 {
            task.fail();
            return;
        }
        ammo.0 -= 1;
        task.succeed();
    });
}

Add task systems after the trees run:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
fn chase() {}
fn shoot() {}
fn main() {
    App::new()
        .add_plugins((
            DefaultPlugins,
            ArborPlugins.with_space(Space::Planar),
        ))
        .add_systems(FixedUpdate, (chase, shoot).after(ArborSystems))
        .run();
}

Reporting

  • task.succeed() and task.fail() report. The first report counts. Later ones are ignored.
  • A report wakes the tree. It reads the report on its next tick.
  • task.elapsed() is how long the task has run, by the tree’s clock. It counts in whatever schedule your task system runs, and goes on while the tree sleeps.
  • Tasks skips tasks that already reported and tasks of paused trees.
  • Tasks is a shortcut for a query of tasks and a query of agents, paired by TaskOf. You can write those two queries yourself.
  • A task despawned by anything other than its tree fails.

Reporting from commands

Observers and systems that don’t query Task report through TaskCommands. The guard’s Shout is done the moment it starts:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)]
struct Shout;

fn shout(started: On<Add<Shout>>, mut commands: Commands) {
    // Call allies here.
    commands.entity(started.entity).succeed_task();
}

Starting and ending

A task’s components come and go with it. Use Bevy’s own observers to set up and clean up. The guard shows a muzzle flash while it shoots:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Shoot;
#[derive(Component)]
struct MuzzleFlash;

fn flash_on(started: On<Add<Shoot>>, tasks: Query<&TaskOf>, mut commands: Commands) {
    if let Ok(of) = tasks.get(started.entity) {
        commands.entity(of.agent()).insert(MuzzleFlash);
    }
}

fn flash_off(ended: On<Remove<Shoot>>, tasks: Query<(&Task, &TaskOf)>, mut commands: Commands) {
    if let Ok((task, of)) = tasks.get(ended.entity) {
        // `None` if it was canceled before it reported.
        info!("shot: {:?}", task.outcome());
        commands.entity(of.agent()).remove::<MuzzleFlash>();
    }
}

On<Remove<..>> runs however the task goes: it reported, it was canceled, or its agent was despawned. Its Task is still there, with the outcome.

An agent’s AgentTasks lists its running tasks. Despawning the agent despawns them.

Names

A task is named after its bundle’s type, or the first component of a tuple: task((Reload, Graceful)) is Reload. The name goes in its Name and in the TreeTrace. Give your own with task("Warning shot", Shoot).

State

State(bundle) => { .. } puts bundle on the agent while its child runs, and removes it when the child ends or is canceled. Use it for systems that should work only during a branch. The guard is Alert for the whole fight:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Chase;
#[derive(Component, Clone)] struct Shoot;
fn sees_intruder(seen: &Perceived<Sight>) -> bool { seen.active().any(Percept::is_hostile) }
#[derive(Component, Clone)]
struct Alert;

fn tree() -> Behavior {
behavior_tree! {
    Select => {
        when(sees_intruder) => State(Alert) => {
            task(Chase),
            task(Shoot),
        },
    }
}
}

A system filtering on With<Alert> then runs only while the guard fights. A single task doesn’t need State: filter on the task itself.

Pitfalls

  • A task that never reports holds its branch forever. Make sure every path in its system reports, or put a Timeout around it.
  • Run task systems in the trees’ schedule, .after(ArborSystems). See How it fits together.

Interruption

A Select cancels the running branch as soon as a better one passes. That keeps the guard alert, but some work shouldn’t be cut off. A shot already fired should land. A reload half done should finish.

Commit

Commit => { .. } can’t be interrupted once it starts. The guard always finishes a shot:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Patrol { from: f32, to: f32 }
#[derive(Component, Clone)] struct Chase;
#[derive(Component, Clone)] struct Shoot;
fn sees_intruder(seen: &Perceived<Sight>) -> bool { seen.active().any(Percept::is_hostile) }
fn tree() -> Behavior {
behavior_tree! {
    Forever => {
        Select => {
            when(sees_intruder) => Sequence => {
                task(Chase),
                Commit => { task(Shoot) },
                Wait(0.8),
            },
            task(Patrol { from: -10.0, to: 10.0 }),
        }
    }
}
}

If the intruder ducks out of sight mid-shot, the shot finishes first. Then the Select switches to patrolling. The Chase and the Wait around it can still be interrupted.

A task inside Commit isn’t even told that something wants it to stop.

Graceful tasks

A task with Graceful in its bundle is asked to stop instead of being canceled. task.interrupted() turns true. The task winds down, reports, and only then does the tree move on.

The guard reloads gracefully. Past halfway, it finishes the reload even when asked to stop:

use std::time::Duration;
use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)] struct Ammo(u32);
fn out_of_ammo(ammo: &Ammo) -> bool { ammo.0 == 0 }
#[derive(Component, Clone)]
struct Reload;

const RELOAD: Duration = Duration::from_secs(2);

fn reload(mut tasks: Tasks<&Reload, &mut Ammo>) {
    tasks.for_each(|_, mut task, mut ammo| {
        if task.elapsed() >= RELOAD {
            ammo.0 = 6;
            task.succeed();
        } else if task.interrupted() && task.elapsed() < RELOAD / 2 {
            // Not far in: give up the reload.
            task.fail();
        }
    });
}

fn tree() -> Behavior {
behavior_tree! {
    Select => {
        when(out_of_ammo) => task((Reload, Graceful)),
    }
}
}
  • If whatever asked it to stop changes its mind, interrupted() turns false again.
  • While a task winds down, nothing new starts around it. A Sequence doesn’t move to its next child, and Forever doesn’t restart.

Select or Fallback

Building trees explains how each asks its decorators. Which to reach for:

You wantUse
The guard to drop the patrol the moment it sees someoneSelect
A list of things to try in order, each run to the endFallback
One part of a Select branch to finishCommit around that part
A task to finish cleanly when interruptedGraceful

Holding

Commit and Graceful work by holding: a node that holds asks not to be stopped this tick. Your own nodes can hold too. See Custom nodes.

Holding only stops Select and RepeatWhile. Force still gets through:

  • a Timeout running out,
  • an All, Any or Race settling and canceling the rest,
  • anything above being canceled,
  • the tree being removed, replaced or despawned.

Pitfalls

  • A graceful task that never reports holds its branch forever. Put a Timeout around one that might not.
  • Commit around a whole branch makes the guard deaf to everything above it until the branch ends. Commit to the smallest part that needs it.

Sleeping

A tree only ticks when something it waits on happens. Between those moments it sleeps, and costs almost nothing. A hundred patrolling guards cost little until one of them sees something.

What the patrolling guard waits on

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Patrol { from: f32, to: f32 }
#[derive(Component, Clone)] struct Chase;
fn sees_intruder(seen: &Perceived<Sight>) -> bool { seen.active().any(Percept::is_hostile) }
fn tree() -> Behavior {
behavior_tree! {
    Forever => {
        Select => {
            when(sees_intruder) => task(Chase),
            task(Patrol { from: -10.0, to: 10.0 }),
        }
    }
}
}

While it patrols, this tree waits on two things:

  • Perceived<Sight> changing, because sees_intruder reads it.
  • The Patrol task reporting.

Nothing else wakes it. The patrol system moves the guard every frame, but the tree doesn’t tick.

What wakes a tree

It waits onBecause of
A component changing, coming or goingA typed condition that reads it
A task’s reportA running task
A timeA Wait, a Timeout or a cooldown
Every tickA system condition that could matter
Every tickA custom node or decorator that says nothing

A sleeping tree costs a check of the change ticks it watches. That’s about 20 nanoseconds an agent, against about 150 for one asking four system conditions every tick. See Performance.

What “changed” means

The tree remembers each component’s change tick when it reads it. It wakes once the tick moves past that, or the component comes or goes. So the tree doesn’t wake for its own writes made before the read. A change after the read does wake it, even one made later in the same tick.

Keeping trees asleep

Bevy marks a component changed on any mutable access, even if the value stays the same. A system that writes a component every frame keeps every tree that reads it awake.

Write with set_if_neq. An armory refills the guard’s ammo, but marks it changed only when the count really changes:

use bevy::prelude::*;
#[derive(Component, PartialEq)]
struct Ammo(u32);

#[derive(Component)]
struct AtArmory;

fn refill(mut guards: Query<&mut Ammo, With<AtArmory>>) {
    for mut ammo in &mut guards {
        ammo.set_if_neq(Ammo(6));
    }
}

Keep components small and specific. A typed condition reading a big component wakes for changes to any part of it.

The polling rule

A system condition can read anything, so the tree can’t know when its answer changes. While one could matter, its tree ticks every frame.

The same goes for custom nodes and decorators. Each says what it waits on as it runs, with cx.watch, cx.wake_at or cx.poll. A running node that says nothing is ticked again next frame. That way a mistake costs time, not a stuck tree.

A decorator whose answer changes only when its own branch starts or ends says so with cx.wait_on_nothing(). An idle cooldown does this. See Custom nodes.

Pitfalls

  • One system condition near the top of the tree keeps it awake all the time. Prefer typed conditions. Keep what a system condition would compute on the agent, with a system of your own that writes it with set_if_neq.
  • A system writing Transform every frame wakes any typed condition reading Transform. Read a derived component instead.

Large trees and sockets

The guard’s tree grows with every chapter. One behavior_tree! holding all of it gets hard to read. Three habits keep it readable: split it into subtrees, leave timing to the tasks, and plug in behavior that varies with sockets.

Subtrees

A subtree is a function returning a Behavior. Put it in with @. The top of the guard’s tree becomes a list of priorities, one per line:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Patrol { from: f32, to: f32 }
#[derive(Component, Clone)] struct Chase;
#[derive(Component, Clone)] struct Search;
#[derive(Component, Clone)] struct Shoot;
#[derive(Component, Clone)] struct Reload;
#[derive(Component)] struct Ammo(u32);
fn sees_intruder(seen: &Perceived<Sight>) -> bool { seen.active().any(Percept::is_hostile) }
fn remembers_intruder(seen: &Perceived<Sight>) -> bool { seen.iter().any(|p| p.is_hostile() && !p.is_active()) }
fn out_of_ammo(ammo: &Ammo) -> bool { ammo.0 == 0 }
fn guard() -> Behavior {
    behavior_tree! {
        Forever => {
            Select => {
                when(sees_intruder) => @ fight(),
                when(remembers_intruder) => @ search(),
                @ patrol(),
            }
        }
    }
}

fn fight() -> Behavior {
    behavior_tree! {
        Select => {
            when(out_of_ammo) => task((Reload, Graceful)),
            Sequence => {
                task(Chase),
                Commit => { task(Shoot) },
                Wait(0.8),
            },
        }
    }
}

fn search() -> Behavior {
    behavior_tree! { task(Search) }
}

fn patrol() -> Behavior {
    behavior_tree! { task(Patrol { from: -10.0, to: 10.0 }) }
}
  • @ costs nothing. It puts the subtree’s nodes straight into the tree, so the tree runs and traces as if written in one piece.
  • A subtree can take arguments: @ patrol(-10.0, 10.0).
  • Several kinds of agent can share a subtree.
  • @[ iter ] puts in every node of an iterator, for a list of children built in code.

Timing in tasks

How long a search lasts is the search’s business. Keep it in the task’s system, not in the tree:

use std::time::Duration;
use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)]
struct Search;

const GIVE_UP: Duration = Duration::from_secs(8);

fn search(mut tasks: Tasks<&Search, &mut Transform>) {
    tasks.for_each(|_, mut task, mut at| {
        if task.elapsed() > GIVE_UP {
            task.fail();
            return;
        }
        // Walk toward where the intruder was last seen.
      let _ = &mut at;
    });
}

The tree then says what to do and when, not for how long. Keep Timeout for a part of the tree whose pieces don’t know about each other, such as a whole Sequence that must finish in time.

Sockets

A subtree put in with @ is fixed when the tree is built. A socket is filled at runtime, from the agent. The tree says where with Socket(key). A Plugged<S> on the agent says what runs there.

Some guards carry a crossbow and keep their distance. Others carry a sword and close in. The tree doesn’t need to know which:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Chase;
#[derive(Component, Clone)] struct Shoot;
#[derive(Component, Clone)] struct Slash;
fn sees_intruder(seen: &Perceived<Sight>) -> bool { seen.active().any(Percept::is_hostile) }
/// The socket weapons plug into.
struct Weapon;

fn fight() -> Behavior {
    behavior_tree! {
        Select => {
            when(plugged::<Weapon>) => Socket(Weapon),
            task(Chase),
        }
    }
}

fn crossbow() -> Behavior {
    behavior_tree! { Sequence => { task(Shoot), Wait(1.5) } }
}

fn sword() -> Behavior {
    behavior_tree! { Sequence => { task(Chase), task(Slash) } }
}

fn arm(commands: &mut Commands, guard: Entity, archer: bool) {
    let weapon = if archer {
        Plugged::new(Weapon, crossbow)
    } else {
        Plugged::new(Weapon, sword)
    };
    commands.entity(guard).insert(weapon);
}
  • Inserting another Plugged<Weapon> swaps it. The socket cancels what it ran, once that stops holding, and starts the new one.
  • Removing it unplugs the socket. A socket with nothing plugged in fails.
  • plugged::<Weapon> is a decorator that passes while something is plugged in. It keeps an empty socket out of the way, and wakes the tree when something is plugged in.
  • Each agent builds its own copy of the plugged subtree.

Smart objects use a socket too: a slot plugs in how to use it.

Pitfalls

  • Reach for a socket when the decisions differ: a crossbow keeps its distance, a sword closes in. When only how a task is done differs, one task whose system reads the weapon is simpler.
  • A Timeout around every task is noise. Put the time limit in the task.

Custom nodes

The built-in nodes are written against one trait, Behave. Yours can be too, and do anything they do. The guard gets a node of its own: one that alternates between two children, so it walks a different route each time.

Alternate

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Patrol { from: f32, to: f32 }
use bevy_arbor::bt::behavior::{Behave, Child, Context};

/// Runs one child, then the other the next time, and so on.
struct Alternate {
    children: [Behavior; 2],
    next: usize,
    running: usize,
}

impl Behave for Alternate {
    fn start(&mut self, _cx: &mut Context) {
        self.running = self.next;
        self.next = 1 - self.next;
    }

    fn tick(&mut self, cx: &mut Context) -> Status {
        self.children[self.running].tick(cx)
    }

    fn end(&mut self, cx: &mut Context, _outcome: Option<Outcome>) {
        self.children[self.running].cancel(cx);
    }

    fn children(&self) -> Vec<Child<'_>> {
        self.children.iter().map(Child::new).collect()
    }
}

fn alternate(first: Behavior, second: Behavior) -> Behavior {
    Alternate { children: [first, second], next: 0, running: 0 }.into()
}

fn patrol() -> Behavior {
    behavior_tree! {
        Forever => {
            @ alternate(
                behavior_tree! { task(Patrol { from: -10.0, to: 10.0 }) },
                behavior_tree! { task(Patrol { from: 20.0, to: 30.0 }) },
            )
        }
    }
}

Any type implementing Behave turns into a Behavior with .into(). Put it in a tree with @.

The lifecycle

The tree calls three methods. Your node only says what happens at each:

  • start runs before the first tick, each time the node runs. It’s optional.
  • tick runs the node for a tick. Return Status::Running until it’s done, then Status::Success or Status::Failure.
  • end runs once, when the node finishes or is canceled. outcome is how it finished, or None if it was canceled.

Children are Behaviors:

  • child.tick(cx) runs one. It starts it if it isn’t running, and ends it when it’s done.
  • child.cancel(cx) stops one. A child that isn’t running is left as it is, so it’s safe to call on every child.

end must cancel the node’s children. If it doesn’t, their tasks keep running after the node is gone.

cx is the Context: cx.agent() is the agent, cx.world() and cx.world_mut() the world, cx.now() the tree’s clock.

In a trace

In a TreeTrace, a node shows as its type’s name with nothing under it. Change that with two optional methods:

  • label is how it reads. Alternate keeps its type’s name, so it doesn’t need one.
  • children lists what’s under it. Child::noted(child, "..") writes a note above a child, as Select writes its decorators.

Both are built only when the tree is traced. Leaving a child out of children only hides it from the trace.

use bevy::prelude::*;
use bevy_arbor::prelude::*;
use bevy_arbor::bt::behavior::{Behave, Child, Context};
struct Alternate { children: [Behavior; 2], next: usize, running: usize }
impl Behave for Alternate {
fn tick(&mut self, _: &mut Context) -> Status { Status::Running }
fn end(&mut self, cx: &mut Context, _: Option<Outcome>) { self.children[self.running].cancel(cx); }
fn label(&self) -> String {
    format!("Alternate (next: {})", self.next)
}

fn children(&self) -> Vec<Child<'_>> {
    vec![
        Child::noted(&self.children[0], "first"),
        Child::noted(&self.children[1], "second"),
    ]
}
}

Sleeping

Trees sleep until something they wait on happens. See Sleeping. A node says what it waits on as it ticks:

  • cx.wake_at(time) to tick again at a time.
  • cx.watch::<T>(entity) to tick again when a component changes.
  • cx.poll() to tick again next frame.

Children say what they wait on themselves. Alternate says nothing of its own: its patrol task’s report wakes the tree. A running node that says nothing, children included, is polled. See the polling rule.

Holding and interruption

A node that just ticks its children, as Alternate does, needs nothing more. Holding below it passes up on its own. A node that does more follows three rules:

  • If it skips a tick of a running child, it calls child.skip(cx). Otherwise the child’s hold lapses for that tick.
  • To switch away from a child that holds, it ticks the child with child.tick_interrupted(cx) until the child ends or stops holding. That’s what Select and RepeatWhile do.
  • While cx.interrupted() is true, it starts nothing new. It goes on with the running child, and ends when that child does.

A node of your own can hold too: call cx.hold() on each tick it doesn’t want to be stopped. See Interruption.

Pitfalls

  • Forgetting to cancel children in end leaves their tasks running.
  • start runs each time the node starts again, as under Forever. Reset per-run state there, not in the constructor.

Senses

Senses tell the guard what’s around it. It sees the intruder, hears footsteps, feels a hit, and hears what other guards shout.

Each sense is a component on the guard. What it senses is written back to the guard as Perceived<S>, one per sense, with when and where in Traces<S>. The guard’s conditions read them:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)] struct Guard;
#[derive(Component)] struct Ammo(u32);
fn sees_intruder(seen: &Perceived<Sight>) -> bool {
    seen.active().any(Percept::is_hostile)
}

fn heard_something(heard: &Perceived<Hearing>) -> bool {
    !heard.is_empty()
}

fn spawn(mut commands: Commands) {
commands.spawn((
    Guard,
    Sight::new(12.0).with_lose_range(15.0),
    Hearing::new(8.0),
    Team(1),
    Ammo(6),
    Facing(Dir3::X),
    Transform::from_xyz(0.0, 0.0, 0.0),
));
}

Perceived<S> changes only when something is noticed, lost or forgotten. It doesn’t change as things move, or as something already heard is heard again. So a tree reading Perceived<Sight> sleeps until something comes into view, and a noise doesn’t wake it.

2D and 3D

Perception works in 3D by default. The guard lives in a side-scroller, so tell it to work in the plane:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
fn main() {
App::new().add_plugins((
    MinimalPlugins,
    ArborPlugins.with_space(Space::Planar),
));
}

With Space::Planar, perception works in the XY plane. It ignores z, which 2D uses for layering. Ahead is +X. In 3D, ahead is -Z, Bevy’s forward. Queries, avian and the gizmos work in the same space. With avian2d, a space other than Planar is warned about at startup.

The senses

SenseSustainedSenses
SightYesWhat’s Perceivable, in range and in its cone
HearingNoNoise messages within range
TouchNoTouched messages
SharedNoShare messages from allies

A sustained sense keeps a thing active while it lasts: in view, for sight. When it stops, the thing is lost. The other senses are moments: a noise is heard once.

Sight

Sight sees what’s Perceivable in range and in its cone. Make the intruder perceivable:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)] struct Intruder;
fn spawn(mut commands: Commands) {
commands.spawn((Intruder, Perceivable, Team(2), Transform::from_xyz(8.0, 0.0, 0.0)));
}

Sight has a few options:

use std::f32::consts::FRAC_PI_4;
use std::time::Duration;
use bevy::prelude::*;
use bevy_arbor::prelude::*;
fn sight() -> Sight {
Sight::new(12.0)
    .with_lose_range(15.0)
    .with_half_angle(FRAC_PI_4)
    .with_eye(Vec3::new(0.0, 1.6, 0.0))
    .with_interval(Duration::from_millis(100))
}
OptionWhat it doesDefault
rangeHow far it notices thingsGiven
lose_rangeHow far it keeps seeing what it already seesrange
half_angleHow far off ahead it sees, to either side, in radiansπ/2, the front half
eyeWhere it looks from, relative to the agentThe origin
intervalHow long between looks0.2 s

A lose_range larger than range stops the intruder from flickering in and out of view at the edge. A half_angle of π sees all around. An interval of zero looks every tick.

Each agent looks at its own moment within its interval. So looks spread across ticks instead of all landing on one.

Facing

Sight looks the way the agent’s transform does. In 2D that’s +X, and a negative x scale flips it. But a sprite flipped with Sprite::flip_x doesn’t turn its transform. Give the guard a Facing and keep it up to date instead:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)] struct Guard;
// Bevy's own, without the sprite feature the doctests build with.
#[derive(Component)] struct Sprite { flip_x: bool }
fn face(mut guards: Query<(&Sprite, &mut Facing), (With<Guard>, Changed<Sprite>)>) {
    for (sprite, mut facing) in &mut guards {
        let way = if sprite.flip_x { Dir3::NEG_X } else { Dir3::X };
        facing.set_if_neq(Facing(way));
    }
}

The guard needs a Facing for this query to find it. Spawn it with Facing(Dir3::X). The guard’s sensors look the same way, unless they have a Facing of their own.

What’s seen

What’s in view is active. Its trace, in Traces<Sight>, says when and where it came into view. Once lost, it’s no longer active. Its trace then says when and where it was lost. It stays like that until the guard forgets it (Sensors, memory and events).

For where the intruder is now, read its Transform. Perception keeps no live positions.

Hearing

An agent with Hearing hears a Noise within its range. Your systems write the noises. Here, the intruder’s footsteps:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)] struct Intruder;
#[derive(Component)] struct Step;
fn footsteps(
    mut noises: MessageWriter<Noise>,
    steps: Query<(Entity, &GlobalTransform), (With<Intruder>, Added<Step>)>,
) {
    for (intruder, at) in &steps {
        noises.write(Noise::new(intruder, at.translation()).with_loudness(0.5));
    }
}

Loudness scales the range. A guard with Hearing::new(8.0) hears a noise of loudness 0.5 within 4 units. The default loudness is 1.

What makes the noise is what’s heard. An explosion is its own entity. An agent doesn’t hear its own noises.

Touch

An agent with Touch feels what Touched it. Write the message when something hits the guard:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Message)] struct Hit { victim: Entity, attacker: Entity }
fn hits(mut hit: MessageReader<Hit>, mut touches: MessageWriter<Touched>) {
    for hit in hit.read() {
        touches.write(Touched { agent: hit.victim, by: hit.attacker });
    }
}

With avian, contacts can write these for you (Occlusion and attitude).

Sharing

A guard that spots the intruder can tell the others. That’s the guard’s Shout task. It writes a Share:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Shout;
fn shout(mut tasks: Tasks<&Shout, Entity>, mut shares: MessageWriter<Share>) {
    tasks.for_each(|_, mut task, guard| {
        shares.write(Share::allies(guard, 20.0));
        task.succeed();
    });
}

Listeners are agents with Shared. They get what they’re told as Perceived<Shared>, and when and where as Traces<Shared>. Give the other guards Shared so they hear the shout.

BuilderWho’s told
Share::allies(from, range)Agents on its Team within range
Share::to(from, [..])The agents listed, wherever they are
.about(entity)Only about that one thing

Without about, the guard passes on everything it perceives, by every sense. What it sees now is passed on where it is now. What it only remembers is passed on from when and where it last sensed it. Each listener gives it its own attitude. A listener keeps what it already knew if that’s newer.

Perceived, Percept and Traces

Perceived<S> holds one Percept per thing the agent perceives by S. Traces<S> holds when and where each was sensed. It comes with Perceived<S> and goes with it.

On Perceived<S>Gives
iter()Everything it perceives or remembers
active()Only what’s active: in view, for sight
get(entity)That thing’s percept, if any
is_empty()Whether it perceives or remembers nothing
On PerceptGives
entity()What it is
attitude()Hostile, neutral or friendly
is_hostile(), is_friendly()Shortcuts for the attitude
is_active()Whether it’s active now
On Traces<S>Gives
get(entity)When and where that thing was sensed: as it came into view or was lost, for sight; last, for the others
iter()Each thing and its trace

The two are apart so a tree can sleep through what it doesn’t ask about. A footstep from the intruder the guard already heard changes its trace, not Perceived<Hearing>. A condition asking whether the guard heard anything stays asleep. One reading Traces<Hearing> wakes.

Pitfalls

  • Noisy noises. Something writing a noise every tick wakes every listening tree every tick. Write noises at the rate they matter: a footstep, not a frame.
  • Nothing is seen. The intruder needs Perceivable. And in 2D, set Space::Planar, or sight looks down -Z into the screen.
  • A flipped sprite sees behind it. Use Facing, as above.
  • Where is it now? A trace is where it was sensed, not where it is. Read the transform for that.

Sensors, memory and events

This chapter gives the guard a lookout camera, a memory of where it last saw the intruder, and a line to shout when the intruder slips away.

Sensors

A sense can sit on its own entity, a sensor, instead of on the agent. The sensor is SensorOf the agent. What it senses goes to the agent’s Perceived<S>, as if the agent sensed it itself.

The guard’s post has a camera high on the wall, looking along the corridor the guard patrols:

use std::f32::consts::PI;
use bevy::prelude::*;
use bevy_arbor::prelude::*;
fn spawn(mut commands: Commands, guard: Entity) {
commands.spawn((
    Sight::new(30.0).with_half_angle(PI / 6.0),
    SensorOf(guard),
    Transform::from_xyz(-15.0, 4.0, 0.0),
));
}

When the camera sees the intruder, so does the guard. Its sees_intruder condition doesn’t change.

  • A sensor looks from its own transform. The camera stays on the wall while the guard walks.
  • For a sensor that moves with the agent, eyes on a head say, parent it with ChildOf(agent) as well. SensorOf is only about perception.
  • The sensor keeps nothing itself. Everything goes to the agent.
  • Despawning the agent despawns its sensors. The agent’s Sensors lists them.
  • A sensor despawned, detached, or moved to another agent stops counting at once. What only it saw is lost, as if it had looked away. Detached, it senses for itself again.

An agent can have several sensors of one sense, and its own as well. For sight, what any of them sees is active. It’s lost only once none of them sees it. The camera looking away doesn’t lose what the guard still sees.

Memory

When the intruder goes out of view, the guard doesn’t forget it at once. The percept stays in Perceived<Sight>, no longer active. Its trace, in Traces<Sight>, says when and where the guard lost it. remembers_intruder reads the first:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Patrol { from: f32, to: f32 }
#[derive(Component, Clone)] struct Chase;
#[derive(Component, Clone)] struct Search;
fn sees_intruder(seen: &Perceived<Sight>) -> bool {
    seen.active().any(Percept::is_hostile)
}

fn remembers_intruder(seen: &Perceived<Sight>) -> bool {
    seen.iter().any(|p| p.is_hostile() && !p.is_active())
}

fn tree() -> Behavior {
behavior_tree! {
    Forever => {
        Select => {
            when(sees_intruder) => task(Chase),
            when(remembers_intruder) => task(Search),
            task(Patrol { from: -10.0, to: 10.0 }),
        }
    }
}
}

The Search task reads the trace and walks to where the intruder was lost:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct Search;
fn search(
    time: Res<Time>,
    mut tasks: Tasks<&Search, (&Perceived<Sight>, &Traces<Sight>, &mut Transform)>,
) {
    tasks.for_each(|_, mut task, (seen, traces, mut at)| {
        let last = seen
            .iter()
            .filter(|p| p.is_hostile() && !p.is_active())
            .find_map(|p| traces.get(p.entity()));
        let Some(last) = last else {
            task.fail();
            return;
        };
        let gap = last.position.x - at.translation.x;
        if gap.abs() < 0.5 {
            task.succeed();
        } else {
            at.translation.x += gap.signum() * 3.0 * time.delta_secs();
        }
    });
}

When the memory fades, the intruder is forgotten. remembers_intruder turns false, and the guard goes back to its patrol.

How long it remembers

Each sense remembers for its Sense::MEMORY:

SenseRemembers for
Sight10 s
Shared10 s
Hearing5 s
Touch5 s

Memory counts from when the thing was last sensed. What’s active never fades: the guard doesn’t forget the intruder while it’s looking at it. An agent with nothing due to fade costs nothing to check that tick.

To change it for one agent, spawn it with its own Perceived:

use std::time::Duration;
use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)] struct Guard;
fn spawn(mut commands: Commands) {
commands.spawn((
    Guard,
    Sight::new(12.0).with_lose_range(15.0),
    Perceived::<Sight>::remembering(Duration::from_secs(30)),
    Transform::default(),
));
}

Duration::MAX remembers for good.

Memory belongs to the agent, not to a sensor. The guard and its camera share one memory of sight, and one set of traces.

Events

Perception triggers events on the agent, one set per sense:

EventWhen
Noticed<S>Something comes to its attention, or is active again
Lost<S>Something stops being active: out of view, for sight
Forgotten<S>It forgets something

entity is the agent. what is the thing. Observe them for barks, alerts and UI:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
fn where_did_they_go(lost: On<Lost<Sight>>, names: Query<&Name>) {
    if let Ok(name) = names.get(lost.what) {
        info!("{}: where did {name} go?", lost.entity);
    }
}

fn add(app: &mut App) {
app.add_observer(where_did_they_go);
}

Only sustained senses, like sight, lose things. A noise is noticed, then forgotten.

Pitfalls

  • Don’t drive trees with events. A tree already wakes when Perceived<S> changes, which carries the same news. Use conditions such as remembers_intruder, and keep events for side effects.
  • A sensor that doesn’t follow. SensorOf doesn’t move the sensor. Add ChildOf for that.
  • A trace is the past. For the intruder’s position while in view, read its transform.

Occlusion and attitude

So far the guard sees through walls, and it takes anyone on another team for an enemy. This chapter fixes both. Walls block its sight, and you decide who it’s hostile to.

Perception doesn’t depend on a physics engine. What blocks sight and who’s hostile are systems that join its stages. With avian, line of sight is ready-made.

Walls with avian

Enable the avian2d feature of bevy_arbor, and depend on avian as you would anyway:

[dependencies]
bevy_arbor = { version = "0.1", features = ["avian2d"] }
avian2d = "0.8"

ArborPlugins then adds LineOfSightPlugin. It casts a ray from where each sensor looked to each thing it might see. If the ray hits something else first, the thing is blocked. The walls only need colliders:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
use avian2d::prelude::*;

fn spawn(mut commands: Commands) {
commands.spawn((
    RigidBody::Static,
    Collider::rectangle(1.0, 6.0),
    Transform::from_xyz(5.0, 0.0, 0.0),
));
}

Now the intruder behind the wall at x = 5 is out of the guard’s sight.

  • The guard’s own colliders, and its sensor’s, never block.
  • A collider on the intruder, or on a child of it, is the intruder. It doesn’t block.
  • Trigger volumes (avian’s Sensor) never block.
  • The plugin needs avian’s PhysicsPlugins. Without them it warns and blocks nothing.

What blocks

By default, every collision layer blocks sight. Set Occluders to choose the layers:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
use avian2d::prelude::*;
use bevy_arbor::avian2d::sight::Occluders;

#[derive(PhysicsLayer, Default)]
enum Layer {
    #[default]
    Default,
    Walls,
    Glass,
    Guards,
}

fn main() {
    App::new()
        .add_plugins((
            MinimalPlugins,
            PhysicsPlugins::default(),
            ArborPlugins.with_space(Space::Planar),
        ))
        // Walls and glass block sight. Other guards don't.
        .insert_resource(Occluders(LayerMask::from([Layer::Walls, Layer::Glass])))
        .run();
}

A sensor can override that with its own SensorOccluders. The lookout camera sees through glass:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
use avian2d::prelude::*;
#[derive(PhysicsLayer, Default)] enum Layer { #[default] Default, Walls, Glass }
use bevy_arbor::avian2d::sight::SensorOccluders;

fn spawn(mut commands: Commands, guard: Entity) {
commands.spawn((
    Sight::new(30.0),
    SensorOf(guard),
    SensorOccluders(LayerMask::from([Layer::Walls])),
    Transform::from_xyz(-15.0, 4.0, 0.0),
));
}

Sight points

A ray goes to the middle of what it looks at. So an intruder whose head shows over a low crate is still hidden. SightPoints lists the points it can be seen by, relative to it. It’s seen if a ray to any of them gets through:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)] struct Intruder;
use bevy_arbor::avian2d::sight::SightPoints;

fn spawn(mut commands: Commands) {
commands.spawn((
    Intruder,
    Perceivable,
    Team(2),
    SightPoints(vec![Vec3::new(0.0, 0.8, 0.0), Vec3::ZERO, Vec3::new(0.0, -0.8, 0.0)]),
    Transform::from_xyz(8.0, 0.0, 0.0),
));
}

Head, middle and feet. A head over the crate is enough.

Finding what’s near

With avian, sight finds what’s near a guard through avian’s colliders (ColliderSightPlugin, in ArborPlugins), not perception’s own grid. Avian keeps its colliders up to date anyway, so there’s nothing to rebuild.

So what can be seen needs a collider, on it or on a child of it. An intruder without one is never found. Give it the collider it has anyway, for physics.

A look asks avian for every collider in range: the walls and floor tiles too. Put what’s perceivable on a layer of its own, and say so with PerceivableLayers:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
use avian2d::prelude::*;
use bevy_arbor::avian2d::sight::PerceivableLayers;

#[derive(PhysicsLayer, Default)]
enum Layer {
    #[default]
    Default,
    Walls,
    Bodies,
}

fn setup(app: &mut App) {
    // Looks ask only for the guards and intruders.
    app.insert_resource(PerceivableLayers(LayerMask::from(Layer::Bodies)));
}

Without avian’s PhysicsPlugins, it warns and sight keeps its own grid. A game on another physics engine can do the same with its own: implement SightIndex and call app.set_sight_index::<YourIndex>().

Touch from contacts

The avian integration also adds ContactTouchPlugin. It turns collisions into Touched messages for agents with Touch. A bump or a hit is felt without code of yours. Limit it to some layers, so the guard feels a sword and not the floor:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
use avian2d::prelude::*;
#[derive(PhysicsLayer, Default)] enum Layer { #[default] Default, Hitbox }
use bevy_arbor::avian2d::touch::ContactTouchPlugin;

fn main() {
App::new().add_plugins((
    MinimalPlugins,
    PhysicsPlugins::default(),
    ArborPlugins.set(ContactTouchPlugin { layers: LayerMask::from([Layer::Hitbox]) }),
));
}

An agent with Touch gets avian’s CollisionEventsEnabled, so its own collider reports contacts. A collider on a child of the agent needs one too.

Occlusion of your own

Without avian, write the occluder yourself. Perception runs in four stages, PerceptionSystems:

StageWhat runs there
GatherEach sensor collects what it might sense this tick
OccludeBlocks what’s out of sight
ClassifyGives each candidate an attitude
CommitWrites Perceived<S> and forgets what faded

Systems in Occlude and Classify work on each sensor’s Candidates<S>. Here’s one blocking sight with a raycast of your own:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)] struct Wall;
fn ray_hits_wall(_: Vec3, _: Vec3, _: &Query<&GlobalTransform, With<Wall>>) -> bool { false }
fn walls(mut sensors: Query<&mut Candidates<Sight>>, walls: Query<&GlobalTransform, With<Wall>>) {
    for mut candidates in &mut sensors {
        let Some(eye) = candidates.eye() else {
            continue;
        };
        for candidate in candidates.iter_mut() {
            if ray_hits_wall(eye, candidate.position(), &walls) {
                candidate.block();
            }
        }
    }
}

fn add(app: &mut App) {
app.add_systems(FixedUpdate, walls.in_set(PerceptionSystems::Occlude));
}

eye() is where the sensor looked from this tick. It’s None if the sensor didn’t look, and then there’s nothing to block. Several occluders combine: one blocking is enough.

Attitude

Every percept has an Attitude: hostile, neutral or friendly. The guard’s conditions use it through is_hostile.

By default it comes from Team:

The guardThe otherAttitude
Team(1)Team(1)Friendly
Team(1)Team(2)Hostile
Either has no teamNeutral

That’s why the guard has Team(1) and the intruder Team(2).

Attitude of your own

A system in PerceptionSystems::Classify can set the attitude instead. What it sets stands over the team. Here, a disguised intruder passes as a friend:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)]
struct Disguised;

fn disguises(mut sensors: Query<&mut Candidates<Sight>>, disguised: Query<(), With<Disguised>>) {
    for mut candidates in &mut sensors {
        for candidate in candidates.iter_mut() {
            if disguised.contains(candidate.entity()) {
                candidate.set_attitude(Attitude::Friendly);
            }
        }
    }
}

fn add(app: &mut App) {
app.add_systems(FixedUpdate, disguises.in_set(PerceptionSystems::Classify));
}

When the attitude changes, Perceived<S> changes too. Drop the disguise, and the guard’s tree wakes and sees an enemy.

Pitfalls

  • Perception’s schedule. It runs in FixedUpdate unless you said otherwise. Add your stage systems to the same schedule.
  • A step behind. Avian runs in FixedPostUpdate, so a ray sees colliders where the last physics step left them. An AI won’t notice.
  • The wrong space. With avian2d, perception has to work in Space::Planar, and with avian3d in Space::Volume. Otherwise it looks where the physics isn’t, and a warning says so at startup.
  • Perceivable without a collider. With avian, sight finds things through their colliders. Without one, a perceivable is never seen.
  • Two classifiers on one candidate. The last to run wins, and their order isn’t fixed. Make each handle its own case, or order them with .after.

Senses of your own

The guard gets a dog. The dog can’t see far in the dark, but it smells the intruder through walls. Smell isn’t built in, so you write it.

A sense of your own has three parts:

  1. A component implementing Sense: its configuration.
  2. SensePlugin registering it.
  3. A system in PerceptionSystems::Gather that fills each sensor’s Candidates<S>.

The built-in senses are made the same way.

A minimal sense

use std::time::Duration;
use bevy::prelude::*;
use bevy_arbor::prelude::*;
/// On an agent: it smells what's `Scented` within range.
#[derive(Component, Reflect)]
struct Smell {
    range: f32,
}

impl Sense for Smell {
    const MEMORY: Duration = Duration::from_secs(20);
}

/// On what leaves a scent: the intruder.
#[derive(Component)]
struct Scented;

fn sniff(
    space: Res<Space>,
    scents: Query<(Entity, &GlobalTransform), With<Scented>>,
    mut noses: Query<(&Smell, &GlobalTransform, &mut Candidates<Smell>)>,
) {
    for (smell, nose, mut candidates) in &mut noses {
        let nose = space.flatten(nose.translation());
        for (scent, at) in &scents {
            let at = space.flatten(at.translation());
            if at.distance(nose) <= smell.range {
                candidates.sense(scent, at);
            }
        }
    }
}

fn add(app: &mut App) {
app.add_plugins(SensePlugin::<Smell>::default())
    .add_systems(FixedUpdate, sniff.in_set(PerceptionSystems::Gather));
}

Spawn the dog with Smell { range: 6.0 } and a tree of its own. Give the intruder Scented. The dog’s conditions read Perceived<Smell>, just as the guard’s read Perceived<Sight>:

use std::time::Duration;
use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Reflect)] struct Smell { range: f32 }
impl Sense for Smell { const MEMORY: Duration = Duration::from_secs(20); }
fn smells_intruder(smelled: &Perceived<Smell>) -> bool {
    smelled.iter().any(Percept::is_hostile)
}

What the plugin gives

SensePlugin::<Smell> gives the sense everything the built-in ones have:

  • Perceived<Smell> on each agent with Smell, or with a sensor that has it.
  • Candidates<Smell> on each sensor, emptied after every tick.
  • Memory, for Smell::MEMORY or the agent’s own remembering.
  • Sharing: a Share passes on what the dog smelled.
  • The events Noticed<Smell>, Lost<Smell> and Forgotten<Smell>.
  • Your occlusion and attitude systems work on it, as on any sense (Occlusion and attitude).

Add it after PerceptionPlugin, that is after ArborPlugins.

In 2D

candidates.sense(entity, position) takes a position in perception’s Space. Pass positions through Space::flatten, as sniff does. In 2D it drops z, which 2D uses for layering, so z doesn’t count toward distance.

Sustained senses

Smell, as written, is a sense of moments, like hearing. Each sniff refreshes the memory, and the scent fades when the sniffing stops. It’s never active, and never lost.

Each sniff also changes Perceived<Smell>, since the scent was sensed anew. sniff runs every tick, so the dog’s tree wakes every tick while it smells the intruder. Sniff less often, or make the sense sustained.

A sustained sense keeps a thing active while it lasts, as sight does. Say so with SUSTAINED, and call look_from each time a sensor looks:

use std::time::Duration;
use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)] struct Scented;
#[derive(Component, Reflect)]
struct Smell {
    range: f32,
}

impl Sense for Smell {
    const SUSTAINED: bool = true;
    const MEMORY: Duration = Duration::from_secs(20);
}

fn sniff(
    space: Res<Space>,
    scents: Query<(Entity, &GlobalTransform), With<Scented>>,
    mut noses: Query<(&Smell, &GlobalTransform, &mut Candidates<Smell>)>,
) {
    for (smell, nose, mut candidates) in &mut noses {
        let nose = space.flatten(nose.translation());
        candidates.look_from(nose);
        for (scent, at) in &scents {
            let at = space.flatten(at.translation());
            if at.distance(nose) <= smell.range {
                candidates.sense(scent, at);
            }
        }
    }
}

What a sensor sensed at its last look is active. What none of an agent’s sensors sensed at their last look is lost, and triggers Lost<Smell>.

A sensor that doesn’t call look_from this tick keeps what it had. So a sense can look less often than every tick, as sight does with its interval.

look_from also says where the sensor looked from. That’s the eye() occluders cast rays from. Avian’s LineOfSightPlugin only blocks sight by default. For it to block another sense, add LineOfSightPlugin::<Smell> too.

Pitfalls

  • Forgetting Gather. Outside PerceptionSystems::Gather, a gather system isn’t ordered with occlusion and attitude. What it senses may skip them, or be committed a tick late.
  • A moment sensed every tick. For a sense that isn’t sustained, every sensing changes Perceived<S> and wakes the trees reading it.
  • A sustained sense without look_from. Nothing is ever active or lost.

Queries

A tree decides what the guard does. An environment query decides where, or at what. When the guard reloads, it wants a spot the intruder can’t see. When two intruders are in view, it wants the better one to shoot at. Both are queries. Turn them on with the eqs feature.

A query for cover

A query makes items, drops the ones that fail its filters, scores the rest, and picks one. Here is the guard’s cover:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
use avian2d::prelude::Collider;
use bevy_arbor::avian2d::eqs::{Clearance, LineOfSight, OccluderDistance};

/// Somewhere out of the intruder's sight.
struct Cover;

fn cover() -> EnvQuery {
    // Where the intruder is, or was last seen.
    let intruders = || Hostiles::<Sight>::remembered();
    // Points level with the guard, either side of it.
    EnvQuery::new(Row::along(Agent).extent(8.0).spacing(0.5))
        // Keep the points the guard fits in.
        .filter(Clearance::of(Collider::circle(0.4)), Filter::is(true))
        // And that the intruder can't see.
        .filter(LineOfSight::from(intruders()), Filter::is(false))
        // Prefer up against the wall, near the guard, far from the intruder.
        .score(OccluderDistance::new(), Score::lower())
        .score(Distance::to(Agent), Score::lower().weight(0.5))
        .score(Distance::to(intruders()), Score::higher().weight(0.5))
}

The guard’s world is seen from the side, so its cover is a Row along the ground at its height, not a Donut, whose points would be in the air.

remembered() is where the intruder is while it’s in view, and where it was last seen after. So the guard can still take cover once it’s lost sight of the intruder.

Cover is the query’s key. It’s any type of yours. The next chapter adds the query to the app under it and runs it.

A query for a target

Items can be things, not just places. This query weighs the intruders the guard sees:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
/// The intruder to shoot at.
struct Target;

fn target() -> EnvQuery {
    EnvQuery::new(Percepts::<Sight>::all().active().hostile())
        // The nearest, and then the one most in front.
        .score(Distance::to(Agent), Score::lower())
        .score(Dot::of(Agent), Score::higher().weight(0.5))
}

Generators

A generator makes the items.

GeneratorItems
Donut::around(a)Points on rings round an anchor: .radius(inner, outer), .rings(n), .points(n)
Grid::around(a)Points on a square grid: .extent(e), .spacing(s), at most 50 steps each way
Fan::from(a)Points on rays ahead of an anchor: .angle(a), .range(near, far), .rays(rays, steps)
Row::along(a)Points on a line through an anchor, the way it looks: .extent(e), .spacing(s), at most 50 steps each way
Percepts::<S>::all()What the agent perceives by sense S: .active(), .hostile()
Nearby::<C>::within(a, r)Things with component C near an anchor

Points lie in the plane things stand on. That’s perception’s Space: the XY plane in 2D, and in 3D the XZ plane at the anchor’s height. In a 2D side view, that plane is the wall, not the floor: a Row keeps to the guard’s height, where a Donut would put points in the air. Add more generators with .generate(..): the items of all of them are weighed together.

Anchors

An anchor is where a generator or a test measures from.

AnchorWhere
AgentThe agent running the query
At(point)A fixed point
Thing(entity)An entity
Hostiles::<S>::active()Hostiles the agent perceives by S now
Hostiles::<S>::remembered()Hostiles where they were last sensed

An anchor can be at several places, such as two intruders. A test then measures from the nearest. A generator makes items round each.

Tests

A test gives each item a number.

TestNumber
Distance::to(a)How far the item is from the anchor
Dot::of(a)1 straight ahead of the anchor, -1 behind
Perceives::<S>::at_all()1 if the agent perceives the item by S, else 0
RandomA random number from 0 to 1
LineOfSight::from(a)1 if a ray from the anchor reaches the item (avian, with eqs)
Clearance::of(shape)1 if the shape, centered on the item, overlaps no collider: room to stand (avian, with eqs)
OccluderDistance::new()How far the item is from the nearest collider that blocks sight: .on(layers) for others (avian, with eqs)

A test can have no answer for an item: Perceives for a place, say. The item then fails any filter on that test, and scores nothing by it.

Filters

A filter keeps the items whose number is in range.

  • Filter::at_least(min) and Filter::at_most(max).
  • Filter::range(min, max).
  • Filter::is(yes), for a test that answers 1 or 0.

Filters run before scores, cheapest first. So the cover query casts a ray only for the points that are left.

Scores

A score turns a test’s numbers into 0 for the worst item and 1 for the best.

  • Score::higher() prefers high numbers. Score::lower() prefers low ones.
  • .squared() makes the best stand out more.
  • .weight(w) says how much this score counts. It’s 1 unless set.

An item’s score is the sum of its scores. A query with no scores scores every item kept 1.

Picks

.pick(..) says what the query gives.

  • Pick::Best: the best item. This is the default.
  • Pick::RandomOfBest(0.25): one item at random among the best quarter, for variety.
  • Pick::All: every item kept, best first.

Pitfalls

  • An anchor that’s nowhere. Hostiles::<Sight>::active() is nowhere when the guard sees no one. Then a line-of-sight test from it has no answer for any point, and every point fails. Use remembered() to hide from where the intruder was last seen.
  • Points in walls. Generators don’t know about walls. A point inside one is out of sight, so it passes a line-of-sight filter. Filter on Clearance first.
  • Points in mid-air. In a side view, points level with the guard can be over a drop, and a Row doesn’t follow the floor up a slope or onto a ledge. Rule them out with a test of your own: a Measure that casts a ray down from each point.
  • Straight lines. Distance is as the crow flies. A spot behind a wall may be near but a long walk away.
  • Too many items. Every item runs every test. A donut of 3 rings of 16 is 48 points; a fine grid can be thousands. Keep generators coarse, and put cheap filters first.
  • Normalized scores. Scores compare items with each other. The best item scores 1 even if it’s poor. Use a filter to rule items out.

Running queries

The previous chapter built the guard’s Cover and Target queries. This one adds them to the app, runs them from the guard’s tree, and reads what they found. It ends with generators, tests and anchors of your own.

Adding and running a query

Add a query to the app under its key with add_env_query:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
struct Cover;
fn cover() -> EnvQuery { EnvQuery::new(Donut::around(Agent)) }
fn setup(app: &mut App) {
    app.add_env_query(Cover, cover());
}

In a tree, a query is a task: task(Survey::of(Cover)). It succeeds if the query found something, and fails if not. Out of ammo, the guard stops shooting. It looks for cover, goes there, and reloads:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
struct Cover;
#[derive(Component)] struct Ammo(u32);
#[derive(Component, Clone)] struct TakeCover;
#[derive(Component, Clone)] struct Reload;
#[derive(Component, Clone)] struct Shoot;
fn out_of_ammo(ammo: &Ammo) -> bool { ammo.0 == 0 }
fn fight() -> Behavior {
    behavior_tree! {
        Select => {
            when(out_of_ammo) => Sequence => {
                task(Survey::of(Cover)),
                task(TakeCover),
                task(Reload),
            },
            task(Shoot),
        }
    }
}

Reading what it found

What a query found goes on the agent as a Found<Cover>. A task system reads it like any component:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
struct Cover;
#[derive(Component, Clone)] struct TakeCover;
fn take_cover(mut tasks: Tasks<&TakeCover, (&Found<Cover>, &mut Transform)>) {
    tasks.for_each(|_, mut task, (cover, mut at)| {
        let Some(spot) = cover.position() else {
            task.fail();
            return;
        };
        // Walk toward the spot, and succeed once there.
        let step = (spot - at.translation).clamp_length_max(0.1);
        at.translation += step;
        if at.translation.distance(spot) < 0.1 {
            task.succeed();
        }
    });
}

The Navigation chapter walks the guard there with MoveTo::found::<Cover>() instead of a task of its own.

Found has:

  • position(): where the best item is.
  • entity(): what the best item is, if it’s a thing. The Target query’s intruder.
  • best(): the best item and its score.
  • iter(): every item, best first. That’s one item, unless the query picks Pick::All.

Found<Cover> is removed when a run finds nothing. It changes only when a run finds something else. So found::<Cover> is a typed condition: the guard has cover in mind.

When queries run

Queries run in EqsSystems, after trees tick in the same schedule. A query a tree asks for runs that tick. The tree reads its task’s result the next tick.

All queries together get at most the EqsBudget each tick. It’s one millisecond unless you set it:

use std::time::Duration;
use bevy::prelude::*;
use bevy_arbor::prelude::*;
fn setup(app: &mut App) {
app.insert_resource(EqsBudget(Duration::from_micros(500)));
}

Queries left over wait for the next tick, oldest first. At least one runs each tick. A hundred guards all looking for cover at once spread over a few ticks.

Outside a tree

Insert a Survey::of(Cover) on an agent to run the query for it once. It’s taken off when it has run, and Found<Cover> is written as usual. This suits a game system that wants an answer without a tree.

A query of your own parts

A generator, test or anchor of your own implements a trait. A test can also be a system, which is easier (below). Each trait is given a QueryContext. It has:

  • world(): the world, read-only.
  • agent(): whose query it is.
  • space(): 2D or 3D, as perception works in.
  • position(entity) and ahead(entity): where a thing is, and which way it looks.
  • locate(&anchor): where an anchor is. Each anchor is located once a run, however many items ask.
  • roll(): a random number from 0 to 1, the query’s own.

An anchor

An anchor implements Locate. The guard has a post it shouldn’t stray far from:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
use bevy_arbor::eqs::anchor::{Locate, Located};
use bevy_arbor::eqs::query::QueryContext;

/// Where a guard is posted.
#[derive(Component)]
struct GuardPost(Vec3);

/// The agent's post, as an anchor.
struct Post;

impl Locate for Post {
    fn locate(&self, cx: &QueryContext, out: &mut Vec<Located>) {
        if let Some(post) = cx.world().get::<GuardPost>(cx.agent()) {
            out.push(Located { position: post.0, ahead: Vec3::ZERO, entity: None });
        }
    }
}

.score(Distance::to(Post), Score::lower()) then prefers cover near the post. An anchor can push no places, or several.

A test from a system

Dark spots hide a guard well. The easy way to a test of your own is a read-only system, made a test with Measure::by. Here it’s how far each item is from the nearest lamp:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)]
struct Lamp;

/// How far each item is from the nearest lamp.
fn darkness(batch: Batch, lamps: Query<&GlobalTransform, With<Lamp>>) -> Vec<Option<f32>> {
    batch
        .items
        .iter()
        .map(|item| {
            let lamps = lamps.iter().map(|lamp| lamp.translation().distance(item.position()));
            lamps.reduce(f32::min)
        })
        .collect()
}

fn dark_cover() -> EnvQuery {
    EnvQuery::new(Donut::around(Agent).radius(2.0, 8.0).rings(3).points(16))
        .score(Measure::by(darkness), Score::higher())
}
  • The system’s first parameter is a Batch: the agent, and every item. Any read-only system parameters follow it.
  • It answers every item at once, in order: a number, or None for no answer. So setup, like finding the lamps, happens once a run.
  • An answer of the wrong length, or a system that can’t run (a resource missing), is no answer for any item.
  • It’s Cost::High unless you say otherwise with .costing(Cost::Medium).

The Test trait

For full control, implement Test. The same test, as a trait:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
use bevy_arbor::eqs::query::{Item, QueryContext};
use bevy_arbor::eqs::test::{Cost, Test};

#[derive(Component)]
struct Lamp;

/// How far an item is from the nearest lamp.
struct Darkness;

impl Test for Darkness {
    fn test(&self, cx: &QueryContext, item: &Item) -> Option<f32> {
        self.test_all(cx, std::slice::from_ref(item)).pop().flatten()
    }

    // Find the lamps once a run, not once an item.
    fn test_all(&self, cx: &QueryContext, items: &[Item]) -> Vec<Option<f32>> {
        let world = cx.world();
        let lamps: Vec<Vec3> = world
            .try_query_filtered::<&GlobalTransform, With<Lamp>>()
            .map(|mut lamps| lamps.iter(world).map(GlobalTransform::translation).collect())
            .unwrap_or_default();
        items
            .iter()
            .map(|item| lamps.iter().map(|lamp| lamp.distance(item.position())).reduce(f32::min))
            .collect()
    }

    fn cost(&self) -> Cost {
        Cost::Medium
    }
}
  • test gives one item’s number, or None for no answer.
  • test_all gives every item’s at once. Override it when a test has setup to do once a run, as here.
  • cost is Low, Medium or High. Filters run cheapest first.
  • prepare(&mut World) runs before each query the test is in, for setup that needs to change the world.

A generator

A generator implements Generate: generate(&self, cx, out) pushes Items onto out. An item is a place, Item::Point(position), or a thing, Item::Entity { entity, position }.

Pitfalls

  • A key never added. A Survey for a key with no add_env_query logs a warning and fails its task.
  • Reading Found too soon. A task right after the Survey in a Sequence is fine: the sequence waits for the survey. A system reading Found on its own may see last time’s answer.
  • Stale answers. Found<Cover> stays until the next run. If the intruder moves, ask again.

Smart objects

Some behavior belongs to things, not agents. The guard’s wall has a turret. The turret knows how it’s fired. The guard only needs to know it wants a free turret. A smart object has slots: places an agent can claim, so no one else takes them. Each slot can bring the behavior for using it. Turn smart objects on with the smart feature.

A turret with a gunner’s slot

A slot is an entity with SlotOf pointing at its object. What it offers is a component of yours on it. A Plugged<Using> on it is the behavior it brings:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)]
struct WallTurret;

/// What the turret's slot offers.
#[derive(Component)]
struct Gunner;

/// A task: fire the turret.
#[derive(Component, Clone)]
struct FireTurret;

fn fire_turret() -> Behavior {
    behavior_tree! { task(FireTurret) }
}

fn spawn_turret(mut commands: Commands) {
    commands
        .spawn((WallTurret, Transform::from_xyz(6.0, 2.0, 0.0)))
        .with_related::<SlotOf>((
            Gunner,
            Plugged::new(Using, fire_turret),
            Transform::from_xyz(5.0, 0.0, 0.0),
        ));
}

The slot’s transform is where the gunner stands. SlotOf isn’t ChildOf, so it’s in world space.

Finding a free slot

FreeSlots<A> is a query generator. It makes the slots offering A near an anchor that the agent could claim. Score them like any items:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)] struct Gunner;
/// The nearest free turret.
struct Turret;

fn setup(app: &mut App) {
    app.add_env_query(
        Turret,
        EnvQuery::new(FreeSlots::<Gunner>::within(Agent, 20.0))
            .score(Distance::to(Agent), Score::lower()),
    );
}

Claiming and using it

claim::<Turret>() is a decorator. It claims the slot the Turret query found, for as long as its branch runs. The slot’s Plugged<Using> is plugged into the guard, and Socket(Using) runs it:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
struct Turret;
#[derive(Component, Clone)] struct GoToSlot;
fn sees_intruder(seen: &Perceived<Sight>) -> bool { seen.active().any(Percept::is_hostile) }
fn man_turret() -> Behavior {
    behavior_tree! {
        Select => {
            when(sees_intruder && found::<Turret> && claim::<Turret>()) => Sequence => {
                task(GoToSlot),
                Socket(Using),
            },
            when(sees_intruder) => task(Survey::of(Turret)),
        }
    }
}

The guard sees an intruder and looks for a turret. Once it has found one, the first branch passes. It claims the slot, walks there and fires. GoToSlot is your task. It finds the slot in the guard’s Claims:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component, Clone)] struct GoToSlot;
fn go_to_slot(
    mut tasks: Tasks<&GoToSlot, (&Claims, &mut Transform)>,
    slots: Query<&GlobalTransform, With<SlotOf>>,
) {
    tasks.for_each(|_, mut task, (claims, mut at)| {
        let Some(slot) = claims.iter().find_map(|slot| slots.get(slot).ok()) else {
            task.fail();
            return;
        };
        let spot = slot.translation();
        let step = (spot - at.translation).clamp_length_max(0.1);
        at.translation += step;
        if at.translation.distance(spot) < 0.1 {
            task.succeed();
        }
    });
}

With the nav feature, task(MoveTo::found::<Turret>()) does the same, and your movement takes the guard there. See Navigation.

When the branch ends, however it ends, the claim is given up. The plugged behavior is taken out of the guard too. If the guard had a Plugged<Using> of its own, it’s set aside while the slot’s runs, and put back after.

The details

  • One claim, one agent. claim passes while the slot is free or already the guard’s. If two guards go for one turret, the first to claim it wins. The other’s claim stops passing, and its Select moves on.
  • Lost slots. A slot taken by someone else, given SlotDisabled, or despawned cancels the branch.
  • Claims are a relationship. ClaimedBy is on the slot, Claims on the agent. Observe On<Add<ClaimedBy>> and On<Remove<ClaimedBy>> to hear claims and releases. Despawning the agent gives its slots up.
  • Objects own their slots. The object’s Slots lists them. Despawning the object despawns them.
  • Disabled slots. A slot with SlotDisabled can’t be found or claimed: a broken turret.
  • SmartPlugin. It’s in ArborPlugins. It registers the components up front, so slots can be found before any is claimed.

Queries about the slot

ClaimedSlots is an anchor at the slots the agent holds. Use it to ask about the turret, not the guard. The ammo crate nearest the turret:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)]
struct AmmoCrate;

/// The ammo crate nearest the guard's turret.
struct Resupply;

fn setup(app: &mut App) {
    app.add_env_query(
        Resupply,
        EnvQuery::new(Nearby::<AmmoCrate>::within(ClaimedSlots, 10.0))
            .score(Distance::to(ClaimedSlots), Score::lower()),
    );
}

ClaimedSlots is nowhere while the agent holds no slot.

Pitfalls

  • Claiming without finding. claim::<Turret>() claims what Found<Turret> holds. Put found::<Turret> before it, and run the query first.
  • Leaving the branch. The claim lasts only while its branch runs. A task outside that branch can’t count on the slot.

Navigation

The guard found cover. Now it has to walk there. bevy_arbor finds no paths and moves nothing: your game already has its own movement, and its own idea of where a character can go. Navigation is the contract between the two. The AI says where the agent should be, and your movement says how getting there goes. Turn it on with the nav feature.

The contract

Three components on the agent:

ComponentWhat it saysWho writes it
DestinationWhere the agent is asked to go: a Goal (a point, or an entity to follow) and how near counts as thereA MoveTo task, or you
TravelHow it’s going: Moving, Arrived or UnreachableYour movement
RouteThe way it’s taking, as points, if your movement saysYour movement, for gizmos and debugging

Your movement reads Destination. It finds a way with whatever pathfinding suits the game, moves the agent its own way, and writes Travel.

Moving from a tree

MoveTo is a task that asks for the agent to be somewhere:

  • MoveTo::point(post): a point.
  • MoveTo::entity(leader): an entity, wherever it goes.
  • MoveTo::found::<Cover>(): where the query keyed by Cover found. It fails if the query found nothing.
  • .within(2.0): how near counts as there. Half a unit unless set.

The guard’s fight, with cover it walks to:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
struct Cover;
#[derive(Component)] struct Ammo(u32);
#[derive(Component, Clone)] struct Reload;
#[derive(Component, Clone)] struct Shoot;
fn out_of_ammo(ammo: &Ammo) -> bool { ammo.0 == 0 }
fn fight() -> Behavior {
    behavior_tree! {
        Select => {
            when(out_of_ammo) => Sequence => {
                task(Survey::of(Cover)),
                task(MoveTo::found::<Cover>()),
                task(Reload),
            },
            task(Shoot),
        }
    }
}

As MoveTo starts, it sets the guard’s Destination and clears its old Travel. An Arrived left from the last move isn’t this one’s. It succeeds once your movement writes Travel::Arrived, and fails on Travel::Unreachable, or if the entity it’s going to is despawned. However it ends, the Destination it set is taken off, with Travel and Route. A move cut short by an interruption stops the guard. A Destination set since, by another move or by you, stays.

A Graceful MoveTo doesn’t stop when interrupted. It carries on until it arrives or can’t get there: for a move you’d rather finish.

Your movement

The guard lives in a side-scroller. This movement walks it along its floor, toward its goal’s x. A goal on another floor is out of reach: getting there takes jumps, which need a jump graph (see below).

use bevy::prelude::*;
use bevy_arbor::prelude::*;
/// How fast the guard walks, in units a second.
const SPEED: f32 = 3.0;

/// Walks each agent with a destination along its floor, and says how it goes.
fn walk(
    mut agents: Query<(Entity, &Destination, &mut Transform, Option<&Travel>)>,
    places: Query<&GlobalTransform>,
    time: Res<Time>,
    mut commands: Commands,
) {
    for (agent, destination, mut at, travel) in &mut agents {
        let Some(goal) = destination.goal.position(&places) else {
            continue;
        };
        let away = goal.x - at.translation.x;
        let now = if (goal.y - at.translation.y).abs() > 1.0 {
            // On another floor: only a jump graph would know the way.
            Travel::Unreachable
        } else if away.abs() <= destination.within {
            Travel::Arrived
        } else {
            at.translation.x += away.signum() * (SPEED * time.delta_secs()).min(away.abs());
            Travel::Moving
        };
        // Written only when it changes.
        if travel != Some(&now) {
            let on_floor = Vec3::new(goal.x, at.translation.y, at.translation.z);
            commands.entity(agent).insert((now, Route(vec![on_floor])));
        }
    }
}

fn main() {
    App::new()
        .add_plugins((
            DefaultPlugins,
            ArborPlugins.with_space(Space::Planar),
        ))
        .add_systems(FixedUpdate, walk.after(ArborSystems))
        .run();
}

It only sees agents with a Destination. So when a MoveTo ends and the destination goes, the guard stops.

MoveTo tasks are kept in NavSystems, in ArborSystems after trees and queries. Your movement runs after ArborSystems. A MoveTo a tree starts sets its destination that tick, and your movement starts moving the same tick.

Finding the way

Which pathfinding fits depends on how the game’s characters move:

GameHow characters movePathfinding
Top-down 2DAnywhere on the floorA navmesh: vleue_navigator, built from avian2d colliders
3DAnywhere on walkable groundA navmesh: rerecast builds it from avian3d colliders, and landmass finds paths, steers and avoids
TilesTile to tileA grid: bevy_northstar, or A* from the pathfinding crate
Side-scrollerAlong floors, then falling, dropping and jumpingA jump graph of your own, searched with the pathfinding crate

The guard’s world is the last kind. A navmesh is a floor plan: an agent on it can go anywhere within its polygons, in any direction. In a side view, that floor plan would take in the air between platforms, and the guard would walk up into it. A walker in a side-scroller moves along a floor’s surface. To go anywhere else, it has to fall off an end, drop through a platform, or jump. Whether a jump makes it depends on its speed, gravity, its height and the ceilings in the way.

So a side-scroller’s graph has places to stand as its points, and four kinds of link between them: walk, fall, drop and jump. Each jump is kept only if a jump at the walker’s speed lands. Build the graph once per level and body size, and search it with A*. Your movement then follows the path leg by leg, jumping where a leg says to.

Path costs in queries

The guard’s cover query measures straight lines. A spot behind a wall may be near, and still a long walk away. PathDistance measures the walk instead, with your pathfinder. Set the pathfinder once, as a read-only system:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
struct Cover;
/// The walk to each item along the guard's floor, if it's on that floor.
fn walk_costs(batch: Batch, places: Query<&GlobalTransform>) -> Vec<Option<f32>> {
    let Ok(agent) = places.get(batch.agent) else {
        return vec![None; batch.items.len()];
    };
    let from = agent.translation();
    batch
        .items
        .iter()
        .map(|item| {
            let to = item.position();
            ((to.y - from.y).abs() <= 1.0).then(|| (to.x - from.x).abs())
        })
        .collect()
}

fn setup(app: &mut App) {
    app.set_path_costs(walk_costs).add_env_query(
        Cover,
        EnvQuery::new(Donut::around(Agent).radius(2.0, 8.0).rings(3).points(16))
            // Cheap first: no further than 10 as the crow flies.
            .filter(Distance::to(Agent), Filter::at_most(10.0))
            // Then only what's a walk of 12 or less.
            .filter(PathDistance::new(), Filter::at_most(12.0))
            .score(Distance::to(Agent), Score::lower()),
    );
}

The system gets a Batch: the agent, and every item the query still has. It answers each, in order, with a cost or None. None means there’s no way there, and the item fails the filter. The whole batch comes at once, so one search from the agent can answer every item.

The cheap Distance filter runs first, so the pathfinder sees fewer items.

Gizmos

With the gizmos feature, ArborGizmosPlugin draws where agents are going: a ring round each destination, as wide as how near counts as there, and the route each agent is taking. Its gizmo group is NavGizmos. Set a color to None to leave that out.

Pitfalls

  • Nothing writes Travel. MoveTo waits for your movement to say Arrived or Unreachable. Without it, the task never ends.
  • Moving without a destination. When a MoveTo ends, however it ends, Destination is taken off. Movement that keeps going after that walks a cancelled guard on.
  • Two schedules. NavPlugin runs in FixedUpdate, as trees and queries do. Moved to another schedule, it warns: MoveTo is no longer ordered after them.
  • One search per item. Path costs come in one batch. Answer them with one search from the agent where you can, not one path an item.
  • No pathfinder set. PathDistance without set_path_costs warns, and answers nothing for any item. Every item fails a filter on it.
  • Scoring by path cost. An unreachable item has no answer, and scores nothing. Filter on PathDistance to drop it. A second PathDistance in a score searches again.

The complete guard

This chapter puts every part of the guard in one listing. Each part is explained in the chapter linked next to it. Read it top to bottom: the app, what’s in the world, the tree, then the task systems.

What it does

The listing

use std::time::Duration;

use avian2d::prelude::*;
use bevy::prelude::*;
use bevy_arbor::avian2d::eqs::{Clearance, LineOfSight, OccluderDistance};
use bevy_arbor::prelude::*;

// The guard, the intruder, and what the guard does.

#[derive(Component)]
struct Guard;

#[derive(Component)]
struct Intruder;

#[derive(Component, PartialEq)]
struct Ammo(u32);

#[derive(Component, Clone)]
struct Patrol { from: f32, to: f32 }

#[derive(Component, Clone)]
struct Chase;

#[derive(Component, Clone)]
struct Search;

#[derive(Component, Clone)]
struct Shoot;

#[derive(Component, Clone)]
struct Reload;

#[derive(Component, Clone)]
struct Shout;

#[derive(Component, Clone)]
struct FireTurret;

// The turret on the wall, and its gunner's slot.

#[derive(Component)]
struct WallTurret;

#[derive(Component)]
struct Gunner;

// Query keys.

/// Somewhere out of the intruder's sight.
struct Cover;

/// The intruder to shoot at.
struct Target;

/// The nearest free turret.
struct Turret;

// The app.

fn main() {
    App::new()
        .add_plugins((
            DefaultPlugins,
            PhysicsPlugins::default(),
            ArborPlugins.with_space(Space::Planar),
        ))
        .add_env_query(Cover, cover())
        .add_env_query(Target, target())
        .add_env_query(Turret, turret())
        .add_systems(Startup, spawn)
        .add_systems(
            FixedUpdate,
            (
                systems::patrol,
                systems::chase,
                systems::shoot,
                systems::reload,
                systems::search,
                systems::shout,
                systems::travel,
                systems::fire_turret,
            )
                .after(ArborSystems),
        )
        .run();
}

fn spawn(mut commands: Commands) {
    commands.spawn((
        Guard,
        Sight::new(12.0).with_lose_range(15.0),
        Hearing::new(8.0),
        Shared,
        Team(1),
        Facing(Dir3::X),
        Ammo(6),
        Transform::from_xyz(0.0, 0.0, 0.0),
        BehaviorTree::new(guard()),
    ));
    // The intruder: sight finds it through its collider.
    commands.spawn((
        Intruder,
        Perceivable,
        Team(2),
        Collider::capsule(0.4, 1.0),
        Transform::from_xyz(30.0, 0.0, 0.0),
    ));
    // A wall: it blocks sight.
    commands.spawn((RigidBody::Static, Collider::rectangle(1.0, 6.0), Transform::from_xyz(5.0, 0.0, 0.0)));
    // A turret, with one place for a gunner.
    commands
        .spawn((WallTurret, Transform::from_xyz(-6.0, 2.0, 0.0)))
        .with_related::<SlotOf>((Gunner, Plugged::new(Using, fire_turret), Transform::from_xyz(-6.0, 0.0, 0.0)));
}

// Conditions.

fn sees_intruder(seen: &Perceived<Sight>) -> bool {
    seen.active().any(Percept::is_hostile)
}

fn remembers_intruder(seen: &Perceived<Sight>) -> bool {
    seen.iter().any(|p| p.is_hostile() && !p.is_active())
}

fn out_of_ammo(ammo: &Ammo) -> bool {
    ammo.0 == 0
}

// The tree.

fn guard() -> Behavior {
    behavior_tree! {
        Forever => {
            Select => {
                // Call allies, and look for a turret.
                when(sees_intruder && cooldown(10.0)) => Sequence => {
                    task(Shout),
                    task(Survey::of(Turret)),
                },
                when(sees_intruder && found::<Turret> && claim::<Turret>()) => Sequence => {
                    task(MoveTo::found::<Turret>()),
                    Socket(Using),
                },
                when(sees_intruder) => @ fight(),
                when(remembers_intruder) => @ search(),
                @ patrol(),
            }
        }
    }
}

fn fight() -> Behavior {
    behavior_tree! {
        Select => {
            when(out_of_ammo) => Sequence => {
                task(Survey::of(Cover)),
                task(MoveTo::found::<Cover>()),
                task((Reload, Graceful)),
            },
            Sequence => {
                task(Survey::of(Target)),
                task(Chase),
                Commit => { task(Shoot) },
                Wait(0.8),
            },
        }
    }
}

fn search() -> Behavior {
    behavior_tree! { task(Search) }
}

fn patrol() -> Behavior {
    behavior_tree! { task(Patrol { from: -10.0, to: 10.0 }) }
}

/// What the turret's slot brings: fire it.
fn fire_turret() -> Behavior {
    behavior_tree! { task(FireTurret) }
}

// The queries.

fn cover() -> EnvQuery {
    let intruders = || Hostiles::<Sight>::remembered();
    EnvQuery::new(Row::along(Agent).extent(8.0).spacing(0.5))
        .filter(Clearance::of(Collider::circle(0.4)), Filter::is(true))
        .filter(LineOfSight::from(intruders()), Filter::is(false))
        .score(OccluderDistance::new(), Score::lower())
        .score(Distance::to(Agent), Score::lower().weight(0.5))
        .score(Distance::to(intruders()), Score::higher().weight(0.5))
}

fn target() -> EnvQuery {
    EnvQuery::new(Percepts::<Sight>::all().active().hostile())
        .score(Distance::to(Agent), Score::lower())
        .score(Dot::of(Agent), Score::higher().weight(0.5))
}

fn turret() -> EnvQuery {
    EnvQuery::new(FreeSlots::<Gunner>::within(Agent, 20.0))
        .score(Distance::to(Agent), Score::lower())
}

// The task systems.

mod systems {
    use super::*;

    const SPEED: f32 = 3.0;

    /// Moves `at` toward `x` along the ground; true once there.
    fn walk(at: &mut Transform, x: f32, time: &Time) -> bool {
        let gap = x - at.translation.x;
        at.translation.x += gap.clamp(-1.0, 1.0) * SPEED * time.delta_secs();
        gap.abs() < 0.1
    }

    pub fn patrol(mut tasks: Tasks<&Patrol, (&mut Transform, &mut Facing)>, time: Res<Time>) {
        tasks.for_each(|patrol, _, (mut at, mut facing)| {
            let x = at.translation.x;
            if x >= patrol.to {
                facing.set_if_neq(Facing(Dir3::NEG_X));
            } else if x <= patrol.from {
                facing.set_if_neq(Facing(Dir3::X));
            }
            at.translation += facing.0 * SPEED * time.delta_secs();
        });
    }

    pub fn chase(
        mut tasks: Tasks<&Chase, (&Found<Target>, &mut Transform)>,
        places: Query<&GlobalTransform>,
        time: Res<Time>,
    ) {
        tasks.for_each(|_, mut task, (target, mut at)| {
            let Some(there) = target.entity().and_then(|e| places.get(e).ok()) else {
                task.fail();
                return;
            };
            // Close enough to shoot.
            if (there.translation().x - at.translation.x).abs() < 6.0 {
                task.succeed();
                return;
            }
            walk(&mut at, there.translation().x, &time);
        });
    }

    pub fn shoot(mut tasks: Tasks<&Shoot, &mut Ammo>) {
        tasks.for_each(|_, mut task, mut ammo| {
            if ammo.0 == 0 {
                task.fail();
                return;
            }
            ammo.0 -= 1;
            task.succeed();
        });
    }

    pub fn reload(mut tasks: Tasks<&Reload, &mut Ammo>) {
        const RELOAD: Duration = Duration::from_secs(2);
        tasks.for_each(|_, mut task, mut ammo| {
            if task.elapsed() >= RELOAD {
                ammo.0 = 6;
                task.succeed();
            } else if task.interrupted() && task.elapsed() < RELOAD / 2 {
                task.fail();
            }
        });
    }

    pub fn search(
        mut tasks: Tasks<&Search, (&Perceived<Sight>, &Traces<Sight>, &mut Transform)>,
        time: Res<Time>,
    ) {
        tasks.for_each(|_, mut task, (seen, traces, mut at)| {
            let intruder = seen.iter().find(|p| p.is_hostile());
            let last_seen = intruder.and_then(|p| traces.get(p.entity()));
            if task.elapsed() > Duration::from_secs(8) {
                task.fail();
            } else if let Some(there) = last_seen {
                walk(&mut at, there.position.x, &time);
            }
        });
    }

    pub fn shout(mut tasks: Tasks<&Shout, Entity>, mut shares: MessageWriter<Share>) {
        tasks.for_each(|_, mut task, guard| {
            shares.write(Share::allies(guard, 20.0));
            task.succeed();
        });
    }

    /// The guard's movement: walks it toward its `Destination`, set by a
    /// `MoveTo`, and says how it goes.
    pub fn travel(
        mut guards: Query<(Entity, &Destination, &mut Transform, Option<&Travel>)>,
        places: Query<&GlobalTransform>,
        time: Res<Time>,
        mut commands: Commands,
    ) {
        for (guard, destination, mut at, travel) in &mut guards {
            let Some(goal) = destination.goal.position(&places) else {
                continue;
            };
            let now = if (goal.x - at.translation.x).abs() <= destination.within {
                Travel::Arrived
            } else {
                walk(&mut at, goal.x, &time);
                Travel::Moving
            };
            if travel != Some(&now) {
                commands.entity(guard).insert(now);
            }
        }
    }

    pub fn fire_turret(mut tasks: Tasks<&FireTurret, Entity>) {
        tasks.for_each(|_, _, _gunner| {
            // Aim the turret and fire. It runs until the guard leaves it.
        });
    }
}

How it reads

  • The top of the tree is a priority list. Select runs the first branch that passes. Shouting comes first, so it interrupts a fight once every ten seconds. See Building trees and Large trees and sockets.
  • The turret branch has three decorators. It passes only while the guard sees the intruder, has found a turret, and can claim it. See Smart objects.
  • The fight runs two queries. Target picks whom to chase. Cover picks where to reload. See Running queries.
  • MoveTo asks, travel moves. The tree says where the guard should be. The travel system walks it there and writes Travel. See Navigation.
  • Timing lives in the tasks. The search gives up after eight seconds. The reload takes two. See Tasks.
  • The tree sleeps on patrol. Every condition is typed, so the tree wakes only when what they read changes. See Sleeping trees.

Pitfalls

  • The task schedule. Task systems go in the trees’ schedule, after ArborSystems. See How it fits together.
  • Facing written every tick. The patrol uses set_if_neq. See Sleeping trees.
  • Physics for walls. The wall and the cover query’s LineOfSight need avian’s PhysicsPlugins. Without them, sight isn’t blocked and the cover test has no answer.

Debugging

When the guard does something odd, you want to know three things. What is its tree running? What does it perceive? What did its queries weigh? Traces answer the first and the last as components. Gizmos draw all three in the world.

What the tree is running

Insert a TreeTrace on the guard. Its tree then keeps it up to date:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)] struct Guard;
fn guard() -> Behavior { behavior_tree! { Wait(1.0) } }
fn spawn_guard(mut commands: Commands) {
    commands.spawn((Guard, BehaviorTree::new(guard()), TreeTrace::default()));
}

Written out with Display, a trace is the tree’s outline. BehaviorTree displays the same way, without a trace. A > marks what’s running. Each decorator is written above the branch it guards. Here is the guard on patrol:

> Forever
>   Select
      when (sees_intruder && cooldown(10s))
        Shout
      when sees_intruder
        Select
          when out_of_ammo
            Reload
          Sequence
            Chase
            Commit
              Shoot
            Wait(0.8s)
      when remembers_intruder
        Search
>     Patrol

Log it whenever it changes:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
fn log_trees(traces: Query<(Entity, &TreeTrace), Changed<TreeTrace>>) {
    for (agent, trace) in &traces {
        info!("{agent}:\n{trace}");
    }
}

A trace changes only when the tree does, so this logs only what’s new.

Reading a trace in code

  • nodes() is every node, root first, each after its parent. A node’s index is its id.
  • running() is the running nodes with their ids, root down.
  • get(id) is one node.

Each TraceNode has label(), parent(), notes() (what’s written above it), is_running() and holds() (whether it asked not to be interrupted).

use bevy::prelude::*;
use bevy_arbor::prelude::*;
/// What the guard is doing: the deepest node running.
fn doing(trace: &TreeTrace) -> Option<&str> {
    trace.running().last().map(|(_, node)| node.label())
}

A trace is a string per node. Keep it on the agents you’re looking at, not on every agent in a shipped game.

What a query weighed

Insert a SurveyTrace on the guard. Each query run for it then records:

  • query: the key’s name, such as "Cover".
  • items: every item, as a Weighed with its item, its score (None if a filter dropped it) and whether it was picked.
use bevy::prelude::*;
use bevy_arbor::prelude::*;
/// How many points the last query dropped.
fn dropped(trace: &SurveyTrace) -> usize {
    trace.items.iter().filter(|weighed| weighed.score.is_none()).count()
}

If the cover query finds nothing, this tells you whether the filter dropped every point, or the generator made none.

Both traces are reflected components. An inspector shows them as they change.

Gizmos

With the gizmos feature, ArborGizmosPlugin draws in the world. It isn’t in ArborPlugins, as it needs a renderer. Add it where you have one, with DefaultPlugins:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
fn main() {
    App::new()
        .add_plugins((
            DefaultPlugins,
            ArborPlugins.with_space(Space::Planar),
            ArborGizmosPlugin,
        ))
        .run();
}

It draws each part that’s on:

PartDraws
PerceptionSight cones out to range, fainter out to lose_range. Hearing ranges. A line to everything in view, colored by attitude. A cross where what’s remembered was last sensed.
QueriesEach agent’s last query, from its SurveyTrace. Kept items from red to green by score, dropped ones gray, the pick ringed.
Smart objectsEvery slot: free, claimed with a line to its agent, or disabled.
NavigationA ring round each Destination, as wide as how near counts as there, and each agent’s Route.

Each part has a gizmo group of its own: PerceptionGizmos, EqsGizmos, SmartGizmos and NavGizmos. Each kind of shape has a color, or None to leave it out. Change them through Bevy’s GizmoConfigStore, as for any gizmo group. To draw only the guard you’re looking at, turn draw_all off and give it ShowPerception:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
#[derive(Component)] struct Guard;
fn watch_one(
    mut config: ResMut<GizmoConfigStore>,
    mut commands: Commands,
    guard: Single<Entity, With<Guard>>,
) {
    let (_, gizmos) = config.config_mut::<PerceptionGizmos>();
    gizmos.draw_all = false;
    gizmos.hearing = None;
    commands.entity(*guard).insert(ShowPerception);
}

Pitfalls

  • No trace, no record. TreeTrace and SurveyTrace are kept only where you insert them. The query gizmos draw only agents with a SurveyTrace.
  • The last query only. A SurveyTrace holds the last query run for the agent. With two queries in a row, you see the second.
  • Headless apps. ArborGizmosPlugin needs Bevy’s gizmos, in DefaultPlugins. Leave them out of a server or a test.

Testing

AI is easiest to test headless, one tick at a time. You place the guard and the intruder, tick, and check which task the guard is running. 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 guard chases what it sees

This test gives the guard the tree from Your first agent. It checks that the guard patrols, chases the intruder once it’s in view, and patrols again once it’s out of view:

use std::time::Duration;
use bevy::prelude::*;
use bevy_arbor::prelude::*;
use bevy_harness::TestApp;

#[derive(Component, Clone)] struct Patrol { from: f32, to: f32 }
#[derive(Component, Clone)] struct Chase;
fn sees_intruder(seen: &Perceived<Sight>) -> bool { seen.active().any(Percept::is_hostile) }
fn guard() -> Behavior {
    behavior_tree! {
        Forever => {
            Select => {
                when(sees_intruder) => task(Chase),
                task(Patrol { from: -10.0, to: 10.0 }),
            }
        }
    }
}
/// An app with the guard at the origin, looking right.
fn guard_app() -> (TestApp, Entity) {
    let mut app = TestApp::builder()
        .plugins(ArborPlugins.with_space(Space::Planar))
        .build();
    let guard = app.spawn((
        Sight::new(12.0).with_lose_range(15.0).with_interval(Duration::ZERO),
        Team(1),
        Facing(Dir3::X),
        GlobalTransform::IDENTITY,
        BehaviorTree::new(guard()),
    ));
    (app, guard)
}

fn main() {
// #[test]
// fn the_guard_chases_what_it_sees() {
let (mut app, _guard) = guard_app();
let intruder = app.spawn((Perceivable, Team(2), GlobalTransform::from_xyz(30.0, 0.0, 0.0)));

// Too far to see: it patrols.
app.tick();
assert_eq!(app.count::<With<Patrol>>(), 1);

// In view: it chases.
app.entity_mut(intruder).insert(GlobalTransform::from_xyz(10.0, 0.0, 0.0));
app.tick();
assert_eq!(app.count::<With<Chase>>(), 1);
assert_eq!(app.count::<With<Patrol>>(), 0);

// Out past the lose range: it patrols again.
app.entity_mut(intruder).insert(GlobalTransform::from_xyz(20.0, 0.0, 0.0));
app.tick();
assert_eq!(app.count::<With<Chase>>(), 0);
assert_eq!(app.count::<With<Patrol>>(), 1);
// }
}

In your crate, the body is a #[test] function. Tasks are entities, so app.count::<With<Chase>>() counts the guard’s running Chase tasks.

Keeping tests simple

  • Look every tick. Sight::with_interval(Duration::ZERO) makes the guard look every tick. The default looks five times a second, at a moment of its own.
  • Set GlobalTransform directly. The test app has no transform plugin, so nothing copies Transform into GlobalTransform. Perception reads the global one.
  • Leave out task systems you don’t test. Without a chase system, the Chase task just keeps running. That’s enough to see the tree chose it.
  • Write messages with app.write. A Noise or a Touched is read on the next tick.
  • Let time pass with app.secs(..). Use it for Wait, cooldowns, timeouts and memory.
  • Wait for a condition with app.until(..). It ticks until the condition holds, and fails the test after a limit: app.until(|world| ..).within_secs(5.0).

Checking the tree itself

To check more than which task runs, add a TreeTrace to the guard and look at what’s running. See Debugging:

use bevy::prelude::*;
use bevy_arbor::prelude::*;
fn running(trace: &TreeTrace) -> Vec<&str> {
    trace.running().map(|(_, node)| node.label()).collect()
}

Pitfalls

  • One tick isn’t always enough. The tree reads a task’s report on the tick after the task gives it. A query asked for by a tree runs that tick, and the tree sees its result the next. Tick twice, or use until.
  • The space. A 2D game’s tests need Space::Planar too. Without it, perception works in 3D, where ahead is -Z unless Facing says otherwise.

Performance

One guard costs next to nothing. A level with hundreds of them is where cost shows. This chapter gives the measured numbers, then what keeps each part cheap.

The numbers are from one machine, in release builds. Yours will differ. The shape won’t.

Trees

examples/load in bevy_arbor_bt runs many agents. Each agent’s tree is a Select of four decorators over one running task.

AgentsSystem conditionsTyped conditions, nothing changingTyped conditions, a tenth changing each tick
10017 µs3.7 µs5.5 µs
50078 µs12 µs21 µs
2,000305 µs41 µs78 µs

Per agent, that’s about 150 nanoseconds when it asks four system conditions every tick. Asleep, it’s about 20. Run it yourself:

cargo run --release -p bevy_arbor_bt --example load

What keeps trees cheap:

  • Typed conditions over system conditions. A system condition is asked every tick. A typed condition is asked again only when what it reads changes. See Decorators.
  • set_if_neq. A component written every frame keeps every tree that reads it awake, even if the value is the same. Write it only when it changes. See Sleeping trees.
  • Noises at the rate they matter. A noise each tick wakes every tree listening, each tick.
  • Timing in tasks. A task that counts its own time doesn’t wake the tree to do it. See Large trees and sockets.

Sight

Without avian, sight finds what’s near through a spatial grid of its own. Its cells are as large as a typical sight, and it’s rebuilt on the ticks a sensor looks. This is a 2D micro-benchmark, in which a tenth of the agents look each tick:

AgentsThrough everythingThrough the grid
1,00058 µs38 µs
2,000196 µs81 µs
10,0004.13 ms435 µs

Looking through everything grows with the square of the agents. The grid grows in step with them, at about 10 nanoseconds a thing to rebuild.

With avian, sight asks avian’s colliders instead, which avian keeps up to date anyway: nothing to rebuild. See Occlusion and attitude.

What keeps perception cheap:

  • A longer interval. Sight looks five times a second by default, each agent at a moment of its own. Guards far from the player can look less often.
  • Perceivables on a layer of their own. With avian, a look asks for every collider in range. Set PerceivableLayers, so it skips walls and tiles.
  • Occlusion only where needed. A line-of-sight ray is cast only for what’s already in range and in the cone. Fewer colliders on the occluding layers make each ray cheaper. See Occlusion and attitude.

Queries

Queries share a time budget, EqsBudget, a millisecond each tick unless you set it. Queries over budget wait for the next tick, oldest first. At least one runs each tick. So many guards asking at once delay each other, rather than stall the frame. See Running queries.

What keeps queries cheap:

  • Few items. Every item runs every test. A donut of 3 rings of 16 is 48 points. Keep generators coarse.
  • Cheap filters first. Filters run cheapest first by each test’s Cost. A LineOfSight filter then casts rays only for what’s left.
  • test_all for setup. A test of your own that looks something up once a run, not once an item, should do it in test_all.
  • Ask when needed. Run a query when the guard needs an answer, not every tick.

Debugging costs

TreeTrace keeps a string per node and SurveyTrace a list per run. They’re kept only on agents you put them on. Leave them off in a shipped build. See Debugging.

Feature flags

bevy_arbor

FeatureDefaultEnables
btYesBehavior trees: bevy_arbor_bt as bevy_arbor::bt. Adds TreePlugin to ArborPlugins.
perceptionNoPerception: bevy_arbor_perception as bevy_arbor::perception. Adds PerceptionPlugin to ArborPlugins.
eqsNoEnvironment queries: bevy_arbor_eqs as bevy_arbor::eqs. Adds EqsPlugin to ArborPlugins. Turns on bt and perception, and the avian crates’ tests (LineOfSight, Clearance, OccluderDistance) where they’re on.
smartNoSmart objects: bevy_arbor_smart as bevy_arbor::smart. Adds SmartPlugin to ArborPlugins. Turns on eqs.
navNoNavigation: bevy_arbor_nav as bevy_arbor::nav. Adds NavPlugin to ArborPlugins. Turns on bt. With eqs on too, MoveTo::found and PathDistance.
gizmosNoDebug drawing: bevy_arbor_gizmos as bevy_arbor::gizmos, with what queries, smart objects and navigation draw when those parts are on. Turns on perception. ArborGizmosPlugin isn’t in ArborPlugins, since it needs a renderer.
avian2dNoPerception with avian2d: bevy_arbor_avian2d as bevy_arbor::avian2d. Adds AvianPerceptionPlugins to ArborPlugins. Turns on perception.
avian3dNoThe same with avian3d, as bevy_arbor::avian3d.

The prelude includes the avian crate that’s on. With both on, their names clash, so neither is in the prelude. Use bevy_arbor::avian2d and bevy_arbor::avian3d directly.

bevy_arbor_gizmos

It always draws perception, as PerceptionGizmos.

FeatureDefaultEnables
eqsNoDrawing how queries weighed their items, as EqsGizmos
smartNoDrawing smart objects’ slots, as SmartGizmos
navNoDrawing where agents are going, as NavGizmos

bevy_arbor_nav

FeatureDefaultEnables
eqsYesMoveTo::found and PathDistance, with environment queries

bevy_arbor_avian2d and bevy_arbor_avian3d

FeatureDefaultEnables
parry-f32Yesavian’s single-precision collider backend
eqsNoLineOfSight, Clearance and OccluderDistance, environment query tests

For double precision, turn off default features and enable parry-f64 on your own avian dependency. The two backends can’t both be on.

bevy_arbor_bt and bevy_arbor_macros have no features.