Chapter 13: Aliens!

Source: Python Crash Course, 3rd Edition by Eric Matthes

In this chapter, we’ll add aliens to Alien Invasion. We’ll add one alien near the top of the screen and then generate a whole fleet of aliens. We’ll make the fleet advance sideways and down, and we’ll get rid of any aliens hit by a bullet. Finally, we’ll limit the number of ships a player has and end the game when the player runs out of ships.

As you work through this chapter, you’ll learn more about Pygame and about managing a large project. You’ll also learn to detect collisions between game objects, like bullets and aliens. Detecting collisions helps you define interactions between elements in your games. For example, you can confine a character inside the walls of a maze or pass a ball between two characters. We’ll continue to work from a plan that we revisit occasionally to maintain the focus of our code-writing sessions.

Reviewing the Project

When you’re beginning a new phase of development on a large project, it’s always a good idea to revisit your plan and clarify what you want to accomplish with the code you’re about to write. In this chapter, we’ll do the following:

  • Add a single alien to the top-left corner of the screen, with appropriate spacing around it.

  • Fill the upper portion of the screen with as many aliens as we can fit horizontally. We’ll then create additional rows of aliens until we have a full fleet.

  • Make the fleet move sideways and down until the entire fleet is shot down, an alien hits the ship, or an alien reaches the ground.

  • If the entire fleet is shot down, we’ll create a new fleet. If an alien hits the ship or the ground, we’ll destroy the ship and create a new fleet.

  • Limit the number of ships the player can use, and end the game when the player has used up the allotted number of ships.

We’ll refine this plan as we implement features, but this is specific enough to start writing code.

You should also review your existing code when you begin working on a new series of features in a project. Because each new phase typically makes a project more complex, it’s best to clean up any cluttered or inefficient code. We’ve been refactoring as we go, so there isn’t any code that we need to refactor at this point.

Creating the First Alien

Placing one alien on the screen is like placing a ship on the screen. Each alien’s behavior is controlled by a class called Alien, which we’ll structure like the Ship class. We’ll continue using bitmap images for simplicity. You can find your own image for an alien or use the one available in the book’s resources at ehmatthes.github.io/pcc_3e. Make sure you save the image file you choose in the images folder.

Creating the Alien Class

Now we’ll write the Alien class and save it as alien.py:

alien.py
import pygame
from pygame.sprite import Sprite

class Alien(Sprite):
    """A class to represent a single alien in the fleet."""

    def __init__(self, ai_game):
        """Initialize the alien and set its starting position."""
        super().__init__()
        self.screen = ai_game.screen

        # Load the alien image and set its rect attribute.
        self.image = pygame.image.load('images/alien.bmp')
        self.rect = self.image.get_rect()

        # Start each new alien near the top left of the screen.
        self.rect.x = self.rect.width    (1)
        self.rect.y = self.rect.height

        # Store the alien's exact horizontal position.
        self.x = float(self.rect.x)     (2)
1 We initially place each alien near the top-left corner of the screen; we add a space to the left of it that’s equal to the alien’s width and a space above it equal to its height, so it’s easy to see.
2 We’re mainly concerned with the aliens' horizontal speed, so we’ll track the horizontal position of each alien precisely.

This Alien class doesn’t need a method for drawing it to the screen; instead, we’ll use a Pygame group method that automatically draws all the elements of a group to the screen.

Creating an Instance of the Alien

We want to create an instance of Alien so we can see the first alien on the screen. Because it’s part of our setup work, we’ll add the code for this instance at the end of the init() method in AlienInvasion. Eventually, we’ll create an entire fleet of aliens, which will be quite a bit of work, so we’ll make a new helper method called _create_fleet().

Here are the updated import statements for alien_invasion.py:

alien_invasion.py
# --snip--
from bullet import Bullet
from alien import Alien

And here’s the updated init() method:

alien_invasion.py
def __init__(self):
    # --snip--
    self.ship = Ship(self)
    self.bullets = pygame.sprite.Group()
    self.aliens = pygame.sprite.Group()

    self._create_fleet()

We create a group to hold the fleet of aliens, and we call _create_fleet(), which we’re about to write.

Here’s the new _create_fleet() method:

alien_invasion.py
def _create_fleet(self):
    """Create the fleet of aliens."""
    # Make an alien.
    alien = Alien(self)
    self.aliens.add(alien)

In this method, we’re creating one instance of Alien and then adding it to the group that will hold the fleet. The alien will be placed in the default upper-left area of the screen.

To make the alien appear, we need to call the group’s draw() method in _update_screen():

