Overview
This lab builds a guard that decides what to do every frame by walking a small tree of nodes instead of a single switch. You will build a Node base type and two composites, Sequence and Selector, then four leaves that read and move a shared Blackboard, and assemble them into a tree that tries an attack, then a chase, then falls back to a patrol. The finished program opens an SFML window where a guard visibly turns red, orange or green and changes what it is doing as you move the mouse toward and away from it.
Learning Objectives
By the end of this lab you will be able to:
- Explain what Status::Running means and why a multi-frame action like walking to a waypoint needs it, unlike Success or Failure alone
- Implement a Sequence composite that behaves as a logical AND over its children and a Selector composite that behaves as a logical OR
- Implement condition and action leaf nodes that read and modify a shared Blackboard
- Assemble a Selector-of-Sequences tree that prioritizes an Attack branch over a Chase branch over a Patrol fallback
- Wire the tree into an SFML per-frame loop so a mouse-controlled player visibly changes which branch the guard follows
Concept
The State Machines and Traffic Light Transitions labs modeled a fixed set of exclusive states with one switch on one enum. A guard that has to check whether it can attack, then whether it can chase, then fall back to patrolling needs several independent checks tried in priority order every frame, and nesting more if statements inside a single switch case stops scaling quickly once there is more than one thing to check. A behaviour tree solves this by breaking the decision into small reusable nodes that can be composed instead of nested.
Every node implements one method, tick, which returns a Status of Success, Failure, or Running. A Sequence ticks its children in order and stops at the first one that is not Success, so it behaves like a logical AND. A Selector ticks its children in order and stops at the first one that is not Failure, so it behaves like a logical OR. Running exists because some actions, like walking toward a target, take many frames to finish, and the tree needs a way to say a node is still working on it rather than reporting success or failure prematurely.
A shared Blackboard struct carries state between nodes, positions, a delta time, and which leaf last ran, since nodes cannot otherwise communicate with each other. Nodes are owned through std::unique_ptr<Node> in a std::vector, and the whole tree is ticked from its root once per frame with tree->tick(bb).
Tasks
Task 1 - The project and a blank window
Create a new Console App (C++) project in Visual Studio the same way as in the Environment Setup lab, naming it BehaviorTreeDemo. Use Add > New Item to add a new C++ source file named BehaviorTree.cpp, and write the code below.
Success condition: building and running the project opens a plain white 900 by 700 window titled Behavior Tree - Guard AI, and closing it with the window's close button ends the program cleanly.
#include <SFML/Graphics.hpp>
#include <iostream>
int main()
{
sf::RenderWindow window(sf::VideoMode({ 900u, 700u }), "Behavior Tree - Guard AI");
sf::Font font;
if (!font.openFromFile("C:/Windows/Fonts/arial.ttf"))
{
std::cerr << "Failed to load font - readout will not render." << std::endl;
}
while (window.isOpen())
{
while (const std::optional<sf::Event> event = window.pollEvent())
{
if (event->is<sf::Event::Closed>()) window.close();
}
window.clear(sf::Color::White);
window.display();
}
}
Task 2 - Status, the Blackboard, and the Node interface
Open BehaviorTree.cpp. Above main(), complete the three declarations below.
- In
enum class Status, declare three values,Success,Failure,Running. - In
struct Blackboard, add threesf::Vector2fmembers namedguardPos,playerPosandpatrolTarget, afloat deltaTimeinitialised to0.0f, and aconst char* lastLeafinitialised to"Patrol". - In
struct Node, declare a pure virtual method,Status tick(Blackboard& bb).
The project still builds once these are filled in, but nothing on screen changes yet since nothing uses them.
#include <SFML/Graphics.hpp>
#include <iostream>
enum class Status
{
// TODO: declare three values, Success, Failure, Running
};
struct Blackboard
{
// TODO: add three sf::Vector2f members, guardPos, playerPos and
// patrolTarget, a float deltaTime = 0.0f, and a const char* lastLeaf
// initialised to "Patrol"
};
struct Node
{
virtual ~Node() = default;
// TODO: declare a pure virtual Status tick(Blackboard& bb)
};
int main()
{
sf::RenderWindow window(sf::VideoMode({ 900u, 700u }), "Behavior Tree - Guard AI");
sf::Font font;
if (!font.openFromFile("C:/Windows/Fonts/arial.ttf"))
{
std::cerr << "Failed to load font - readout will not render." << std::endl;
}
while (window.isOpen())
{
while (const std::optional<sf::Event> event = window.pollEvent())
{
if (event->is<sf::Event::Closed>()) window.close();
}
window.clear(sf::Color::White);
window.display();
}
}
Task 3 - The Sequence composite
Open BehaviorTree.cpp. Add #include <memory> and #include <vector> below the existing includes, since Sequence owns its children through a std::vector<std::unique_ptr<Node>>. Below struct Node, complete Sequence::tick so it loops over children in order, calling tick on each, and returns immediately with that child's status the moment it is not Status::Success. If every child succeeds, return Status::Success.
The project still builds, but nothing uses Sequence yet.
#include <SFML/Graphics.hpp>
#include <iostream>
#include <memory>
#include <vector>
enum class Status { Success, Failure, Running };
struct Blackboard
{
sf::Vector2f guardPos;
sf::Vector2f playerPos;
sf::Vector2f patrolTarget;
float deltaTime = 0.0f;
const char* lastLeaf = "Patrol";
};
struct Node
{
virtual ~Node() = default;
virtual Status tick(Blackboard& bb) = 0;
};
struct Sequence : Node
{
std::vector<std::unique_ptr<Node>> children;
Status tick(Blackboard& bb) override
{
// TODO: loop over children, call tick(bb) on each, and return that
// child's status immediately the moment it is not Status::Success
return Status::Success;
}
};
int main()
{
sf::RenderWindow window(sf::VideoMode({ 900u, 700u }), "Behavior Tree - Guard AI");
sf::Font font;
if (!font.openFromFile("C:/Windows/Fonts/arial.ttf"))
{
std::cerr << "Failed to load font - readout will not render." << std::endl;
}
while (window.isOpen())
{
while (const std::optional<sf::Event> event = window.pollEvent())
{
if (event->is<sf::Event::Closed>()) window.close();
}
window.clear(sf::Color::White);
window.display();
}
}
Task 4 - The Selector composite
Open BehaviorTree.cpp. Below Sequence, complete Selector::tick so it loops over children in order, calling tick on each, and returns immediately with that child's status the moment it is not Status::Failure. If every child fails, return Status::Failure.
Selector is the mirror image of Sequence, an OR instead of an AND. The project still builds, but nothing uses either composite yet.
struct Sequence : Node
{
std::vector<std::unique_ptr<Node>> children;
Status tick(Blackboard& bb) override
{
for (auto& child : children)
{
Status status = child->tick(bb);
if (status != Status::Success) return status;
}
return Status::Success;
}
};
struct Selector : Node
{
std::vector<std::unique_ptr<Node>> children;
Status tick(Blackboard& bb) override
{
// TODO: loop over children, call tick(bb) on each, and return that
// child's status immediately the moment it is not Status::Failure
return Status::Failure;
}
};
Task 5 - Distance and the IsPlayerInRange condition
Open BehaviorTree.cpp. Add #include <cmath> below the existing includes. Below Selector, complete distance so it returns the length of a - b using std::sqrt. Then complete IsPlayerInRange::tick so it returns Status::Success when distance(bb.guardPos, bb.playerPos) is less than or equal to range, and Status::Failure otherwise.
This is the first condition leaf, it only reads the blackboard, it never changes it.
float distance(sf::Vector2f a, sf::Vector2f b)
{
// TODO: return the length of (a - b) using std::sqrt
}
struct IsPlayerInRange : Node
{
float range;
explicit IsPlayerInRange(float range) : range(range) {}
Status tick(Blackboard& bb) override
{
// TODO: return Status::Success when distance(bb.guardPos, bb.playerPos)
// is less than or equal to range, Status::Failure otherwise
}
};
Task 6 - The AttackPlayer action
Open BehaviorTree.cpp. Below IsPlayerInRange, complete AttackPlayer::tick so it sets bb.lastLeaf to "Attack" and returns Status::Success.
Attacking is deliberately instantaneous here, it succeeds every single tick it runs, rather than modelling a wind-up over several frames.
struct AttackPlayer : Node
{
Status tick(Blackboard& bb) override
{
// TODO: set bb.lastLeaf to "Attack" and return Status::Success
}
};
Task 7 - The ChasePlayer action
Open BehaviorTree.cpp. Below AttackPlayer, complete ChasePlayer::tick. It should compute the vector from bb.guardPos to bb.playerPos, find its length with distance, and if that length is greater than 0.0001f, move bb.guardPos toward bb.playerPos by speed * bb.deltaTime along that direction.
ChasePlayer returns Status::Running, not Status::Success, since chasing never finishes on its own. It only stops because the tree tries AttackPlayer first every frame and switches to it once the player is close enough.
struct ChasePlayer : Node
{
static constexpr float speed = 220.0f;
Status tick(Blackboard& bb) override
{
bb.lastLeaf = "Chase";
// TODO: compute the vector from bb.guardPos to bb.playerPos, find its
// length with distance(), and if that length is greater than 0.0001f,
// move bb.guardPos toward bb.playerPos by speed * bb.deltaTime along
// that direction
return Status::Running;
}
};
Task 8 - The Patrol action
Open BehaviorTree.cpp. Below ChasePlayer, complete Patrol::tick.
- When
len(the distance frombb.guardPostobb.patrolTarget) is withinarrivalEpsilon, flipbb.patrolTargetto whichever ofpointAorpointBit is not currently equal to, then returnStatus::Success. - Otherwise, move
bb.guardPostowardbb.patrolTargetbyspeed * bb.deltaTimealongtoTarget / len, and returnStatus::Running.
Patrol has no condition guarding it, so it is the fallback whenever both IsPlayerInRange checks fail. It alternates between two fixed waypoints rather than a random target, so its path is predictable to watch.
struct Patrol : Node
{
static constexpr float speed = 90.0f;
static constexpr float arrivalEpsilon = 4.0f;
sf::Vector2f pointA;
sf::Vector2f pointB;
Patrol(sf::Vector2f pointA, sf::Vector2f pointB) : pointA(pointA), pointB(pointB) {}
Status tick(Blackboard& bb) override
{
bb.lastLeaf = "Patrol";
sf::Vector2f toTarget = bb.patrolTarget - bb.guardPos;
float len = distance(bb.guardPos, bb.patrolTarget);
if (len <= arrivalEpsilon)
{
// TODO: flip bb.patrolTarget to whichever of pointA / pointB it
// is not currently equal to, then return Status::Success
}
// TODO: otherwise move bb.guardPos toward bb.patrolTarget by
// speed * bb.deltaTime along toTarget / len, and return Status::Running
}
};
Task 9 - Building and assembling the tree
Open BehaviorTree.cpp.
- Below
Patrol, complete themakeSequencetemplate so it pushes every node in the parameter pack ontosequence->children. A fold expression does this in one line,(sequence->children.push_back(std::move(nodes)), ...). - In
main(), below the font loading, add the five constants and theBlackboard bbshown, then buildtreeas aSelectorwhose first two children are built withmakeSequence, anIsPlayerInRange(attackRange)paired with anAttackPlayer, and anIsPlayerInRange(chaseRange)paired with aChasePlayer, and whose third child is a plainPatrol(patrolPointA, patrolPointB).
The project still builds, but the tree is not ticked or drawn yet, so the window still shows nothing new.
template <typename... Nodes>
std::unique_ptr<Sequence> makeSequence(Nodes... nodes)
{
auto sequence = std::make_unique<Sequence>();
// TODO: push each node in the pack onto sequence->children using a fold
// expression, (sequence->children.push_back(std::move(nodes)), ...)
return sequence;
}
int main()
{
sf::RenderWindow window(sf::VideoMode({ 900u, 700u }), "Behavior Tree - Guard AI");
sf::Font font;
if (!font.openFromFile("C:/Windows/Fonts/arial.ttf"))
{
std::cerr << "Failed to load font - readout will not render." << std::endl;
}
const float attackRange = 60.0f;
const float chaseRange = 220.0f;
const sf::Vector2f patrolPointA(200.0f, 350.0f);
const sf::Vector2f patrolPointB(700.0f, 350.0f);
Blackboard bb;
bb.guardPos = patrolPointA;
bb.patrolTarget = patrolPointB;
// TODO: build a Selector named tree, then push_back two branches built
// with makeSequence(std::make_unique<IsPlayerInRange>(attackRange),
// std::make_unique<AttackPlayer>()) and the equivalent for chaseRange
// and ChasePlayer, followed by a plain
// std::make_unique<Patrol>(patrolPointA, patrolPointB) as the third child
while (window.isOpen())
{
while (const std::optional<sf::Event> event = window.pollEvent())
{
if (event->is<sf::Event::Closed>()) window.close();
}
window.clear(sf::Color::White);
window.display();
}
}
Task 10 - Ticking the tree and coloring the guard
Open BehaviorTree.cpp. Add #include <algorithm> and #include <string> below the existing includes.
- Below the tree assembly, add the
guardShapeandsf::Clock clockshown. - At the top of the loop body, set
bb.deltaTimefromclock.restart().asSeconds(), clamped to0.033fwithstd::min, and setbb.playerPosfromsf::Vector2f(sf::Mouse::getPosition(window)). - Call
tree->tick(bb). - Pick a
guardColor, green by default, orange whenstd::string(bb.lastLeaf) == "Chase", red when it equals"Attack", and callguardShape.setFillColor(guardColor).
Success condition: with the mouse far from the window, the guard appears as a green circle patrolling back and forth between two fixed points on its own.
// TODO: build a Selector named tree, then push_back two branches built
// with makeSequence(std::make_unique<IsPlayerInRange>(attackRange),
// std::make_unique<AttackPlayer>()) and the equivalent for chaseRange
// and ChasePlayer, followed by a plain
// std::make_unique<Patrol>(patrolPointA, patrolPointB) as the third child
sf::CircleShape guardShape(16.0f);
guardShape.setOrigin(sf::Vector2f(16.0f, 16.0f));
sf::Clock clock;
while (window.isOpen())
{
while (const std::optional<sf::Event> event = window.pollEvent())
{
if (event->is<sf::Event::Closed>()) window.close();
}
// TODO: set bb.deltaTime from clock.restart().asSeconds(), clamped to
// 0.033f with std::min, and set bb.playerPos from
// sf::Vector2f(sf::Mouse::getPosition(window))
// TODO: call tree->tick(bb)
// TODO: pick a guardColor, green by default (sf::Color(60, 170, 70)),
// orange (sf::Color(230, 150, 40)) when std::string(bb.lastLeaf) ==
// "Chase", red (sf::Color(200, 50, 50)) when it equals "Attack", and
// call guardShape.setFillColor(guardColor)
guardShape.setPosition(bb.guardPos);
window.clear(sf::Color::White);
window.draw(guardShape);
window.display();
}
}
Task 11 - Drawing the player
Open BehaviorTree.cpp. Below guardShape, add the playerShape shown. Inside the loop, after guardShape.setPosition(bb.guardPos), set playerShape's position to bb.playerPos, and draw it after guardShape.
Success condition: a small dark dot now follows the mouse cursor inside the window, separate from the green patrolling guard.
sf::CircleShape guardShape(16.0f);
guardShape.setOrigin(sf::Vector2f(16.0f, 16.0f));
sf::CircleShape playerShape(8.0f);
playerShape.setOrigin(sf::Vector2f(8.0f, 8.0f));
playerShape.setFillColor(sf::Color(40, 40, 40));
sf::Clock clock;
while (window.isOpen())
{
while (const std::optional<sf::Event> event = window.pollEvent())
{
if (event->is<sf::Event::Closed>()) window.close();
}
bb.deltaTime = std::min(clock.restart().asSeconds(), 0.033f);
bb.playerPos = sf::Vector2f(sf::Mouse::getPosition(window));
tree->tick(bb);
sf::Color guardColor = sf::Color(60, 170, 70);
if (std::string(bb.lastLeaf) == "Attack") guardColor = sf::Color(200, 50, 50);
else if (std::string(bb.lastLeaf) == "Chase") guardColor = sf::Color(230, 150, 40);
guardShape.setFillColor(guardColor);
guardShape.setPosition(bb.guardPos);
// TODO: set playerShape's position to bb.playerPos
window.clear(sf::Color::White);
window.draw(guardShape);
// TODO: draw playerShape too, after guardShape
window.display();
}
}
Task 12 - The attack and chase range rings
Open BehaviorTree.cpp. Below playerShape, add the attackRing and chaseRing shown, two unfilled circles sized to attackRange and chaseRange. Inside the loop, before drawing anything, set both rings' positions to bb.guardPos, and draw chaseRing then attackRing before guardShape, so the guard and player render on top of them.
Success condition: moving the mouse toward the patrolling guard now shows two faint rings around it. Crossing the outer ring turns the guard orange and pulls it toward the mouse. Crossing the inner ring turns it red and it holds still. Moving back out reverses both changes in order.
sf::CircleShape playerShape(8.0f);
playerShape.setOrigin(sf::Vector2f(8.0f, 8.0f));
playerShape.setFillColor(sf::Color(40, 40, 40));
// TODO: build attackRing as an sf::CircleShape(attackRange), origin set
// to (attackRange, attackRange), transparent fill, and a translucent red
// outline (sf::Color(200, 50, 50, 120)) with thickness 2.0f
// TODO: build chaseRing the same way from chaseRange, with a translucent
// orange outline (sf::Color(230, 150, 40, 120)) instead
sf::Clock clock;
while (window.isOpen())
{
while (const std::optional<sf::Event> event = window.pollEvent())
{
if (event->is<sf::Event::Closed>()) window.close();
}
bb.deltaTime = std::min(clock.restart().asSeconds(), 0.033f);
bb.playerPos = sf::Vector2f(sf::Mouse::getPosition(window));
tree->tick(bb);
sf::Color guardColor = sf::Color(60, 170, 70);
if (std::string(bb.lastLeaf) == "Attack") guardColor = sf::Color(200, 50, 50);
else if (std::string(bb.lastLeaf) == "Chase") guardColor = sf::Color(230, 150, 40);
guardShape.setFillColor(guardColor);
// TODO: set attackRing and chaseRing positions to bb.guardPos
guardShape.setPosition(bb.guardPos);
playerShape.setPosition(bb.playerPos);
window.clear(sf::Color::White);
// TODO: draw chaseRing then attackRing here, before guardShape
window.draw(guardShape);
window.draw(playerShape);
window.display();
}
}
Task 13 - The last-node readout
Open BehaviorTree.cpp. Inside the loop, after window.draw(playerShape), construct an sf::Text readout from font reading std::string("Last node: ") + bb.lastLeaf at size 18, give it a grey fill colour, position it near the top-left corner, and draw it.
Success condition: the on-screen text now reads Last node: Patrol, Last node: Chase or Last node: Attack in step with the guard's current colour, confirming which leaf the tree chose this frame.
window.clear(sf::Color::White);
window.draw(chaseRing);
window.draw(attackRing);
window.draw(guardShape);
window.draw(playerShape);
// TODO: construct an sf::Text readout from font reading
// std::string("Last node: ") + bb.lastLeaf at size 18, set a grey
// fill colour (sf::Color(60, 60, 60)), position it near
// sf::Vector2f(20.0f, 20.0f), and draw it
sf::Text controls(font, "Move the mouse to control the player", 14);
controls.setFillColor(sf::Color(120, 120, 120));
controls.setPosition(sf::Vector2f(20.0f, 665.0f));
window.draw(controls);
window.display();
}
}
Solution
Try the tasks above before revealing the solution.