Let’s continue discussing unit navigation and formations and finish implementing this feature in our RTS!
This article is also available on Medium.
Last time, we talked about our unit formation system and we started to implement the “leader” side: we can now designate one of the selected units as the one that’s to do the computation, have it perform this computation of offsets and/or positions and finally send them as via events to the rest of the group.
Today, let’s see how to receive this data and use it in our behaviour tree to restore the unit movement feature, improved with the formations. Then, we’ll also take a look at how to create some more “rigid” formations that don’t use a random distribution for computation but rather handmade rules.
Registering for the “flock events”
At the moment, our unit movement is broken!
Because we’ve completely removed the logic that sets the behaviour tree branch data in our “TrySetDestinationOrTarget” task node, units are not able to ever pass their “HasDestination” or “HasTarget” check nodes and they can’t exit the initial idle mode.
To fix this, selected units in the group need to use the data that the leader sent in its events and each extract from it what’s relevant to them specifically. Basically, the i-th unit in the selection needs to retrieve back the i-th offset or position from the list of offsets/positions that the leader computed.
Remember that the leader sends one of two events: either “TargetFormationOffsets” if the group has to follow a target or “TargetFormationPositions” if the group needs to go to a specific (fixed) destination point.
But now, the question is: where should we register our event listeners?
Contrary to a finite state machine where you enter and exit state and completely isolate the behaviour in each, meaning that you can register and unregister from events upon entry and exit, behaviour trees are way more “flexible” and parallel: we are constantly going through entire branches, switching to another, coming back to a first composite node, then exiting to the next terminal action, and so on.
We could theoretically put our event registration in the “TrySetDestinationOrTarget” task node constructor: since it is called just at the very beginning, we wouldn’t have any redundant code paths and the listener would be ready for when a leader sends data.
However, we could run the risk of the events not being properly unregistered if the character game object is disabled, for example.
That’s why I decided I would rather do the event register/unregister process in the
CharacterBT script itself, using a specific reference to the
TaskTrySetDestinationOrTarget node in my behaviour tree. Because this class is a common MonoBehaviour, we can use the
OnDisable() methods as usual to take care of the event listeners:
Now, let’s code up basic data receivers in the
TaskTrySetDestinationOrTarget class. The process is very similar for offsets and positions: each unit just gets back its current index in the selection group, takes the matching element in the given list and assigns it to its
destinationPoint behaviour tree data slot.
We also need to assign the
currentTarget itself (i.e. the transform to follow) in case we right-click another unit. Since this piece of info doesn’t have to be computed, let’s simply assign it for all units upon the raycast success:
Note: roughly put, here, we “bypass” the leader role because all unit would end up with the same value of
hit.transform anyway – so no need to compute and send it!
Then, we have to remember to update our
CheckHasTarget class, too: in addition to checking if we have data in our
currentTarget data slot, we also need to check the
And, of course, in our
TaskFollow class, we have to retrieve this offset and use it to compute the current target position of our unit:
Note that we also need to remove the
ClearData() call when we are in “build” mode, i.e. when the unit is ours, because we’ll need this target for the “Build” task. So we just add a little check in the if-branch at the end, when the agent has reached its destination, and if the unit belongs to us we don’t clear the data right now.
And finally, all that’s left to do is call our new data receivers from our event listener callback functions in the
CharacterBT script. I’ll add a simple
_taskTrySetDestinationOrTargetNode variable to my script to get my reference to the node in the behaviour tree, and then call one of its new
At that point, if you select a few units and right-click on the ground (or on another unit), you’ll see that they all move towards their target and eventually settle at different, well-spaced positions.
Here is another example where the units follow a target:
So – we’ve successfully implemented a multi-agent formation system, yay! 🙂
Using “rigid” handmade formations
So far, we’ve seen how to have our “leader” unit assign random positions. We are using the Poisson disc sampling method to get a nice repartition of these points, but it is still a stochastic process that will get us different positions every time.
Sometimes, it’s also interesting to have more specific formations so that your units follow a specific pattern. For example, you might want them to move as a line, or a grid, or even a cross (what if you’re a fan of Deadpool’s X-Force? :)).
This is actually pretty straight-forward with our current system: instead of sampling positions using Poisson, we would only need to have other util functions that sample points according to a given pattern.
A quick example: hold the line!
Let’s say we want to create a “line” formation. Remember that whenever we create a pattern, we want it to hold even if we have only a few units. In the case of a line, this means that we should try and make our positions spread from the center, like this:
If we follow this logic, you’ll see that when we have just 2 or 3 units selected, we won’t really see the pattern, but we’ll nevertheless get logical target positions.
Also, to make sure that the units don’t have overlapping target positions, we can re-use the notion of “sample radius” from the Poisson disc sampling method and use this value to compute the step of each of my spread points: you see in this animation that the computed points are separated by a minimum distance.
And, actually, this is not too hard to code: it’s just about regularly increasing the distance to the reference point on one side or the other depending on whether the index is even or odd. The only tricky part is to find the “axis” that the line should follow. Here, I’ve decided to implement a routine that spreads the units orthogonally to the initial move direction:
This allows me to get the units more or less “facing” the target point, and it simply requires me to get the vector that is orthogonal to the movement with the world up axis as reference. Once again, I’ve arbitrarily taken the leader as the “origin” point to compute my movement direction.
Note that we could just as easily have taken the barycentre of the positions (with all weights equal to 1) as the “origin” point to compute this movement direction – this would probably give a better approximation of the units cluster average position. However, it involves a bit more data grab and computation. This is an example of something very common in game dev: sometimes, you can approximate something for the sake of brevity/optimisation and, as long as the result doesn’t impact the gameplay negatively, it’s ok 😉
Anyway – I’ll put this function in a new C# script called
UnitsFormation.cs, and make it static:
Note: if you want, you can move the
SamplePositions() method we previously put in the
Utils class in this new class; I myself think it’s a bit more generic, so I’ll leave it in the tools section, but it would also make sense to reorganise the project this way 🙂
Now, of course, I need to be able to pick which formation I want to use when I move my units. This is once again something we can store in our
Globals class to make it easily accessible, along with a little enum that gives a list of the possible formation types:
To actually update this value, we could either add buttons in the UI or add yet another command to our debug console.
For now, the console way is quicker to code, so, let’s go to our
DebugConsole class and add a little command that takes in the index of the formation to use:
We can now easily switch from one to the other just by running:
For example, this would set the mode to “none” (i.e. the Poisson sampling), while a value of 1 would set the mode to “line” (and use our new method).
Finally, let’s update our
TaskTrySetDestinationOrTarget class to use the proper sampling algorithm depending on the current unit formation type:
Be careful: for the line formation, we need to sample offsets/positions for all units (leader included) because our pattern computed the zeroed-out position in the center too! 🙂
You can now try this out in-game and see how the computed target positions differ between the two modes:
More complex patterns: the grid and the “X”
Just to show some additional examples of unit formations, let’s see how to make a grid or an “X” cross pattern.
The grid pattern
The grid is basically an extended version of the line pattern. We just decide on a max number of units per “row” and have a second counter to stack multiple rows behind one another:
The rest is very similar to our line formation routine.
The X-Cross pattern
For the “X” pattern, we have to compute positions according to diagonals instead of orthogonal lines. In other words, instead of getting the orthogonal direction, we need to get two 45°-lines around the movement direction:
Once we have these lines, we’ll want to iterate through them as we compute our positions; so, basically, rather than changing side every two units, we’ll “rotate” from a segment to the next one with a cycle of 4 (with our first point in the middle of the cross):
In code, this gives us the following C# snippet:
Of course, we also have to add these new options to our
And then, in our
TaskTrySetDestinationOrTarget class, we need to handle the new cases too:
You can now try this out and have fun with the different formation patterns, using the debug console command! 🙂
Note: we can still get some overshooting of the destination – this can happen depending on your Unity AI navigation agent settings, and in particular the “stop distance” parameter: if it’s too low, then the target can get slightly pass the target point and then come back to it, like here with the grid and X-cross examples…
Bonus: showing up (and picking!) unit formation patterns in the UI
Our debug command is nice but we obviously want “normal” players to be able to choose their unit formation type, too. This means that we need to expose a selector for this value in our UI. There are various choices: we could have a dropdown somewhere, or a set of radio buttons… I’m going to go for something basic and create a row of little buttons above the minimap, one for each formation type, with a currently active one:
To do this, let’s add some UI elements to our canvas. First, we can create a new panel inside the “Minimap” object and offset it so that’s it’s above the map; then, we can add a “GridLayoutGroup” component on it (to have square-sized children), and fill it with very basic buttons:
Now, let’s go to our
UIManager and add a reference to the parent panel – don’t forget to drag it in the Inspector! 😉
We’ll also make an array of
Images to have quick access to the visual of each button: this way, we’ll be able to change their colour and clearly show which one is active.
Finally, in our
Awake() function, let’s prepare the references and switch the buttons to the proper colour depending on the initial unit formation type:
If you run your game, you’ll see that, at the beginning, the button matching the
UNIT_FORMATION_TYPE value in your
Globals will be “highlighted” while the others are darkened.
But we actually want to do two things:
- when we click on a button, it should change the current unit formation type and update the visuals to highlight the newly selected type
- when we change the type from the debug console, the UI should also re-update
Because we can have two possible triggers for a UI re-update (direct click or console command), let’s extract the UI colour switch to its own function and use it both inside our direct click callback and as an event callback:
Now, we can go to our
DebugConsole class and make sure that when we change the unit formation type with our “set_unit_formation_type” command, we also trigger the update event:
Finally, remember to actually use our UI
SetUnitFormationType() button callback on your UI elements, like this:
The buttons use the type integer indices, like the debug command (so “FormationNone” uses index 0, “FormationLine” uses index 1 and so on).
Enter play mode again and you’ll now have easy-to-use buttons to switch between formation types, that are compatible with our previous “hacky” console command 😉
In the last two episodes, we revisited our unit navigation system and implemented the formation system. We saw how to merge this with the multi-agent behaviour tree-based AI we’d coded previously thanks to leadership designation and communication.
Next time, we’ll continue talking about units and discuss how to refactor our building placement system to have “worker” units construct buildings…