Today, let’s finish implementing the AI for our units using behaviour trees!
This article is also available on Medium.
This past couple of weeks, we saw how to use behaviour trees (BTs) to model our units AI system in Unity using virtual and abstract classes, inheritance and polymorphism. We also prepared a toolbox of BT utilities and used them to rewrite our current building/character unit behaviour as BTs.
Today, we’re going to wrap this up by adding new abilities to our units: we want them to spot close enemies, optionally follow them and then attack if they’re within range.
Here’s a little demo of what we’re going to achieve in this tutorial!
Quick recap: what should our units be able to do?
Just so we’re clear on our goals for this tutorial, let’s just restate what our units need to do.
We want our buildings to produce resources regularly. We saw last time how to create a basic BT branch with a little check, a timer and a
TaskProduceResources node and we successfully reproduced our previous behaviour. We now have a resource production logic at the entity level, that is fully controlled by the entity, and that produces new gold, wood or stone (according to the system we designed a few tutorials ago) every
The other “skill” we want our buildings to have is the ability to keep track of close enemy targets, i.e. units that are owned by another player and that are within the FOV range of our building unit. Then, if this building happens to have an
attackDamage value that is greater than zero, then it can actually attack; so if the target gets closer, within its
attackRange, the building should attack the unit.
As we saw last time, this boils down to the following behaviour tree (where the left branch is optional and only required for building units that can actually attack):
Character units have a bit more complex AI behaviour. Because they can move around the map, they aren’t just waiting for units to come close enough to them to react, they can also walk towards a specific target if need be.
There are two reasons why a character should move:
- if the player has this unit selected and right-clicks on the ground, then the unit should mark this position as its
destinationPointand walk towards it; this takes precedence over any other action, it has the highest priority
- else if the character unit has spotted a target within its FOV range but this target is too far away to be hit, the character unit should mark the enemy has its
currentTargetand try to get closer (it might need to regularly update its course if the enemy is another character that runs around the map)
With that said, if the character unit has spotted a target and that this target is within our unit’s
attackRange, then the character will rather attack its enemy than “follow” it to get closer.
Note that we will also be able to right-click a unit to set it as target (either an enemy unit or one that we own), and then the character unit will consider it a priority to follow this unit.
All in all, this gives us the diagram we saw in Part II (where, again, the “attack sequence” only gets added if the character unit can indeed attack):
We already took care of the destination point definition logic last time and we re-introduced the “move to destination” behaviour; but so far, we’ve completely ignored the
So, today, we have to finish up building those behaviour trees by creating the missing leaf node types and re-arranging our
CharacterBT tree structures to incorporate them 🙂
Setting up our attack system
Remember that the attack process we want will require the following updates:
- we’ll add three new variables to our
attackRange(how close the unit must be to the target to allow for attacks), the
attackDamage(how much each strike hurts the enemy unit) and the
attackRate(how often the unit can strike again)
- a unit that has an
attackDamagevalue of zero cannot attack (so its behaviour tree will simply not contain the “attack sequence” subtree)
- for now, we’ll say that each hit removes
attackDamagehealth points (HP) to the enemy
- if a unit reaches 0 HP, it “dies”: it’s deselected and its game object is destroyed
To begin with, let’s update our
UnitData class to add our three new variables:
You should also pick your House unit, for example, and modify its properties in the Inspector to make it able to attack:
Then, a lot of the attack logic will be done in our
UnitManager (since it’s shared between buildings and character units). We’ll create three new methods for this: the
_Die() methods. They’re pretty self-explanatory and not too hard to code up, actually:
Also, don’t forget to update the healthbar UI visual for the unit’s current health points! You might remember that when we first prepared our object, we chose the “filled” mode for the image – this allows us to very quickly modify the horizontal expand of the “Fill” Image in our script, using its
Note: you have to cast the
MaxHP denominator to a
float; otherwise, the computation will take the two variables,
MaxHP, see that they’re both
ints, and compute an integer division (that will always return 0)!
We are now ready to make our units attack enemies – it’s time to inject this action into our unit behaviour trees 😉
Adding the “attack sequence” to our buildings BT
Let’s start with the buildings since their behaviour tree is simpler overall.
For now, they regularly produce resources thanks to the (timer +
TaskProduceResources node) subtree we created last time.
We need to implement the two other branches of the BT that use the
currentTarget data slot to track enemies and attack them if they are close enough.
This means that we have to code up three new node types:
CheckEnemyInFOVRangenode will look for close units owned by other players, compare their distance to our unit to the unit’s FOV range and return “success” or “failure” based on this; they will also store a reference to the closest enemy as the
currentTargetif there is one
CheckEnemyInAttackRangewill fail immediately if there is no
currentTarget; else, it will look at the distance between this
currentTargetand our unit and compare it to the unit’s
attackRangeto return “success” or “failure”
- finally, the
TaskAttackwill only be executed if the previous check has passed; in that case, we’ll simply get the
currentTargetreference and use our brand new
Attack()method on it!
Adding the new node types
CheckEnemyInFOVRange node needs to check for nearby enemy units. This can be cut down as:
- (having some one-time computed references to the unit’s owner index and the unit’s FOV range for caching and optimisation)
- getting all the units within the FOV range
- then filtering those to only keep the ones that are not owned by the player than owns this unit
- sorting them by (ascending) distance
- taking the first one: this is the closest enemy that we’ll store in our
For checking for units within a given range, we can use one of Unity’s physics built-in: the
Physics.OverlapSphere() method. We talked about it a couple of episodes ago when we were computing the resource production for our buildings: this function gathers all the colliders around an origin point inside a (fictional) sphere of given radius and returns them as an array. Just like most of the other Unity physics tools, it can benefit from using a layer mask (in our case the
UNIT_MASK we already defined in our
Globals script) to optimise the search and constrain the list of colliders to look for.
Then, we can use the famous C# Linq utility to do the filtering, the sorting and the slicing.
Overall, this gives us the following code for the
Note: in our filtering function (the delegate in the
Where method call), we check that the object we collided with did have a
UnitManager component. This should always be the case, since we’ve restricted the search to only the targets on the
UNIT_MASK, but it avoids any null references and simply disregards units that lack this component.
You might also notice that when I sort my units according to the distance at the end of the method, I don’t use the actual magnitude of the vector but it’s square; this is a little optimisation trick. If you use the
Vector3.Distance() method, or if you take the
magnitude property of the vector between your two points, the program will need to compute a square root, which is quite expensive. And, in our case, we want to compare distances, so we don’t care about the values themselves, we only care about their relative ordering! Since squares preserve this order, it’s more efficient to stick with squared values by using the
sqrMagnitude property 😉
CheckEnemyInFOVRange node has to first assess there currently is a defined target. If there is none, then it will fail early and prevent the rest of the sequence from running.
But if there is a
currentTarget, we have to check whether it is within the
attackRange of our unit. The tricky thing to keep in mind is that the target might have a large mesh, but that it’s position will still be the point in the center… so if you don’t take this mesh size into account, you could collide with it in a pretty ugly way!
To avoid any unwanted collisions, we’ll say that we don’t exactly aim for the target’s position itself but rather for the position minus a little offset: the size of the target mesh. The question is: how do we get this mesh size?
We could do top-notch computations of the exact mesh size but it would be hard on our CPU for something that is run so often, and it probably wouldn’t be that useful anyway. Here, I’m just going to take the largest dimension of the mesh along the X and Z axis and take it as my mesh size; then, I’ll consider that my mesh is encompassed in a circle with that radius, and that I subtract this radius from my distance computation:
Here it is in C#:
Note: you see that there is also a little check for
!target in the middle: this is to handle an intermediary data state where the
currentTarget has been removed from the scene (for example you’ve attacked and killed it) but not yet cleaned up in the data context. In that case, you want to clear the data reference and interrupt the sequence, because your target isn’t valid anymore.
That node is the simplest of the three! Since we already know the
currentTarget data slot is filled (otherwise the
Sequence parent node would have interrupted before executing the
TaskAttack node), we just need to get it and use the
Attack() method from our
BuildingBT class to use those new nodes
The last thing we need to do is update the structure of the behaviour tree of buildings so that, if our building unit can attack, it has this new “attack sequence” in addition to its usual resource production branch.
We simply translate in code the diagram that we had earlier and get the following code for our
Testing it out!
Time to try this out 🙂
To test that our new building behaviour works, we’ll use the
SpawnBuilding() method that we added to our
BuildingPlacer script a while ago and create a little test building owned by our enemy when we first start the game:
Now, we can try to create a new Soldier unit with our House, then move it closer to the enemy House until it’s within its
attackRange. At that point, the enemy unit starts attacking and we see that our healthbar regularly shrinks as we lose HP. When our Soldier dies, it gets deselected and the game object disappears! 😉
Here is a demo of our new feature (slightly sped up) where I debug the attack range of the enemy building with a green sphere:
Having the characters follow and attack enemies
Alright, now we want to take care of our character units and add the “follow” and “attack” subtrees to their current behaviour tree.
And guess what? That’s where the behaviour trees are really going to shine. Remember how I talked about a high modularity in previous episodes, and how I said that we could very easily reimport behaviour from our little lib of AI actions?…
Well, that’s just it: we actually already have written up more than half of our character behaviour tree without even realising it! Why is that? Because our characters are going to re-use the nodes that we created for our buildings – we are going to mostly rely on the same building blocks. The difference in behaviour will come from the structure they are integrated in.
In truth, we need to do three things:
- create the three nodes we haven’t implemented yet:
- update our
TaskTrySetDestinationnode to also allow the player to designate a target as destination rather than a position on the ground
- modify the structure of the tree in our
CharacterBTclass to use all of this properly!
Updating our character behaviour tree
First things first, let’s prepare the last three nodes we’re still missing, the
CheckHasTarget is really straight-forward to implement since it’s very close to the
CheckTargetIsMine is not too hard to code up either: we’ll just have to get our player index and compare it to the index of the owner of the
currentTarget if we can find it:
TaskFollow node. It’s actually sort of a pot-pourri of other nodes we coded up previously:
- like in the
TaskMoveToDestinationnode, it checks the position of the
currentTargetand compares it to its current NavMeshAgent destination point: if the agent is not going the right way, it re-updates its course
- like in the
TaskAttacknode, it also checks the distance between the character unit and its target with the little offset correction: we don’t aim for the exact position of the target but rather compute the mesh size of this target and subtract it from the real distance to avoid bad collisions
This finally gives us the following
TaskFollow C# class:
Great! Now, we also want to allow for the player to select a unit as target by right-clicking on it (instead of right-clicking on an empty space on the ground). To do that, we will simply rename our
TaskTrySetDestinationOrTarget, and update it with another raycast check:
Note that we can target either our own units or enemy ones (but that our “attack sequence” will then check for ownership to cancel the attack on our own units).
In the end, we can update our
CharacterBT script to integrate the new nodes, like this:
Trying this new behaviour tree in a simple scenario
To check that the character units do implement the AI behaviour we want, let’s set up a little test scenario to play through:
- (we’ll still instantiate a test House unit for the enemy player left of our own headquarters)
- we’ll produce a Soldier from our House and have it move down to test the “move to destination” branch logic
- then, we’ll create another Soldier and have it move towards the first one; we’ll also order the first Soldier to walk so that we can check our “follow” logic works properly
- finally, we’ll move the first Soldier closer to the enemy House
- when the enemy House enters its FOV range, the Soldier should “follow” the enemy unit and move towards it
- then, when the enemy House enters its attack range, the Soldier should stop moving and start attacking the House regularly until it kills it
- when the House has disappeared, all character units should resume to their “idle” (empty) logic and stay still on the ground
For this test scenario to work, don’t forget to:
- update your Soldier Scriptable Object to initialise its attack-related fields with some non-null values:
- reset the
attackDamageof your House unit to zero so that it doesn’t destroy the Soldier unit within its attack range before we can test everything in our scenario 😉
Right, we can now run our scenario and check that our AI reacts as expected!
And it does, yay! We’ve successfully implemented the behaviour trees for both our building and our character units, and we can control their skillset way more easily than before 🙂
Re-enable the selection of enemy units & adapt the selected unit panel info
Before we end this tutorial, there is one last little thing we need to fix.
In the demo above, you see that at the end I selected the enemy House so I could see its healthbar shrink gradually.
But you might remember that, a couple of tutorials ago, we completely disabled the selection of enemy units. That was a quick way of avoiding some basic issues, namely that we could:
- see the enemy’s production or building skills
- access the upgrade/destroy buttons of the buildings
- move around character units
So say I just re-enable the selection of all units, like I did here.
Well – you can see that when I select the enemy building, I can see its production and skills and even produce a new Soldier from it!
We made sure last time that we couldn’t control the enemy Soldier and define a
destinationPoint for it (thanks to our
CheckUnitIsMine node) but the rest of those issues are still pretty embarrassing: we need to take care of them!
Fixing those problems is actually about updating our
UIManager so that it checks whether we own the unit or not and adapts the interface accordingly.
First, we’ll add a few references to the “upgrade” and “destroy” buttons at the bottom of the unit selected panel:
Of course, don’t forget to drag the UI components to the newly created spots 😉
Now, we can update our
_SetSelectedUnitMenu() function to check for the unit owner and toggle some info on or off:
I’ve copied the full function here; the idea is just to define a new flag,
unitIsMine, and then apply it throughout the method to:
- properly set the info block height (at the top of the panel)
- show or hide the unit production
- show or hide the unit skills
- show or hide the “upgrade”/”destroy” buttons
With that, we’re basically preventing the buildings from telling us everything about their current situation if we don’t own them… and we’re blocking any further actions (via unit skills or the common “upgrade”/”destroy” buttons)!
We’ve now successfully adapted our selection state to limit the displayed info and avoid the player from controlling the enemy’s units 🙂
Quick note: when you’re done testing, make sure to remove the
SpawnBuilding() call in the
Start() method of our
BuildingPlacer script that was adding the enemy House unit 😉
Throughout the last three tutorials, thanks to the behaviour tree pattern, we’ve extended the behaviour of our units to react to the presence of enemies and we’ve made much it easier to update their AI in the future. Today, we’ve also adapted the selection state depending on whether we own the unit or not.
Note that, with a little bit of tooling, we can even debug our behaviour trees visually to check everything works as expected… (here, I’ve used Unity’s IMGUI system to create a dedicated in-game debugger) 🙂
Next time, we will continue working on our units, and we’ll talk a bit about the level up/upgrade system! 🙂