alien_invasion.py
def _update_screen(self):
    # --snip--
    self.ship.blitme()
    self.aliens.draw(self.screen)

    pygame.display.flip()

When you call draw() on a group, Pygame draws each element in the group at the position defined by its rect attribute. The draw() method requires one argument: a surface on which to draw the elements from the group.

Now that the first alien appears correctly, we’ll write the code to draw an entire fleet.

Building the Alien Fleet

To draw a fleet, we need to figure out how to fill the upper portion of the screen with aliens, without overcrowding the game window. We’ll approach it by adding aliens across the top of the screen, until there’s no space left for a new alien. Then we’ll repeat this process, as long as we have enough vertical space to add a new row.

Creating a Row of Aliens

To make a full row, we’ll first make a single alien so we have access to the alien’s width. We’ll place an alien on the left side of the screen and then keep adding aliens until we run out of space:

alien_invasion.py
def _create_fleet(self):
    """Create the fleet of aliens."""
    # Create an alien and keep adding aliens until there's no room left.
    # Spacing between aliens is one alien width.
    alien = Alien(self)
    alien_width = alien.rect.width

    current_x = alien_width                                          (1)
    while current_x < (self.settings.screen_width - 2 * alien_width):  (2)
        new_alien = Alien(self)                                      (3)
        new_alien.x = current_x                                      (4)
        new_alien.rect.x = current_x
        self.aliens.add(new_alien)
        current_x += 2 * alien_width                                 (5)
1 We define a variable called current_x, which refers to the horizontal position of the next alien we intend to place on the screen. We initially set this to one alien width, to offset the first alien in the fleet from the left edge of the screen.
2 We’re going to keep adding aliens while there’s enough room to place one. As long as there’s at least two alien widths' worth of space at the right edge of the screen, we enter the loop and add another alien to the fleet.
3 We create an alien and assign it to new_alien.
4 We set the precise horizontal position to the current value of current_x, position the alien’s rect at this same x-value, and add the new alien to the group self.aliens.
5 We increment the value of current_x by two alien widths, to move past the alien we just added and to leave some space between the aliens as well.

It’s not always obvious exactly how to construct a loop like the one shown in this section. One nice thing about programming is that your initial ideas for how to approach a problem like this don’t have to be correct. It’s perfectly reasonable to start a loop like this with the aliens positioned too far to the right, and then modify the loop until you have an appropriate amount of space on the screen.

Refactoring _create_fleet()

If the code we’ve written so far was all we needed to create a fleet, we’d probably leave _create_fleet() as is. But we have more work to do, so let’s clean up the method a bit. We’ll add a new helper method, _create_alien(), and call it from _create_fleet():

alien_invasion.py
def _create_fleet(self):
    # --snip--
    while current_x < (self.settings.screen_width - 2 * alien_width):
        self._create_alien(current_x)
        current_x += 2 * alien_width

def _create_alien(self, x_position):         (1)
    """Create an alien and place it in the row."""
    new_alien = Alien(self)
    new_alien.x = x_position
    new_alien.rect.x = x_position
    self.aliens.add(new_alien)
1 The method _create_alien() requires one parameter in addition to self: the x-value that specifies where the alien should be placed. This refactoring will make it easier to add new rows and create an entire fleet.

Adding Rows

To finish the fleet, we’ll keep adding more rows until we run out of room. We’ll use a nested loop — the inner loop will place aliens horizontally in a row by focusing on the aliens' x-values. The outer loop will place aliens vertically by focusing on the y-values. We’ll stop adding rows when we get near the bottom of the screen, leaving enough space for the ship and some room to start firing at the aliens.

alien_invasion.py
def _create_fleet(self):
    """Create the fleet of aliens."""
    # Create an alien and keep adding aliens until there's no room left.
    # Spacing between aliens is one alien width and one alien height.
    alien = Alien(self)
    alien_width, alien_height = alien.rect.size          (1)

    current_x, current_y = alien_width, alien_height    (2)
    while current_y < (self.settings.screen_height - 3 * alien_height):  (3)
        while current_x < (self.settings.screen_width - 2 * alien_width):
            self._create_alien(current_x, current_y)    (4)
            current_x += 2 * alien_width

        # Finished a row; reset x value, and increment y value.
        current_x = alien_width                         (5)
        current_y += 2 * alien_height
