Making a RTS game #25: Implementing behaviour trees for our units 3/3 (Unity/C#)

Today, let’s finish implementing the AI for our units using behaviour trees!

This article is also available on Medium.

🚀 Find the code of this tutorial series on my Github!

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.

Buildings

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 producingRate seconds.

The other “skill” we want our buildings to have is the ability to keep track of close 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):

Characters

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 destinationPoint and 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 currentTarget and 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 currentTarget-related nodes.

So, today, we have to finish up building those behaviour trees by creating the missing leaf node types and re-arranging our BuildingBT and 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 3 new variables to our UnitData script: the 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 attackDamage value 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 attackDamage health points (HP) to the enemy (we might upgrade how these attack points are computed in the future, though)
  • if a unit reaches 0 HP, it “dies”: its deselected and its game object is destroyed

To begin with, let’s update our UnitData class to add our 3 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 Attack(), TakeHit() and _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 fillAmount parameter:

Note: you have to cast the MaxHP denominator to a float; otherwise, the computation will take the two variables, HP and 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 3 new node types:

  • the CheckEnemyInFOVRange node 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 currentTarget if there is one
  • the CheckEnemyInAttackRange will fail immediately if there is no currentTarget; else, it will look at the distance between this currentTarget and our unit and compare it to the unit’s attackRange to return “success” or “failure”
  • finally, the TaskAttack will only be executed if the previous check has passed; in that case, we’ll simply get the currentTarget reference and use our brand new Attack() method on it!

Adding the new node types

The CheckEnemyInFOVRange node

The 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 currentTarget data slot

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 CheckEnemyInFOVRange class:

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 😉

The CheckEnemyInAttackRange node

The 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 to 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.

The TaskAttack node

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 UnitManager instance:

Updating 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 BuildingBT class:

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 3 things:

  • create the 3 nodes we haven’t implemented yet: CheckHasTarget, CheckTargetIsMine and TaskFollow
  • update our TaskTrySetDestination node 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 CharacterBT class 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, CheckTargetIsMine and TaskFollow blocks.

The CheckHasTarget is really straight-forward to implement since it’s very close to the CheckHasDestination one:

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:

Then, the TaskFollow node. It’s actually sort of a pot-pourri of other nodes we coded up previously:

  • like in the TaskMoveToDestination node, it checks the position of the currentTarget and 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 TaskAttack node, 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 TaskTrySetDestination to 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).

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:

  1. (we’ll still instantiate a test House unit for the enemy player left of our own headquarters)
  2. we’ll produce a Soldier from our House and have it move down to test the “move to destination” branch logic
  3. 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
  4. finally, we’ll move the first Soldier closer to the enemy House
  5. when the enemy House enters its FOV range, the Soldier should “follow” the enemy unit and move towards it
  6. then, when the enemy House enters its attack range, the Soldier should stop moving and start attacking the House regularly until it kills it
  7. 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 attackDamage of 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 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 🙂

Conclusion

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 3 tutorials, thanks to the behaviour tree, 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! 🙂

2 thoughts on “Making a RTS game #25: Implementing behaviour trees for our units 3/3 (Unity/C#)”

  1. Just wanted to say thanks for this series, I’m still slowly working my way through. Your links to other helpful resources combined with your own take on things is nice to see. I just got fog of war working thanks to your guide. I’ll try to post some comments/questions as I work through the other sections.

    1. Hi – thanks for the nice comment! I’m really happy you like it and it helps you do some things you were interested in – yep, don’t hesitate to keep in touch! 🙂

Leave a Reply

Your email address will not be published. Required fields are marked *