Modelling Agent Behaviour with Bevy Behave

Published: April 11, 2025.

Recently, I've been building a game with Bevy in my spare time.

I was looking to add interesting behaviour to the agents in my game, and came across a crate called Bevy Behave. 1

I thought I'd spend a few hours to look into how it works by creating a little interactive demo, and some explanatory writing to go along with it.

It quickly got out of hand, and the little demo I was planning to make became bigger than I intended.

It was a fun adventure, and I am glad to have it running interactively inside this very blog post.

The code examples assume familiarity with Rust and Bevy, but feel free to explore even if you're not — it's still entertaining to play around!

The demo

Starting it requires fetching a bunch of WebAssembly (around 12 MB uncompressed), so take care if you're tight on data.2

To start the demo, tap this button:

If everything worked correctly (🤞) you should see a bunch of grey squares in the background.

This sets the stage for my demo.

To adjust the opacity of the demo, you can use the slider in the bottom right of the screen, in the toolbar.

Spawn an agent

Now, let's spawn something in the world:

What just happened? A green square appeared in the middle of the screen. If it's not visible, something might be in the way — just scroll a bit and it should pop into view.

It appeared because the following code was triggered:

// Spawn an entity with:
//   - the `Agent` marker component,
//   - a mesh (the rectangle),
//   - and a colour (green)
commands.spawn((
  Agent,
  Mesh2d(r_meshes.add(Rectangle::new(0.9, 0.9))),
  MeshMaterial2d(
    r_materials.add(Color::from(tw::GREEN_600))
  ),
));

It appears on the screen because I've set up the Agent component like this:

// `Agent` is a marker component that requires the
// `Transform` and `GridCell` components
#[derive(Component)]
#[require(
  Transform(|| Transform::from_xyz(0.0, 0.0, 0.1)),
  GridCell
)]
pub struct Agent;

Transform is a Bevy component that makes the entity have a position in the world. For the agent, I've configured it to have a slightly higher z value so that it appears above the grey cells.

GridCell is a component I made:

// `GridCell` represents a position on the grid.
// It requires a `Transform` because it needs to
// have a position in the Bevy world.
#[derive(Component, Default)]
#[require(Transform)]
pub struct GridCell {
  pub x: isize,
  pub y: isize,
}

Moving the agents

Now, a green square and a few grey squares might entertain you for about three seconds before you realize you're staring at some motionless coloured boxes.

Let's make it a little more interesting by making the agent move around:

The agent should be moving from left to right endlessly now.

It started moving because the following component was added to the agent entity:

// walk to the left until out of bounds
WalkInDirectionUntilOutOfBounds::new(-1, 0)

I process the movement with this system:

fn process_left_right_walk(
  mut q_walkers: Query<(
      &mut GridCell,
      &mut WalkInDirectionUntilOutOfBounds
  ), With<Agent>>,
  r_grid_bounds: Res<GridBounds>,
) {
  // loop over all grid cells & walk components that
  // are attached to agents
  for (mut grid_cell, mut walk) in q_walkers.iter_mut() {
    // determine the next step, and update the agent's
    // grid cell (make it move there)
    *grid_cell = walk.step_from(&grid_cell);

    // let's see if the next step will put us out of bounds
    let next_target = walk.step_from(&grid_cell);
    if !r_grid_bounds.contains(&next_target) {
      // it would have, so we reverse (basically just flip
      // -1 to +1 or vice versa)
      walk.reverse();
    }
  }
}

All in all, this works, but try implementing anything complex and you'll quickly find yourself wrestling with spaghetti code.

At this point, I should mention you can spawn additional agents either by tapping the spawn button again or by using the button in the toolbar (below the opacity slider).

Moving the agent with Bevy Behave

Now, before doing more complex behaviour, let's first do the same behaviour with Bevy Behave:

Wow, other than maybe switching direction, nothing happened!

With Bevy Behave, I implemented the behaviour with a behaviour tree:

let tree = behave! {
  // repeat forever
  Behave::Forever => {
    Behave::Sequence => {
      // walk left until success
      Behave::spawn((
        WalkInDirectionUntilOutOfBounds::new(-1, 0),
      )),
      // walk right until success
      Behave::spawn((
        WalkInDirectionUntilOutOfBounds::new(1, 0),
      )),
    }
  }
}

Behave trees use control flow nodes:

  • Behave::Forever makes its child node loop forever.
  • Behave::Sequence processes the steps inside it in sequence.
  • Behave::spawn spawns a behaviour entity with the specified components, and waits until it reports a successful or unsuccessful result.

So, what the tree does is:

  • Walk left until success is reported.
  • Walk right until success is reported.
  • Repeat.

