University of Wales Trinity Saint David
ACCB5017 - Games Architecture and Mechanics

Behavior Tree - Guard AI

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:

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.

BehaviorTree.cpp
#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.

  1. In enum class Status, declare three values, Success, Failure, Running.
  2. In struct Blackboard, add three sf::Vector2f members named guardPos, playerPos and patrolTarget, a float deltaTime initialised to 0.0f, and a const char* lastLeaf initialised to "Patrol".
  3. 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.

BehaviorTree.cpp
#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.

BehaviorTree.cpp
#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.

BehaviorTree.cpp
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.

BehaviorTree.cpp
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.

BehaviorTree.cpp
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.

BehaviorTree.cpp
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.

  1. When len (the distance from bb.guardPos to bb.patrolTarget) is within arrivalEpsilon, flip bb.patrolTarget to whichever of pointA or pointB it is not currently equal to, then return Status::Success.
  2. Otherwise, move bb.guardPos toward bb.patrolTarget by speed * bb.deltaTime along toTarget / len, and return Status::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.

BehaviorTree.cpp
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.

  1. Below Patrol, complete the makeSequence template so it pushes every node in the parameter pack onto sequence->children. A fold expression does this in one line, (sequence->children.push_back(std::move(nodes)), ...).
  2. In main(), below the font loading, add the five constants and the Blackboard bb shown, then build tree as a Selector whose first two children are built with makeSequence, an IsPlayerInRange(attackRange) paired with an AttackPlayer, and an IsPlayerInRange(chaseRange) paired with a ChasePlayer, and whose third child is a plain Patrol(patrolPointA, patrolPointB).

The project still builds, but the tree is not ticked or drawn yet, so the window still shows nothing new.

BehaviorTree.cpp
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.

  1. Below the tree assembly, add the guardShape and sf::Clock clock shown.
  2. At the top of the loop body, 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)).
  3. Call tree->tick(bb).
  4. Pick a guardColor, green by default, orange when std::string(bb.lastLeaf) == "Chase", red when it equals "Attack", and call guardShape.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.

BehaviorTree.cpp
    // 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.

BehaviorTree.cpp
    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.

BehaviorTree.cpp
    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.

BehaviorTree.cpp
        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.