Basic video game AI is built up from many simpler components whose actions are coordinated by decision making logic. Complex behavior emerges as we add more fundamental behaviors, and as we combine those in new ways depending on our evaluation of the in-game situation.
In this tutorial, I show you how to begin writing this coordination logic, which will provide you with an outline for building a more advanced bot. I then review a fully completed open source bot to further illustrate how smaller maneuvers and behaviors can be combined to achieve artificial intelligence. But before we get to all that, first I cover how to fix the bug in our bot that pops up when all the large boosts are taken.
GitHub repo for this project: https://github.com/learncodebygaming/rlbot_boosthog
Download RLBot: https://www.rlbot.org/
Beast from the East GitHub: https://github.com/NicEastvillage/RLBot-Beast
When we left off in the last tutorial, we encountered a problem when all the boosts are taken. In that situation
get_nearest_boost() returns None, which in turn results in runtime errors when our code tries to do math against that null value.
One way we can fix that is to ensure
get_nearest_boost() always returns a valid Vec3. If we loop through all the large boosts and don't find any available, let's return the location at the center of the field, given by
Vec3(0, 0, 0). That's sufficient to solve the runtime error, but it can be improved upon in many ways.
One way to improve is to have our bot go for a small boost when all the large ones are gone. In the solution I've provided, I simply loop over all the boosts again (if we didn't find any large boost) but with the
boost.is_full_boost check removed. This code works, but it's less than ideal in a couple ways. Firstly, it's very similar to the code we have before it. Writing code like this violates the DRY principle (which stands for Don't Repeat Yourself). My solution also unnecessarily loops over all the boosts twice, which is less efficient than doing it just once. In our case, we know the number of boost pads on the field will never exceed 34, so the performance hit is minor, but it's still good to notice these sorts of inefficiencies.
Another improvement we could make is to watch the
packet.game_boosts[index].timer and anticipate when a boost will respawn before our bot gets to it.
def get_nearest_boost(info, packet, car_location): nearest_boost_loc = None # loop over all the boosts for i, boost in enumerate(info.boost_pads): # only want large boosts that haven't been taken if boost.is_full_boost and packet.game_boosts[i].is_active: # if we haven't found any boosts yet, use this one if not nearest_boost_loc: nearest_boost_loc = boost.location else: # if this boost is closer, save that if car_location.dist(Vec3(boost.location)) < car_location.dist(Vec3(nearest_boost_loc)): nearest_boost_loc = boost.location # if no large boosts are found, find the nearest small boost # CODE SMELL: very similar duplicate code, looping over boost list twice if nearest_boost_loc is None: for i, boost in enumerate(info.boost_pads): # only want large boosts that haven't been taken if packet.game_boosts[i].is_active: # if we haven't found any boosts yet, use this one if not nearest_boost_loc: nearest_boost_loc = boost.location else: # if this boost is closer, save that if car_location.dist(Vec3(boost.location)) < car_location.dist(Vec3(nearest_boost_loc)): nearest_boost_loc = boost.location # if no boosts are available, target the center of the field if nearest_boost_loc is None: nearest_boost_loc = Vec3(0, 0, 0) # a different possible optimization we could make would be to look at the # packet.game_boosts[i].timer to find boosts that will respawn before our car arrives there return Vec3(nearest_boost_loc)
I've left it as an exercise for you to implement these optimizations.
Now let's talk about introducing some decision making logic into our bot.
Let's make our bot go for the ball during kickoff. We'll make a new class variable
has_first_touch_happened_yet to keep track of if the kickoff has been been completed or not. Inside
get_output() we'll start writing our behavior logic. If the first touch has not happened yet, we'll set the target location to the ball, but once it has been touched we'll switch back to use the nearest boost location as the target.
To determine when the ball has been touched, we'll check to see if it has been moved off its starting position. The ball starts in the middle of the field, which is X=0 and Y=0, but because its position is measured from the center of the ball, the Z coordinate is slightly above 0. To account for this, we can use the Vec3
flat() method to ignore the Z component.
# behavior logic is here. it decides what our target should be if not self.has_first_touch_happened_yet: # during kickoff, go for the ball target_location = ball_location # check to see if kickoff has occurred by seeing if the ball position # has left the middle of the field if ball_location.flat() != Vec3(0, 0, 0): self.has_first_touch_happened_yet = True else: # default behavior is to grab the nearest boost target_location = get_nearest_boost(info, packet, car_location)
If you run this code, you'll find that the bot continues to go after boost instead of the ball during kickoff. This is because there's a problem with our not-equals check between the ball location and the ball starting location:
!= is not defined when comparing two Vec3 vectors! We can solve this in the Vec3 class by defining the comparison methods for equal:
__eq__() and not equal:
def __eq__(self, other: 'Vec3'): return (self.x == other.x and self.y == other.y and self.z == other.z) def __ne__(self, other: 'Vec3'): return not self.__eq__(other)
We also need to account for the situation when a kickoff happens following a goal, or at the start of overtime. To do this, we can check the
packet.game_info.is_kickoff_pause value and flip
has_first_touch_happened_yet back to False when this is the case.
So far so good. I think it'd be cool to add one more feature to our BoostHog bot. Let's keep track of how many boosts we pickup, and then after some number of boost steals let's target the ball again for a few seconds.
boosts_collected as a new class variable to keep track of how many boosts we've picked up,
boost_counted to flag when we've already counted a boost, and also
chase_ball_game_time to mark the time when we reached our threshold and want to start ball chasing. Let's write a new function
update_boosts_collected() to update those values. Whenever our car's boost value gets to 100, we'll increment the number of boosts collected and mark that boost as counted. When the car's boost value dips below 100, we'll flip the
boost_counted flag to prepare for counting the next boost pickup. And then when
boosts_collected reaches a given threshold, I'm using 6, we'll mark the current game time in
chase_ball_game_time and reset
boosts_collected back to 0.
def update_boosts_collected(self, my_car, packet): # mark when 100 boost has been reached if my_car.boost >= 100: if not self.boost_counted: self.boosts_collected += 1 self.boost_counted = True else: self.boost_counted = False # if boosts collected has crossed threshold, trigger our behavior logic to chase ball if self.boosts_collected >= self.BOOSTS_COLLECTED_THRESHOLD: self.chase_ball_game_time = packet.game_info.game_time_remaining # reset the boost collected count self.boosts_collected = 0
Now turn your attention back to our behavior logic in
get_output(). We want to make a new condition that checks the current game time remaining against the
chase_ball_game_time marker. If we are within the window of time when we want to chase the ball, make the ball location the target instead of the nearest boost location. The
packet.game_info.game_time_remaining counts down with the game clock, so to find out if we are within a window of, say, 5 seconds from our
chase_ball_game_time marker, we can simply check to see if the game time remaining is greater than our marked time minus 5.
elif packet.game_info.game_time_remaining > self.chase_ball_game_time - self.BALL_CHASE_DURATION: # go chase the ball target_location = ball_location
If your bots are always choosing the chase ball behavior, make sure you've initialized the
chase_ball_game_time value to a number greater than the starting game time remaining, and not 0. Otherwise the if statement above will always evaluate to True.
It would be a good idea, instead of hard-coding the 6 boost threshold and the 5 second window, to make those constants defined at the top of the class. This makes our code easier to read, and makes it easier for us to change those values in the future. By convention, constant variables are written in all caps.
To wrap up, I invite you to take a look at the open source "Beast from the East" bot code on GitHub. Notice how this code is broken down into different maneuvers, which are then combined to build up different behaviors, and a central "brain" logic controller switches between different behaviors depending on the state of the game. This is a more fully developed implementation of the code structure we've covered here, and represents an example of how I would move forward in building a fully featured bot.
Congratulations on completing this tutorial. I encourage you to keep working on your bot and build something that's a unique expression of you. And let me know how it goes!