An important difference between Bevy Behave and my previous "naive" solution is that the behaviour components don't belong to the agent's entity—they're attached to a completely separate behaviour entity. So, to spawn the tree you add it as a child entity of the agent that you want it to control:

commands
  .spawn(BehaveTree::new(tree))
  .set_parent(agent_entity);

The system that processes the movement also changes slightly:


fn process_walk_in_direction(
  q_walks: Query<(
    &WalkInDirectionUntilOutOfBounds,
    &BehaveCtx,
  )>,
  mut q_agent_cells: Query<&mut GridCell, With<Agent>>,
  r_bounds: Res<GridBounds>,
  mut commands: Commands,
) {
  for (walk, ctx) in q_walks.iter() {
    // retrieve the target entity from the `BehaveCtx`
    // (usually the parent of the tree)
    let Ok(mut agent_cell) = q_agent_cells
      .get_mut(ctx.target_entity()) else {
      // skip if entity is not found
      continue;
    };

    // make the agent take the step
    *agent_cell = walk.step_from(&agent_cell);

    // see if we can take another step next time
    let next_target = walk.step_from(&agent_cell);
    if !r_bounds.contains(&next_target) {
      // the next step would've put the agent out of bounds,
      // so we report that this behaviour step was
      // successfully completed
      commands.trigger(ctx.success());
    }
  }
}

The beauty here is that system logic can focus solely on its specific task, then hand control back to the behaviour tree once it's finished.

With this setup, we can easily implement slightly more interesting behaviour:

The agents now use this behaviour tree:

// repeat forever
Behave::Forever => {
  Behave::Sequence => {
    Behave::spawn(
      // walk left until success
      WalkInDirectionUntilOutOfBounds((-1, 0)),
    ),
    Behave::spawn(
      // walk up until success
      WalkInDirectionUntilOutOfBounds((0, 1)),
    ),
    Behave::spawn(
      // walk right until success
      WalkInDirectionUntilOutOfBounds((1, 0)),
    ),
    Behave::spawn(
      // walk down until success
      WalkInDirectionUntilOutOfBounds((0, -1)),
    ),
  }
}

As you can see, this makes the agents move around the grid's boundaries.

A more challenging environment

So far, our agent doesn't really have a goal, apart from moving. Let's make it a little more challenging by adding a hunger / eating system:

Now, an agent will "disappear" when their hunger indicator runs out.

Quick! Let's spawn some fruit to sustain them:

Now, you'll see little red squares appearing that represent tasty fruit for the agents. When an agent is on the same cell as a piece of fruit, they eat the fruit.

Unless the agents are lucky and fruit keeps spawning on their predetermined path, they will still starve at some point. 😢

Let's give them smarter behaviour that makes them look for fruit and move to it:

The behaviour tree now looks like this:

Behave::Forever => {
  Behave::Sequence => {
    Behave::spawn(
      // find fruit target
      FindTarget::new(TargetKind::Fruit),
    ),
    Behave::spawn(
      // go to target
      GoToTarget,
    ),
  }
}

Of course, these behaviour steps (FindTarget and GoToTarget) also need to be processed in systems, but I won't bother you with those details here. Instead, take a look at the source code if you're interested.

Finding coins while not hungry

This is very cool and all, but you can do much more with Bevy Behave.

Let's make the environment more interesting by adding coins! 🤑

Now, when an agent is in the same cell as a coin, they fill up their point indicator.

With this environment, we can make the agents find coins while they're not really hungry, and find fruit otherwise:

The behaviour tree looks like this:

Behave::Forever => {
  Behave::Sequence => {
    Behave::IfThen => {
      // this check reports success if energy
      // is below 40%
      Behave::trigger(HungerCheck(0.4)),

      // spawned if hunger check succeeded
      Behave::spawn(
        FindTarget::new(TargetKind::Fruit),
      ),

      // spawned if hunger check failed
      Behave::spawn(
        FindTarget::new(TargetKind::Coins),
      ),
    },

    // go to the target we just found
    Behave::spawn(
      GoToTarget,
    ),
  }
}

I've introduced a new control flow node:

  • Behave::IfThen runs the first child. If that child reports success, it runs the second child. Otherwise it runs the third child.

With this behaviour tree, the agent first checks if they're hungry. If they are, they'll go look for food. If not, they'll look for coins instead!

Conclusion

We've only scratched the surface of what's possible.

I really like how you can define small building blocks like FindTarget and GoToTarget, and compose them into complex behaviour trees.

I look forward to building more behaviours with Bevy Behave!

You can follow me on Mastodon or Bluesky if you want to stay updated on my game development journey.

Footnotes

Footnotes

  1. Fun fact: crate maintainer RJ founded Audioscrobbler, which evolved into Last.fm.
  2. This is expected to be improved soon, as Bevy 0.16 will support no-std, making it possible to ship much smaller binaries.