In this section, we will add simple AI-controlled enemies to the game.
These enemies will patrol their platform randomly until the player comes nearby. When that happens, they will chase the player to inflict damage on collision. If the player escapes, they will go back to patrolling.
Fair warning: this section is the hardest part of the tutorial and will require the combined use of every skill we've learned so far: flow graphs, state graphs, super units, transitions, macros, custom events and variables. Make sure you understand every previous section before starting, because now that the concepts have been introduced, we will go a bit faster than usual.
When we're done, the final set of graphs will look like this:
Take a short break, stretch your legs, and when you're ready, let's get started!
1. Root state machine
The first thing we'll do is create a root state machine on our enemy. It will have two states:
- Alive: When the enemy is alive most of the logic happens, like patrolling, chasing and damaging. For the first time, this state will be a super state, meaning it will itself be contain a state graph. Previously, we only used flow states, where the child graph was a flow graph.
- Dead: When the enemy is killed, it should slowly spin downwards then disappear. This will be a simple flow state that we will implement in the next section, when we add projectiles to kill enemies.
To create the root state machine:
- Open the Level3 scene
- Select the Enemy object
- Add a new State Machine component
- Create a new macro for it called Enemy
- Apply the changes to the prefab
In the root state machine:
- Delete the default Start state, because it's a flow state and we need a super state
- Create a new Super State, open it and change its title to Alive
- Right click the Alive state and choose Toggle Start
- Create a new Flow State, open it and change its title to Dead
- Add a transition from Alive to Dead.
At this point, root state graph should look like this:
2. Alive state
Inside the Alive state graph, we will need three child states:
- Patrol: When the player is away and the enemy is patrolling the platform randomly. This will be a Super State, because the patrol will include different states as well. Does this feel like inception already? :)
- Chase: When the player is nearby, the enemy chases it. This will be a regular flow state.
- Damage: In parallel to patrol and chase, the enemy can inflict damage when colliding with the player. This will be a regular flow state as well.
Parallel here means that there will be two "systems" of states in the alive state graph that will run at the same time: one for movement (Patrol or Chase), and one for damage (just one state). To do that, we only have to define multiple start states: a powerful feature unique to Bolt. Our two start states will be Patrol and Chase.
- Add a super state called Patrol
- Add a flow state called Chase
- Add a flow state called Damage
- Toggle start on Patrol
- Toggle start on Damage
- Add transitions back and forth between Patrol and Chase
The graph in alive should then look like this:
Inflicting damage to the player is really easy now that we have already created all the health system in the previous section. As a reminder, we added a Damage custom event to the PlayerHealth state graph that took one argument: the amount of damage inflicted.
Open the Damage state.
Now, all we have to do is use our On Collision With macro to trigger that event and inflict 1 heart of damage:
That's it! If you test your game now, the enemy should inflict damage to the player, which in turn should become temporarily invulnerable:
Notice how keeping our graphs DRY and organized made this very simple: the collision code is fully handled on its own, and the player is responsible for its own health and damage system. The enemy, a separate entity, only has to trigger a single event. This is an example of how nesting and events in Bolt allow you to create a robust game architecture.
Let's take a moment to plan ahead: we know our enemy has to walk in multiple places. It has to walk left and right when patrolling a platform, and walk towards a player when chasing. That's 3 places already we're we'll need a walking graph. At this point, you should see where this is going: we'll keep everything DRY and create a reusable macro! Create a new flow macro asset called EnemyWalk. This graph will be similar to movement on the player, but not exactly the same.
4.1 Direction & movement
Add a new float input value called direction:
This direction input represents a X axis value.
- If it is above zero, the enemy should walk right
- If it is below zero, the enemy should walk left
- If it is equal to zero, the enemy should stay idle
Note that this direction isn't a speed: for example, if the direction is -5, the enemy should go left exactly as fast as if the direction had been -1. To do that, we will first normalize the direction before multiplying it by the speed. Normalizing keeps the sign (+ / -), but with an value of 1, unless the value was zero, in which case it leaves it as is. In practice, it means we calculate movement like this:
Notice we use a Speed object variable again. Because we want the player to be a bit faster than the enemies, we will add a speed variable with a value of 2 to our enemy game object and apply the changes to the prefab:
Like we did for the player earlier, we'll flip the enemy sprite by setting the X axis of the scale equal to the direction, but only if the direction isn't zero. If it is zero, meaning the enemy is idle, we'll skip flipping altogether and keep the last scale.
You'll notice that this time the Equal unit takes numbers and allows for an inline value for B. This is because Numeric is checked in the graph inspector for the unit:
It works is exactly the same as using the following nodes, but a bit more conveniently:
Like we did for the player, we'll set the enemy rigidbody's X velocity to match the calculated movement variable:
Finally, we pass the speed to the enemy's animator controller:
At this point, the final EnemyWalk macro should look like this when zoomed out:
Now that we have our walking macro ready, we can implement patrol.
Open the patrol state under Enemy > Alive > Patrol:
In this nested state graph, we will need three states:
- Idle: When the enemy is idle and waiting. It adds a bit of realism to have it "sit and think"!
- Walk Left: When the enemy decides to walk left
- Walk Right: When the enemy decides to walk right
By default, our enemy will be idle. Setup your graph like so:
- Rename the default Start state to Idle
- Add a new flow state called Walk Left
- Add a new flow state called Walk Right
- Arrange the states in a pyramid with Idle on top
We will now add use our EnemyWalk macro as a super unit in each of these states. Delete every event in each state and drag & drop our macro to create the super unit.
- In Idle, the direction should be 0
- In Walk Left, the direction should be -1
- In Walk Right, the direction should be +1
When you're done, each state should have a single node:
|Idle||Walk Left||Walk Right|
Next up, we will create the transitions between these states.
5.1 Change mind transitions
Sometimes, the enemy should randomly change its mind about where it's going. It might be idle and decide to walk, or change direction, or stop walking. We will call this the "change mind" transition, and we'll create a single reusable macro to handle all of them.
Create a new flow macro called EnemyChangeMind, and set it up like this:
- Random Range is located under Unity Engine > Codebase > Random
- On Timer Elapsed is located under Events > Time
- Trigger State Transition is located under Nesting
This simple transition randomly waits between 1 and 3 seconds to trigger. That's it!
Remember to give it a title so it's easier to understand what it does:
Next step: add it to the graph. An enemy can change its mind from any state of a patrol to any other, so we need back and forth transitions between each state in our triangle:
- Select each of these transitions
- Set its source to Macro
- Choose the EnemyChangeMind macro
The inspector for each transition should then look like this:
And the state graph should look like this:
If you test now, the enemy should start patrolling randomly... but it won't stop when it reaches the edge of its platform!
Let's fix that by adding another type of transition when the enemy reaches the edge of its platform.
5.2 Reach edge transitions
To determine whether the enemy has reached the end of its platform and should switch direction, we'll use a technique similar to how we detected whether the player was grounded earlier: we will use a downwards circle cast. However, this time, we'll add a small offset in the direction of the enemy, so that our circle cast predicts ahead of time if the end of the platform will be reached:
If that ground check with an offset returns false, we'll know that the enemy soon won't be grounded, and therefore has reached the edge of its platform.
5.2.1 Add parameters to the ground check
Since we already have a GroundCheck macro, we will only modify it to add some parameters. Open the GroundCheck macro we created earlier for the player controller:
Add a new Input unit with three parameters:
- Offset (Vector 2, default 0,0): the offset to add to the origin of the circle cast from the object's position
- Radius (float, default 0.3): the width of the circle cast
- Distance (float, default 1.1): the distance to check downwards
We could leave radius and distance at constants, but while we're at it, why not turn them into arguments to make our macro really flexible?
Then, connect the new input ports to the circle cast node, using an Add to apply the offset:
5.2.2 Create a reach edge transition macro
Create a new flow macro called EnemyReachEdge for our transition. Set it up so that the transition triggers if the enemy is not grounded:
Then, we'll need to calculate the offset parameter so that it points in the direction the enemy is facing. We can use the X axis of the scale to know that, because we flip the enemy in our movement code. We then multiply it by 0.5 units forward, which is a small offset of about half the enemy's width.
Then, just connect the offset vector to the offset port of our super unit:
5.2.2 Add the reach edge transitions to the patrol state
Finally, we just need to add this transition to our patrol graph. Logically, an enemy should only ever reach an edge while walking either left or right, and when it does, it should immediately go in the opposite direction. Therefore, we'll only add our new transition back and forth between the Walk Left and Walk Right states:
If you test your game now, the enemy should properly avoid falling off the platform:
Now that our patrol state is complete, we'll implement the Chase state:
When chasing, the enemy should walk towards the player. Thanks to our EnemyWalk macro, this is fairly easy: we only need to calculate the direction from the enemy to the player and pass it to the super unit.
Using simple vector maths, we subtract Player Position - Enemy Position to get our direction vector, and take the X component from it. Because the enemy walk macro takes care of normalizing the value to +1 / -1, we can pass that directly:
6.1 Chase transitions
The enemy should start chasing the player when it gets nearby, and stop when it escapes.
Add a Detection variable to the enemy object to indicate how far it starts seeing the player, and apply the changes to the prefab:
Open the transition going from Patrol to Chase:
Here, we will calculate the distance between the player and the enemy, and transition if it is lower than the detection radius:
Rename the transition to Player Nearby:
Back to the alive state, open the transition from Chase to Patrol:
Here we'll implement the opposite check: if the player is farther than the detection range, trigger the transition:
Tip: You can copy-paste everything from the Player Nearby transition, right-click Less and choose Replace, then pick Greater Or Equal instead.
Rename the transition to Player Away and get back to the alive state:
If you test your game now, you'll see that the enemy does chase the player when it gets near... but even if that means falling off the ledge!
Fortunately, this is an easy fix: just use the Reach Edge transition to transition back to patrol when chasing:
You got through the hardest part! Congratulations!
At this point, your enemy should patrol the platform, chase the player when it gets near, and inflict damage on collision.
Here's a full HD screenshot of all the nested structure of our enemy graph (Full Resolution Link). We used the full power of nesting to reuse our code and make a robust AI:
Customer support service by UserEcho