1 We grab the alien’s width and height using the size attribute of an alien rect. A rect’s `size attribute is a tuple containing its width and height.
2 We set the initial x- and y-values for the placement of the first alien in the fleet.
3 We define the while loop that controls how many rows are placed onto the screen. As long as the y-value for the next row is less than the screen height, minus three alien heights, we’ll keep adding rows.
4 We call _create_alien(), and pass it the y-value as well as its x-position.
5 These lines are inside the outer while loop, but outside the inner while loop. After each row has been added, we reset the value of current_x and add two alien heights to current_y, so the next row will be placed further down the screen. Indentation is really important here.

We need to modify _create_alien() to set the vertical position of the alien correctly:

alien_invasion.py
def _create_alien(self, x_position, y_position):
    """Create an alien and place it in the fleet."""
    new_alien = Alien(self)
    new_alien.x = x_position
    new_alien.rect.x = x_position
    new_alien.rect.y = y_position
    self.aliens.add(new_alien)

We modify the definition of the method to accept the y-value for the new alien, and we set the vertical position of the rect in the body of the method.

When you run the game now, you should see a full fleet of aliens. In the next section, we’ll make the fleet move!

Try It Yourself

13-1. Stars: Find an image of a star. Make a grid of stars appear on the screen.

13-2. Better Stars: You can make a more realistic star pattern by introducing randomness when you place each star. Recall from Chapter 9 that you can get a random number like this:

from random import randint
random_number = randint(-10, 10)

This code returns a random integer between −10 and 10. Using your code in Exercise 13-1, adjust each star’s position by a random amount.

Making the Fleet Move

Now let’s make the fleet of aliens move to the right across the screen until it hits the edge, and then make it drop a set amount and move in the other direction. We’ll continue this movement until all aliens have been shot down, one collides with the ship, or one reaches the bottom of the screen.

Moving the Aliens Right

To move the aliens, we’ll use an update() method in alien.py, which we’ll call for each alien in the group of aliens. First, add a setting to control the speed of each alien:

settings.py
def __init__(self):
    # --snip--
    # Alien settings
    self.alien_speed = 1.0

Then use this setting to implement update() in alien.py:

alien.py
def __init__(self, ai_game):
    """Initialize the alien and set its starting position."""
    super().__init__()
    self.screen = ai_game.screen
    self.settings = ai_game.settings
    # --snip--

def update(self):
    """Move the alien to the right."""
    self.x += self.settings.alien_speed   (1)
    self.rect.x = self.x                  (2)
1 Each time we update an alien’s position, we move it to the right by the amount stored in alien_speed. We track the alien’s exact position with the self.x attribute, which can hold float values.
2 We then use the value of self.x to update the position of the alien’s rect.

In the main while loop, we already have calls to update the ship and bullet positions. Now we’ll add a call to update the position of each alien as well:

alien_invasion.py
while True:
    self._check_events()
    self.ship.update()
    self._update_bullets()
    self._update_aliens()
    self._update_screen()
    self.clock.tick(60)

We update the aliens' positions after the bullets have been updated, because we’ll soon be checking to see whether any bullets hit any aliens. Here’s the first version of _update_aliens():

alien_invasion.py
def _update_aliens(self):
    """Update the positions of all aliens in the fleet."""
    self.aliens.update()

We use the update() method on the aliens group, which calls each alien’s update() method. When you run Alien Invasion now, you should see the fleet move right and disappear off the side of the screen.

Creating Settings for Fleet Direction

Now we’ll create the settings that will make the fleet move down the screen and to the left when it hits the right edge:

settings.py
    # Alien settings
    self.alien_speed = 1.0
    self.fleet_drop_speed = 10
    # fleet_direction of 1 represents right; -1 represents left.
    self.fleet_direction = 1

The setting fleet_drop_speed controls how quickly the fleet drops down the screen each time an alien reaches either edge. Rather than using text values like 'left' or 'right', we use the values 1 and −1 and switch between them each time the fleet changes direction. Moving right involves adding to each alien’s x-coordinate value, and moving left involves subtracting from each alien’s x-coordinate value.

Checking Whether an Alien Has Hit the Edge

We need a method to check whether an alien is at either edge, and we need to modify update() to allow each alien to move in the appropriate direction. This code is part of the Alien class:

alien.py
def check_edges(self):
    """Return True if alien is at edge of screen."""
    screen_rect = self.screen.get_rect()
    return (self.rect.right >= screen_rect.right) or (self.rect.left <= 0)  (1)

def update(self):
    """Move the alien right or left."""
    self.x += self.settings.alien_speed * self.settings.fleet_direction  (2)
    self.rect.x = self.x
1 The alien is at the right edge if the right attribute of its rect is greater than or equal to the right attribute of the screen’s rect. It’s at the left edge if its left value is less than or equal to 0. Rather than put this conditional test in an if block, we put the test directly in the return statement.
2 We modify update() to allow motion to the left or right by multiplying the alien’s speed by the value of fleet_direction. If fleet_direction is 1, the value of alien_speed will be added to the alien’s current position, moving the alien to the right; if fleet_direction is −1, the value will be subtracted, moving the alien to the left.

Dropping the Fleet and Changing Direction

When an alien reaches the edge, the entire fleet needs to drop down and change direction. We’ll make this happen by writing the methods _check_fleet_edges() and _change_fleet_direction(), and then modifying _update_aliens():

alien_invasion.py
def _check_fleet_edges(self):
    """Respond appropriately if any aliens have reached an edge."""
    for alien in self.aliens.sprites():
        if alien.check_edges():               (1)
            self._change_fleet_direction()
            break                             (2)

def _change_fleet_direction(self):
    """Drop the entire fleet and change the fleet's direction."""
    for alien in self.aliens.sprites():
        alien.rect.y += self.settings.fleet_drop_speed   (3)
    self.settings.fleet_direction *= -1
1 In _check_fleet_edges(), we loop through the fleet and call check_edges() on each alien.
2 If check_edges() returns True, we know an alien is at an edge and the whole fleet needs to change direction; so we call _change_fleet_direction() and break out of the loop.
3 In _change_fleet_direction(), we loop through all the aliens and drop each one using the setting fleet_drop_speed; then we change the value of fleet_direction by multiplying its current value by −1. The line that changes the fleet’s direction isn’t part of the for loop — we want to change each alien’s vertical position, but we only want to change the direction of the fleet once.

Here are the changes to _update_aliens():

alien_invasion.py
def _update_aliens(self):
    """Check if the fleet is at an edge, then update positions."""
    self._check_fleet_edges()
    self.aliens.update()

We’ve modified the method by calling _check_fleet_edges() before updating each alien’s position. When you run the game now, the fleet should move back and forth between the edges of the screen and drop down every time it hits an edge.

Try It Yourself

13-3. Raindrops: Find an image of a raindrop and create a grid of raindrops. Make the raindrops fall toward the bottom of the screen until they disappear.

13-4. Steady Rain: Modify your code in Exercise 13-3 so when a row of raindrops disappears off the bottom of the screen, a new row appears at the top of the screen and begins to fall.

Shooting Aliens

We’ve built our ship and a fleet of aliens, but when the bullets reach the aliens, they simply pass through because we aren’t checking for collisions. In game programming, collisions happen when game elements overlap. To make the bullets shoot down aliens, we’ll use the function sprite.groupcollide() to look for collisions between members of two groups.

Detecting Bullet Collisions

We want to know right away when a bullet hits an alien so we can make an alien disappear as soon as it’s hit. To do this, we’ll look for collisions immediately after updating the position of all the bullets.

The sprite.groupcollide() function compares the rects of each element in one group with the rects of each element in another group. In this case, it compares each bullet’s rect with each alien’s rect and returns a dictionary containing the bullets and aliens that have collided. Each key in the dictionary will be a bullet, and the corresponding value will be the alien that was hit.

Add the following code to the end of _update_bullets() to check for collisions between bullets and aliens:

alien_invasion.py
def _update_bullets(self):
    """Update position of bullets and get rid of old bullets."""
    # --snip--

    # Check for any bullets that have hit aliens.
    #   If so, get rid of the bullet and the alien.
    collisions = pygame.sprite.groupcollide(
            self.bullets, self.aliens, True, True)

The new code compares the positions of all the bullets in self.bullets and all the aliens in self.aliens, and identifies any that overlap. Whenever the rects of a bullet and alien overlap, groupcollide() adds a key-value pair to the dictionary it returns. The two True arguments tell Pygame to delete the bullets and aliens that have collided.

(To make a high-powered bullet that can travel to the top of the screen, destroying every alien in its path, you could set the first Boolean argument to False and keep the second set to True. The aliens hit would disappear, but all bullets would stay active until they disappeared off the top of the screen.)

Making Larger Bullets for Testing

You can test many features of Alien Invasion simply by running the game, but some features are tedious to test in the normal version of the game. For example, it’s a lot of work to shoot down every alien on the screen multiple times to test whether your code responds to an empty fleet correctly.

To test particular features, you can change certain game settings to focus on a particular area. For example, you might shrink the screen so there are fewer aliens to shoot down or increase the bullet speed and give yourself lots of bullets at once.

My favorite change for testing Alien Invasion is to use really wide bullets that remain active even after they’ve hit an alien. Try setting bullet_width to 300, or even 3,000, to see how quickly you can shoot down the fleet!

Changes like these will help you test the game more efficiently and possibly spark ideas for giving players bonus powers. Just remember to restore the settings to normal when you’re finished testing a feature.

Repopulating the Fleet

One key feature of Alien Invasion is that the aliens are relentless: every time the fleet is destroyed, a new fleet should appear.

To make a new fleet of aliens appear after a fleet has been destroyed, we first check whether the aliens group is empty. If it is, we make a call to _create_fleet(). We’ll perform this check at the end of _update_bullets(), because that’s where individual aliens are destroyed:

alien_invasion.py
def _update_bullets(self):
    # --snip--

    if not self.aliens:                     (1)
        # Destroy existing bullets and create new fleet.
        self.bullets.empty()               (2)
        self._create_fleet()
1 We check whether the aliens group is empty. An empty group evaluates to False, so this is a simple way to check whether the group is empty.
2 If it is, we get rid of any existing bullets by using the empty() method, which removes all the remaining sprites from a group. We also call _create_fleet(), which fills the screen with aliens again.

Speeding Up the Bullets

If you’ve tried firing at the aliens in the game’s current state, you might find that the bullets aren’t traveling at the best speed for gameplay. We modify the speed of the bullets by adjusting the value of bullet_speed in settings.py. On my system, I’ll adjust the value of bullet_speed to 2.5, so the bullets travel a little faster:

settings.py
    # Bullet settings
    self.bullet_speed = 2.5
    self.bullet_width = 3
    # --snip--

The best value for this setting depends on your experience of the game, so find a value that works for you. You can adjust other settings as well.

Refactoring _update_bullets()

Let’s refactor _update_bullets() so it’s not doing so many different tasks. We’ll move the code for dealing with bullet-alien collisions to a separate method:

alien_invasion.py
def _update_bullets(self):
    # --snip--
    # Get rid of bullets that have disappeared.
    for bullet in self.bullets.copy():
        if bullet.rect.bottom <= 0:
            self.bullets.remove(bullet)

    self._check_bullet_alien_collisions()

def _check_bullet_alien_collisions(self):
    """Respond to bullet-alien collisions."""
    # Remove any bullets and aliens that have collided.
    collisions = pygame.sprite.groupcollide(
            self.bullets, self.aliens, True, True)

    if not self.aliens:
        # Destroy existing bullets and create new fleet.
        self.bullets.empty()
        self._create_fleet()

We’ve created a new method, _check_bullet_alien_collisions(), to look for collisions between bullets and aliens and to respond appropriately if the entire fleet has been destroyed. Doing so keeps _update_bullets() from growing too long and simplifies further development.

Try It Yourself

13-5. Sideways Shooter Part 2: We’ve come a long way since Exercise 12-6, Sideways Shooter. For this exercise, try to develop Sideways Shooter to the same point we’ve brought Alien Invasion to. Add a fleet of aliens, and make them move sideways toward the ship. Or, write code that places aliens at random positions along the right side of the screen and then sends them toward the ship. Also, write code that makes the aliens disappear when they’re hit.

Ending the Game

What’s the fun and challenge in playing a game you can’t lose? If the player doesn’t shoot down the fleet quickly enough, we’ll have the aliens destroy the ship when they make contact. At the same time, we’ll limit the number of ships a player can use, and we’ll destroy the ship when an alien reaches the bottom of the screen. The game will end when the player has used up all their ships.

Detecting Alien-Ship Collisions

We’ll start by checking for collisions between aliens and the ship so we can respond appropriately when an alien hits it. We’ll check for alien-ship collisions immediately after updating the position of each alien in AlienInvasion:

alien_invasion.py
def _update_aliens(self):
    # --snip--
    self.aliens.update()

    # Look for alien-ship collisions.
    if pygame.sprite.spritecollideany(self.ship, self.aliens):  (1)
        print("Ship hit!!!")                                     (2)
1 The spritecollideany() function takes two arguments: a sprite and a group. The function looks for any member of the group that has collided with the sprite and stops looping through the group as soon as it finds one member that has collided with the sprite. If no collisions occur, spritecollideany() returns None and the if block won’t execute.
2 If it finds an alien that has collided with the ship, it returns that alien and the if block executes: it prints Ship hit!!! Writing a print() call is a simple way to ensure we’re detecting these collisions properly before we write the full response.

Now when you run Alien Invasion, the message Ship hit!!! should appear in the terminal whenever an alien runs into the ship. When you’re testing this feature, set fleet_drop_speed to a higher value, such as 50 or 100, so the aliens reach your ship faster.

Responding to Alien-Ship Collisions

Now we need to figure out exactly what will happen when an alien collides with the ship. Instead of destroying the ship instance and creating a new one, we’ll count how many times the ship has been hit by tracking statistics for the game. Tracking statistics will also be useful for scoring.

Let’s write a new class, GameStats, to track game statistics, and let’s save it as game_stats.py:

game_stats.py
class GameStats:
    """Track statistics for Alien Invasion."""

    def __init__(self, ai_game):
        """Initialize statistics."""
        self.settings = ai_game.settings
        self.reset_stats()          (1)

    def reset_stats(self):
        """Initialize statistics that can change during the game."""
        self.ships_left = self.settings.ship_limit
1 We’ll make one GameStats instance for the entire time Alien Invasion is running, but we’ll need to reset some statistics each time the player starts a new game. We initialize most of the statistics in reset_stats() instead of directly in init(), so we can also call reset_stats() anytime the player starts a new game.

The number of ships the player starts with should be stored in settings.py as ship_limit:

settings.py
    # Ship settings
    self.ship_speed = 1.5
    self.ship_limit = 3

We also need to make a few changes in alien_invasion.py. First, we’ll update the import statements at the top of the file:

alien_invasion.py
import sys
from time import sleep

import pygame

from settings import Settings
from game_stats import GameStats
from ship import Ship
# --snip--

We import the sleep() function from the time module in the Python standard library, so we can pause the game for a moment when the ship is hit. We also import GameStats.

We’ll create an instance of GameStats in init():

alien_invasion.py
def __init__(self):
    # --snip--
    self.screen = pygame.display.set_mode(
        (self.settings.screen_width, self.settings.screen_height))
    pygame.display.set_caption("Alien Invasion")

    # Create an instance to store game statistics.
    self.stats = GameStats(self)

    self.ship = Ship(self)
    # --snip--

We make the instance after creating the game window but before defining other game elements, such as the ship.

When an alien hits the ship, we’ll subtract 1 from the number of ships left, destroy all existing aliens and bullets, create a new fleet, and reposition the ship in the middle of the screen. We’ll also pause the game for a moment so the player can notice the collision and regroup before a new fleet appears.

Let’s put most of this code in a new method called _ship_hit(). We’ll call this method from _update_aliens() when an alien hits the ship:

alien_invasion.py
def _ship_hit(self):
    """Respond to the ship being hit by an alien."""
    # Decrement ships_left.
    self.stats.ships_left -= 1                  (1)

    # Get rid of any remaining bullets and aliens.
    self.bullets.empty()                        (2)
    self.aliens.empty()

    # Create a new fleet and center the ship.
    self._create_fleet()                        (3)
    self.ship.center_ship()

    # Pause.
    sleep(0.5)                                  (4)
1 Inside _ship_hit(), the number of ships left is reduced by 1.
2 We empty the groups bullets and aliens.
3 We create a new fleet and center the ship. (We’ll add the method center_ship() to Ship in a moment.)
4 We add a pause after the updates have been made to all the game elements but before any changes have been drawn to the screen, so the player can see that their ship has been hit. The sleep() call pauses program execution for half a second, long enough for the player to see that the alien has hit the ship.

In _update_aliens(), we replace the print() call with a call to _ship_hit() when an alien hits the ship:

alien_invasion.py
def _update_aliens(self):
    # --snip--
    if pygame.sprite.spritecollideany(self.ship, self.aliens):
        self._ship_hit()

Here’s the new method center_ship(), which belongs in ship.py:

ship.py
def center_ship(self):
    """Center the ship on the screen."""
    self.rect.midbottom = self.screen_rect.midbottom
    self.x = float(self.rect.x)

We center the ship the same way we did in init(). After centering it, we reset the self.x attribute, which allows us to track the ship’s exact position.

Notice that we never make more than one ship; we make only one ship instance for the whole game and recenter it whenever the ship has been hit. The statistic ships_left will tell us when the player has run out of ships.

Aliens That Reach the Bottom of the Screen

If an alien reaches the bottom of the screen, we’ll have the game respond the same way it does when an alien hits the ship. To check when this happens, add a new method in alien_invasion.py:

alien_invasion.py
def _check_aliens_bottom(self):
    """Check if any aliens have reached the bottom of the screen."""
    for alien in self.aliens.sprites():
        if alien.rect.bottom >= self.settings.screen_height:  (1)
            # Treat this the same as if the ship got hit.
            self._ship_hit()
            break
1 An alien reaches the bottom when its rect.bottom value is greater than or equal to the screen’s height. If an alien reaches the bottom, we call _ship_hit(). If one alien hits the bottom, there’s no need to check the rest, so we break out of the loop after calling _ship_hit().

We’ll call this method from _update_aliens():

alien_invasion.py
def _update_aliens(self):
    # --snip--
    # Look for alien-ship collisions.
    if pygame.sprite.spritecollideany(self.ship, self.aliens):
        self._ship_hit()

    # Look for aliens hitting the bottom of the screen.
    self._check_aliens_bottom()

Now a new fleet will appear every time the ship is hit by an alien or an alien reaches the bottom of the screen.

Game Over!

Alien Invasion feels more complete now, but the game never ends. The value of ships_left just grows increasingly negative. Let’s add a game_active flag, so we can end the game when the player runs out of ships. We’ll set this flag at the end of the init() method in AlienInvasion:

alien_invasion.py
def __init__(self):
    # --snip--
    # Start Alien Invasion in an active state.
    self.game_active = True

Now we add code to _ship_hit() that sets game_active to False when the player has used up all their ships:

alien_invasion.py
def _ship_hit(self):
    """Respond to ship being hit by alien."""
    if self.stats.ships_left > 0:
        # Decrement ships_left.
        self.stats.ships_left -= 1
        # --snip--
        # Pause.
        sleep(0.5)
    else:
        self.game_active = False

Most of _ship_hit() is unchanged. We’ve moved all the existing code into an if block, which tests to make sure the player has at least one ship remaining. If so, we create a new fleet, pause, and move on. If the player has no ships left, we set game_active to False.

Identifying When Parts of the Game Should Run

We need to identify the parts of the game that should always run and the parts that should run only when the game is active:

alien_invasion.py
def run_game(self):
    """Start the main loop for the game."""
    while True:
        self._check_events()

        if self.game_active:
            self.ship.update()
            self._update_bullets()
            self._update_aliens()

        self._update_screen()
        self.clock.tick(60)

In the main loop, we always need to call _check_events(), even if the game is inactive. For example, we still need to know if the user presses Q to quit the game or clicks the button to close the window. We also continue updating the screen so we can make changes to the screen while waiting to see whether the player chooses to start a new game. The rest of the function calls need to happen only when the game is active.

Now when you play Alien Invasion, the game should freeze when you’ve used up all your ships.

Try It Yourself

13-6. Game Over: In Sideways Shooter, keep track of the number of times the ship is hit and the number of times an alien is hit by the ship. Decide on an appropriate condition for ending the game, and stop the game when this situation occurs.

Summary

In this chapter, you learned how to add a large number of identical elements to a game by creating a fleet of aliens. You used nested loops to create a grid of elements, and you made a large set of game elements move by calling each element’s update() method. You learned to control the direction of objects on the screen and to respond to specific situations, such as when the fleet reaches the edge of the screen. You detected and responded to collisions when bullets hit aliens and aliens hit the ship. You also learned how to track statistics in a game and use a game_active flag to determine when the game is over.

In the next and final chapter of this project, we’ll add a Play button so the player can choose when to start their first game and whether to play again when the game ends. We’ll speed up the game each time the player shoots down the entire fleet, and we’ll add a scoring system. The final result will be a fully playable game!

Applied Exercises: Ch 13 — Aliens!

These exercises implement the underlying patterns from the chapter — grid generation, directional movement, collision detection, statistics tracking, and game state flags — as pure Python simulations, directly applicable to your infrastructure and security tooling.

Domus Digitalis / Homelab

D13-1. Node Grid Generator: Write a function create_node_grid(cols, rows, col_spacing, row_spacing) that returns a list of (x, y) tuples representing grid positions for nodes. Print the grid as a formatted table showing hostname labels (e.g., node-0-0, node-0-1). This mirrors the nested loop fleet creation pattern.

D13-2. Fleet Direction Tracker: Write a class called FleetState with attributes direction (1 or -1), drop_speed, and current_y. Add check_edges(current_x, screen_width, alien_width) that returns True if the fleet has hit an edge. Add change_direction() that drops current_y by drop_speed and flips direction. Simulate 10 movement cycles and print the state each cycle.

D13-3. Service Collision Detector: Write a function check_collisions(active_alerts, monitored_services) that simulates groupcollide() — it returns a dict of {alert: service} pairs where the alert’s target matches a service name. Remove matched items from both lists (simulate with True, True). Test with 5 alerts and 5 services, some overlapping.

D13-4. Deployment Statistics: Write a class DeployStats with a reset_stats() method that initializes deployments_left from a settings dict. Add record_failure() that decrements deployments_left and a is_game_over property that returns True when deployments_left ⇐ 0. Test by simulating 4 failures against a limit of 3.

D13-5. Active State Flag: Write a class StackMonitor with game_active = True and a run() method that calls _check_events(), conditionally runs _update_state() only when active, and always calls _render(). Simulate with a list of event dicts including a {'type': 'failure', 'count': 4} that sets game_active = False when failures >= ship_limit.

CHLA / ISE / Network Security

C13-1. Policy Grid Generator: Write a function create_policy_grid(rows, cols) that returns a list of policy entry dicts with name, row, col, and x/y position values. Use the same nested loop pattern from _create_fleet(). Print a formatted summary of the grid.

C13-2. Fleet Edge and Drop Simulation: Write a class PolicyEvalFleet with a list of policy objects each having an x float and a direction (1 or -1). Add check_edges(screen_width) that returns True if any policy object is at an edge. Add change_direction(drop_amount) that drops all y-values and flips direction. Simulate 8 movement cycles.

C13-3. Bullet-Alien Collision (Log-Event Matching): Write a function check_log_event_collisions(log_entries, active_threats, remove_both) that returns a dict of matched pairs. If remove_both is True, remove matched entries from both lists. Test with 6 log entries and 4 threats, some matching on event_id.

C13-4. ISE GameStats: Write a class ISEStats with a reset_stats() method initializing nodes_available = settings['node_limit']. Add record_node_failure() that decrements nodes_available, and a game_over property. Write a _ship_hit() analog called _node_hit(stats, bullets, aliens) that decrements the counter, empties both groups (simulate with lists), and calls sleep(0.5). Test with 3 failures.

C13-5. Game Active Pattern for SIEM: Write a class SIEMMonitor with game_active = True and a run() method modeled on the chapter’s final run_game() pattern. Always call _check_events() and _update_screen(). Only call _update_pipeline() and _update_threats() when game_active is True. Simulate with 8 event cycles including one that triggers game over.

General Sysadmin / Linux

L13-1. Service Grid: Write a function create_service_grid(services, cols) that arranges a flat list of service names into a 2D grid (list of lists). Print the grid formatted as a table. This mirrors the fleet creation nested loop.

L13-2. Process Direction Simulator: Write a class ProcessScanner with a direction (1 or -1) and a current_position float. Add check_bounds(min_val, max_val) returning True if at either edge. Add change_direction(drop) that shifts a secondary dimension and flips direction. Simulate 10 scan cycles and print the state.

L13-3. Process-Alert Collision: Write a function match_alerts_to_processes(alerts, processes) that returns a dict of matched pairs where alert['pid'] matches process['pid']. Optionally remove matched items from both lists. Test with 5 alerts and 5 processes, some overlapping.

L13-4. System Stats Tracker: Write a class SystemStats with a reset_stats() method initializing retries_left from settings. Add record_failure() and a system_down property. Write a _handle_crash() method that decrements retries_left, clears two service lists (simulate with lists), and calls sleep(0.5). Test with 3 crash events.

L13-5. Monitor Active Flag: Write a class SystemMonitor with active = True and a run() method that always calls _check_alerts() and _render_dashboard() but only calls _update_services() and _update_processes() when active is True. Simulate 8 cycles with a trigger that sets active = False when failures >= limit.

Spanish / DELE C2

E13-1. Vocabulary Grid: Write a function create_vocab_grid(words, cols) that arranges a list of Spanish vocabulary words into a 2D grid. Print the grid formatted as a table. Use the nested loop pattern from _create_fleet().

E13-2. Chapter Navigator Fleet: Write a class ChapterFleet with a list of chapter objects each having a position float and a direction (1 or -1). Add check_edges(min_pos, max_pos) and change_direction(drop). Simulate navigating through Don Quijote chapters 1–30 with 8 movement cycles.

E13-3. Vocabulary Quiz Collision: Write a function check_quiz_answers(submitted, correct_answers) that returns a dict of matched pairs where submitted['answer'] matches correct['answer']. Optionally remove matched items from both lists. Test with 5 submissions and 5 correct answers, some matching.

E13-4. Study Statistics: Write a class StudyStats with a reset_stats() method initializing sessions_remaining from settings. Add record_missed_session() and a study_over property. Write a _handle_missed() method that decrements the counter, clears two topic lists, and calls sleep(0.5). Test with 3 missed sessions against a limit of 3.

E13-5. DELE Active Flag: Write a class DELESession with active = True and a run() method that always calls _check_events() and _render_progress() but only calls _update_vocab() and _update_grammar() when active is True. Simulate 8 study cycles with a trigger that sets active = False when remaining sessions hit 0.