In this tutorial, we continue coding a hockey game using steering behaviors and finite state machines. In this part of the series, you will learn about the artificial intelligence required by game entities to coordinate an attack, which involves intercepting and carrying the puck to the opponent's goal.
A Few Words About Attacking
Coordinating and performing an attack in a cooperative sport game is a very complex task. In the real world, when humans play a hockey game, they make several decisions based on many variables.
Those decisions involve calculations and understanding what is going on. A human can tell why an opponent is moving based on the actions of another opponent, for instance, "he is moving to be in a better strategic position." It's not trivial to port that understanding to a computer.
As a consequence, if we try to code the AI to follow all the human nuances and perceptions, the result will be a huge and scary pile of code. Additionally, the result might not be precise or easily modifiable.
That's the reason why our attack AI will try to mimic the result of a group of humans playing, not the human perception itself. That approach will lead to approximations, but the code will be easier to understand and tweak. The outcome is good enough for several use cases.
Organizing the Attack With States
We'll break the attack process down into smaller pieces, with each one performing a very specific action. Those pieces are the states of a stack-based finite state machine. As previously explained, each state will produce a steering force that will make the athlete behave accordingly.
The orchestration of those states and the conditions to switch among them will define the attack. The image below presents the complete FSM used in the process:
As illustrated by the image, the conditions to switch among the states will be solely based on the puck's distance and ownership. For instance, team has the puck
or puck is too far away
.
The attack process will be composed of four states: idle
, attack
, stealPuck
, and pursuePuck
. The idle
state was already implemented in the previous tutorial, and it is the starting point of the process. From there, an athlete will switch to attack
if the team has the puck, to stealPuck
if the opponent's team has the puck, or to pursuePuck
if the puck has no owner and it is close enough to be collected.
The attack
state represents an offensive movement. While in that state, the athlete carrying the puck (named leader
) will try to reach the opponent's goal. Teammates will move along, trying to support the action.
The stealPuck
state represents something between a defensive and an offensive movement. While in that state, an athlete will focus on pursuing the opponent carrying the puck. The objective is to recover the puck, so the team can start attacking again.
Finally, the pursuePuck
state is not related to attack or defense; it will just guide the athletes when the puck has no owner. While in that state, an athlete will try to get the puck that is freely moving on the rink (for instance, after being hit by someone's stick).
Updating the Idle State
The idle
state that was previously implemented had no transitions. Since this state is the starting point for the whole AI, let's update it and make it able to switch to other states.
The idle
state has three transitions:
If the athlete's team has the puck, idle
should be popped from the brain and attack
should be pushed. Similarly, if the opponent's team has the puck, idle
should be replaced by stealPuck
. The remaining transition happens when nobody owns the puck and it is close to the athlete; in that case, pursuePuck
should be pushed into the brain.
The updated version of idle
is as follows (all other states will be implemented later):
class Athlete { // (...) private function idle() :void { var aPuck :Puck = getPuck(); stopAndlookAt(aPuck); // This is a hack to help test the AI. if (mStandStill) return; // Does the puck has an owner? if (getPuckOwner() != null) { // Yeah, it has. mBrain.popState(); if (doesMyTeamHaveThePuck()) { // My team just got the puck, it's attack time! mBrain.pushState(attack); } else { // The opponent team got the puck, let's try to steal it. mBrain.pushState(stealPuck); } } else if (distance(this, aPuck) < 150) { // The puck has no owner and it is nearby. Let's pursue it. mBrain.popState(); mBrain.pushState(pursuePuck); } } private function attack() :void { } private function stealPuck() :void { } private function pursuePuck() :void { } }
Let's proceed with the implementation of the other states.
Pursuing the Puck
Now that the athlete has gained some perception about the environment and is able to switch from idle
to any state, let's focus on pursuing the puck when it has no owner.
An athlete will switch to pursuePuck
immediately after the match begins, because the puck will be placed at the center of the rink with no owner. The pursuePuck
state has three transitions:
The first transition is puck is too far away
, and it tries to simulate what happens in a real game regarding chasing the puck. For strategic reasons, usually the athlete closest to the puck is the one that tries to catch it, while the others wait or try to help.
Without switching to idle
when the puck is distant, every AI-controlled athlete would pursue the puck at the same time, even if they are away from it. By checking the distance between the athlete and the puck, pursuePuck
pops itself from the brain and pushes idle
when the puck is too distant, which means the athlete just "gave up" pursuing the puck:
class Athlete { // (...) private function pursuePuck() :void { var aPuck :Puck = getPuck(); if (distance(this, aPuck) > 150) { // Puck is too far away from our current position, so let's give up // pursuing the puck and hope someone will be closer to get the puck // for us. mBrain.popState(); mBrain.pushState(idle); } else { // The puck is close, let's try to grab it. } } // (...) }
When the puck is close, the athlete must go after it, which can be easily achieved with the seek behavior. Using the puck's position as the seek destination, the athlete will gracefully pursue the puck and adjust his trajectory as the puck moves:
class Athlete { // (...) private function pursuePuck() :void { var aPuck :Puck = getPuck(); mBoid.steering = mBoid.steering + mBoid.separation(); if (distance(this, aPuck) > 150) { // Puck is too far away from our current position, so let's give up // pursuing the puck and hope someone will be closer to get the puck // for us. mBrain.popState(); mBrain.pushState(idle); } else { // The puck is close, let's try to grab it. if (aPuck.owner == null) { // Nobody has the puck, it's our chance to seek and get it! mBoid.steering = mBoid.steering + mBoid.seek(aPuck.position); } else { // Someone just got the puck. If the new puck owner belongs to my team, // we should switch to 'attack', otherwise I should switch to 'stealPuck' // and try to get the puck back. mBrain.popState(); mBrain.pushState(doesMyTeamHaveThePuck() ? attack : stealPuck); } } } }
The remaining two transitions in the pursuePuck
state, team has the puck
and opponent has the puck
, are related to the puck being caught during the pursue process. If somebody catches the puck, the athlete must pop the pursuePuck
state and push a new one into the brain.
The state to be pushed depends on the puck's ownership. If the call to doesMyTeamHaveThePuck()
returns true
, it means that a teammate got the puck, so the athlete must push attack
, which means it's time to stop pursuing the puck and start moving towards the opponent's goal. If an opponent got the puck, the athlete must push stealPuck
, which will make the team try to recover the puck.
As a small enhancement, athletes should not remain too close from each other during the pursuePuck
state, because a "crowded" pursuing movement is unnatural. Adding separation to the state's steering force (line 6
in the code above) ensures athletes will keep a minimum distance among them.
The result is a team that's able to pursue the puck. For the sake of testing, in this demo, the puck is placed at the center of the rink every few seconds, to make the athletes move continually:
Attacking With the Puck
After obtaining the puck, an athlete and his team must move towards the opponent's goal to score. That's the purpose of the attack
state:
The attack
state has only two transitions: opponent has the puck
and puck has no owner
. Since the state is solely designed to make athletes move towards the opponent's goal, there is no point to remain attacking if the puck is not under the team's possession any more.
Regarding the movement towards the opponent's goal: the athlete carrying the puck (leader) and the teammates helping him should behave differently. The leader must reach the opponent's goal, and the teammates should help him along the way.
This can be implemented by checking whether the athlete running the code has the puck:
class Athlete { // (...) private function attack() :void { var aPuckOwner :Athlete = getPuckOwner(); // Does the puck have an owner? if (aPuckOwner != null) { // Yeah, it has. Let's find out if the owner belongs to the opponents team. if (doesMyTeamHaveThePuck()) { if (amIThePuckOwner()) { // My team has the puck and I am the one who has it! Let's move // towards the opponent's goal. mBoid.steering = mBoid.steering + mBoid.seek(getOpponentGoalPosition()); } else { // My team has the puck, but a teammate has it. Let's just follow him // to give some support during the attack. mBoid.steering = mBoid.steering + mBoid.followLeader(aPuckOwner.boid); mBoid.steering = mBoid.steering + mBoid.separation(); } } else { // The opponent has the puck! Stop the attack // and try to steal it. mBrain.popState(); mBrain.pushState(stealPuck); } } else { // Puck has no owner, so there is no point to keep // attacking. It's time to re-organize and start pursuing the puck. mBrain.popState(); mBrain.pushState(pursuePuck); } } }
If amIThePuckOwner()
returns true
(line 10), the athlete running the code has the puck. In that case, he will just seek the opponent's goal position. That's pretty much the same logic used to pursue the puck in the pursuePuck
state.
If amIThePuckOwner()
returns false
, the athlete doesn't have the puck, so he must help the leader. Helping the leader is a complicated task, so we will simplify it. An athlete will assist the leader just by seeking a position ahead of him:
As the leader moves, he will be surrounded by teammates as they follow the ahead
point. This gives the leader some options to pass the puck to if there's any of trouble. As in a real game, the surrounding teammates should also stay out of the leader's way.
This assistance pattern can be achieved by adding a slightly modified version of the leader following behavior (line 18). The only difference is that athletes will follow a point ahead of the leader, instead of one behind him as was originally implemented in that behavior.
Athletes assisting the leader should also keep a minimum distance among each other. That's implemented by adding a separation force (line 19).
The result is a team able to move towards the opponent's goal, without crowding and while simulating an assisted attack movement:
Improving the Attack Support
The current implementation of the attack
state is good enough for some situations, but it has a flaw. When someone catches the puck, he becomes the leader and is immediately followed by teammates.
What happens if the leader is moving towards his own goal when he catches the puck? Take a closer look at the demo above and notice the unnatural pattern when teammates start following the leader.
When the leader catches the puck, the seek behavior takes some time to correct the leader's trajectory and effectively make him move towards the opponent's goal. Even when the leader is "maneuvering", teammates will try to seek his ahead
point, which means they will move towards their own goal (or the place that the leader is staring at).
When the leader is finally in position and ready to move towards the opponent's goal, teammates will be "maneuvering" to follow the leader. The leader will then move without teammate support for as long as the others are adjusting their trajectories.
This flaw can be fixed by checking whether the teammate is ahead of the leader when the team recovers the puck. Here, the condition "ahead" means "closer to the opponent's goal":
class Athlete { // (...) private function isAheadOfMe(theBoid :Boid) :Boolean { var aTargetDistance :Number = distance(getOpponentGoalPosition(), theBoid); var aMyDistance :Number = distance(getOpponentGoalPosition(), mBoid.position); return aTargetDistance <= aMyDistance; } private function attack() :void { var aPuckOwner :Athlete = getPuckOwner(); // Does the puck have an owner? if (aPuckOwner != null) { // Yeah, it has. Let's find out if the owner belongs to the opponents team. if (doesMyTeamHaveThePuck()) { if (amIThePuckOwner()) { // My team has the puck and I am the one who has it! Let's move // towards the opponent's goal. mBoid.steering = mBoid.steering + mBoid.seek(getOpponentGoalPosition()); } else { // My team has the puck, but a teammate has it. Is he ahead of me? if (isAheadOfMe(aPuckOwner.boid)) { // Yeah, he is ahead of me. Let's just follow him to give some support // during the attack. mBoid.steering = mBoid.steering + mBoid.followLeader(aPuckOwner.boid); mBoid.steering = mBoid.steering + mBoid.separation(); } else { // Nope, the teammate with the puck is behind me. In that case // let's hold our current position with some separation from the // other, so we prevent crowding. mBoid.steering = mBoid.steering + mBoid.separation(); } } } else { // The opponent has the puck! Stop the attack // and try to steal it. mBrain.popState(); mBrain.pushState(stealPuck); } } else { // Puck has no owner, so there is no point to keep // attacking. It's time to re-organize and start pursuing the puck. mBrain.popState(); mBrain.pushState(pursuePuck); } } }
If the leader (who is the puck owner) is ahead of the athlete running the code, then the athlete should follow the leader just like he was doing before (lines 27 and 28). If the leader is behind him, the athlete should hold his current position, keeping a minimum distance between the others (line 33).
The result is a bit more convincing than the initial attack
implementation:
Tip: By tweaking the distance calculations and comparisons in the isAheadOfMe()
method, it's possible to modify the way athletes hold their current positions.
Stealing the Puck
The final state in the attacking process is stealPuck
, which becomes active when the opposing team has the puck. The main purpose of the stealPuck
state is to steal the puck from the opponent carrying it, so that the team can start attacking again:
Since the idea behind this state is to steal the puck from the opponent, if the puck is recovered by the team or it becomes free (that is, it has no owner), stealPuck
will pop itself from the brain and push the right state to deal with the new situation:
class Athlete { // (...) private function stealPuck() :void { // Does the puck have any owner? if (getPuckOwner() != null) { // Yeah, it has, but who has it? if (doesMyTeamHaveThePuck()) { // My team has the puck, so it's time to stop trying to steal // the puck and start attacking. mBrain.popState(); mBrain.pushState(attack); } else { // An opponent has the puck. var aOpponentLeader :Athlete = getPuckOwner(); // Let's pursue him while mantaining a certain separation from // the others to avoid that everybody will ocuppy the same // position in the pursuit. mBoid.steering = mBoid.steering + mBoid.pursuit(aOpponentLeader.boid); mBoid.steering = mBoid.steering + mBoid.separation(); } } else { // The puck has no owner, it is probably running freely in the rink. // There is no point to keep trying to steal it, so let's finish the 'stealPuck' state // and switch to 'pursuePuck'. mBrain.popState(); mBrain.pushState(pursuePuck); } } }
If the puck has an owner and he belongs to the opponent's team, the athlete must pursue the opposing leader and try to steal the puck. In order to pursue the opponent's leader, an athlete must predict where he will be in the near future, so he can be intercepted in his trajectory. That's different from just seeking the opposing leader.
Fortunately, this can be easily achieved with the pursue behavior (line 19). By using a pursuit force in the stealPuck
state, athletes will try to intercept the opponent's leader, instead of just following him:
Preventing a Crowded Steal Movement
The current implementation of stealPuck
works, but in a real game only one or two athletes approach the opponent leader to steal the puck. The rest of the team remains in the surrounding areas trying to help, which prevents a crowded stealing pattern.
It can be fixed by adding a distance check (line 17) before the opponent's leader pursuit:
class Athlete { // (...) private function stealPuck() :void { // Does the puck have any owner? if (getPuckOwner() != null) { // Yeah, it has, but who has it? if (doesMyTeamHaveThePuck()) { // My team has the puck, so it's time to stop trying to steal // the puck and start attacking. mBrain.popState(); mBrain.pushState(attack); } else { // An opponent has the puck. var aOpponentLeader :Athlete = getPuckOwner(); // Is the opponent with the puck close to me? if (distance(aOpponentLeader, this) < 150) { // Yeah, he is close! Let's pursue him while mantaining a certain // separation from the others to avoid that everybody will ocuppy the same // position in the pursuit. mBoid.steering = mBoid.steering.add(mBoid.pursuit(aOpponentLeader.boid)); mBoid.steering = mBoid.steering.add(mBoid.separation(50)); } else { // No, he is too far away. In the future, we will switch // to 'defend' and hope someone closer to the puck can // steal it for us. // TODO: mBrain.popState(); // TODO: mBrain.pushState(defend); } } } else { // The puck has no owner, it is probably running freely in the rink. // There is no point to keep trying to steal it, so let's finish the 'stealPuck' state // and switch to 'pursuePuck'. mBrain.popState(); mBrain.pushState(pursuePuck); } } }
Instead of blindly pursuing the opponent's leader, an athlete will check whether the distance between him and the opponent leader is less than, say, 150
. If that's true
, the pursuit happens normally, but if the distance is greater than 150
, it means the athlete is too far from the opponent leader.
If that happens, there is no point in continuing trying to steal the puck, since it is too far away and there are probably teammates already in place trying to do the same. The best option is to pop stealPuck
from the brain and push the defense
state (which will be explained in the next tutorial). For now, an athlete will just hold his current position if the opponent leader is too far away.
The result is a more convincing and natural stealing pattern (no crowding):
Avoiding Opponents While Attacking
There is one last trick that the athletes must learn in order to attack effectively. Right now, they move towards the opponent's goal without considering the opponents along the way. An opponent must be seen as a threat, and should be avoided.
Using the collision avoidance behavior, athletes can dodge opponents while they move:
Opponents will be seen as circular obstacles. As a result of the dynamic nature of steering behaviors, which are updated in every game loop, the avoidance pattern will gracefully and smoothly work for moving obstacles (which is the case here).
In order to make athletes avoid opponents (obstacles), a single line must be added to the attack state (line 14):
class Athlete { // (...) private function attack() :void { var aPuckOwner :Athlete = getPuckOwner(); // Does the puck have an owner? if (aPuckOwner != null) { // Yeah, it has. Let's find out if the owner belongs to the opponents team. if (doesMyTeamHaveThePuck()) { if (amIThePuckOwner()) { // My team has the puck and I am the one who has it! Let's move // towards the opponent's goal, avoding any opponents along the way. mBoid.steering = mBoid.steering + mBoid.seek(getOpponentGoalPosition()); mBoid.steering = mBoid.steering + mBoid.collisionAvoidance(getOpponentTeam().members); } else { // My team has the puck, but a teammate has it. Is he ahead of me? if (isAheadOfMe(aPuckOwner.boid)) { // Yeah, he is ahead of me. Let's just follow him to give some support // during the attack. mBoid.steering = mBoid.steering + mBoid.followLeader(aPuckOwner.boid); mBoid.steering = mBoid.steering + mBoid.separation(); } else { // Nope, the teammate with the puck is behind me. In that case // let's hold our current position with some separation from the // other, so we prevent crowding. mBoid.steering = mBoid.steering + mBoid.separation(); } } } else { // The opponent has the puck! Stop the attack // and try to steal it. mBrain.popState(); mBrain.pushState(stealPuck); } } else { // Puck has no owner, so there is no point to keep // attacking. It's time to re-organize and start pursuing the puck. mBrain.popState(); mBrain.pushState(pursuePuck); } } }
This line will add a collision avoidance force to the athlete, which will be combined with the forces that already exist. As a result, the athlete will avoid obstacles at the same time as seeking the opponent's goal.
Below is a demonstration of an athlete running the attack
state. Opponents are immovable to highlight the collision avoidance behavior:
Conclusion
This tutorial explained the implementation of the attack pattern used by the athletes to steal and carry the puck towards the opponent's goal. Using a combination of steering behaviors, athletes are now able to perform complex movement patterns, such as following a leader or pursuing the opponent with the puck.
As previously discussed, the attack implementation aims to simulate what humans do, so the result is an approximation of a real game. By individually tweaking the states that compose the attack, you can produce a better simulation, or one that fits your needs.
In the next tutorial, you will learn how to make athletes defend. The AI will become feature-complete, able to attack and defend, resulting in a match with 100% AI-controlled teams playing against each other.
References
- Sprite: Hockey Stadium on GraphicRiver
- Sprites: Hockey Players by Taylor J Glidden