Chapter 12: A Ship That Fires Bullets

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

Let’s build a game called Alien Invasion! We’ll use Pygame, a collection of fun, powerful Python modules that manage graphics, animation, and even sound, making it easier for you to build sophisticated games. With Pygame handling tasks like drawing images to the screen, you can focus on the higher-level logic of game dynamics.

In this chapter, you’ll set up Pygame and then create a rocket ship that moves right and left and fires bullets in response to player input. In the next two chapters, you’ll create a fleet of aliens to destroy, and then continue to refine the game by setting limits on the number of ships you can use and adding a scoreboard.

While building this game, you’ll also learn how to manage large projects that span multiple files. We’ll refactor a lot of code and manage file contents to organize the project and make the code efficient.

Making games is an ideal way to have fun while learning a language. It’s deeply satisfying to play a game you wrote, and writing a simple game will teach you a lot about how professionals develop games. As you work through this chapter, enter and run the code to identify how each code block contributes to overall gameplay. Experiment with different values and settings to better understand how to refine interactions in your games.

Alien Invasion spans a number of different files, so make a new alien_invasion folder on your system. Be sure to save all files for the project to this folder so your import statements will work correctly.

Also, if you feel comfortable using version control, you might want to use it for this project. If you haven’t used version control before, see Appendix D for an overview.

Planning Your Project

When you’re building a large project, it’s important to prepare a plan before you begin to write code. Your plan will keep you focused and make it more likely that you’ll complete the project.

Let’s write a description of the general gameplay. Although the following description doesn’t cover every detail of Alien Invasion, it provides a clear idea of how to start building the game:

In Alien Invasion, the player controls a rocket ship that appears at the bottom center of the screen. The player can move the ship right and left using the arrow keys and shoot bullets using the spacebar. When the game begins, a fleet of aliens fills the sky and moves across and down the screen. The player shoots and destroys the aliens. If the player destroys all the aliens, a new fleet appears that moves faster than the previous fleet. If any alien hits the player’s ship or reaches the bottom of the screen, the player loses a ship. If the player loses three ships, the game ends.

For the first development phase, we’ll make a ship that can move right and left when the player presses the arrow keys and fire bullets when the player presses the spacebar. After setting up this behavior, we can create the aliens and refine the gameplay.

Installing Pygame

Before you begin coding, install Pygame. We’ll do this the same way we installed pytest in Chapter 11: with pip. If you skipped Chapter 11 or need a refresher on pip, see Installing pytest with pip on page 210.

To install Pygame, enter the following command at a terminal prompt:

$ python -m pip install --user pygame

If you use a command other than python to run programs or start a terminal session, such as python3, make sure you use that command instead.

Starting the Game Project

We’ll begin building the game by creating an empty Pygame window. Later, we’ll draw the game elements, such as the ship and the aliens, on this window. We’ll also make our game respond to user input, set the background color, and load a ship image.

Creating a Pygame Window and Responding to User Input

We’ll make an empty Pygame window by creating a class to represent the game. In your text editor, create a new file and save it as alien_invasion.py; then enter the following:

alien_invasion.py
import sys

import pygame

class AlienInvasion:
    """Overall class to manage game assets and behavior."""

    def __init__(self):
        """Initialize the game, and create game resources."""
        pygame.init()                                          (1)

        self.screen = pygame.display.set_mode((1200, 800))    (2)
        pygame.display.set_caption("Alien Invasion")

    def run_game(self):
        """Start the main loop for the game."""
        while True:                                            (3)
            # Watch for keyboard and mouse events.
            for event in pygame.event.get():                  (4)
                if event.type == pygame.QUIT:                 (5)
                    sys.exit()

            # Make the most recently drawn screen visible.
            pygame.display.flip()                             (6)

if __name__ == '__main__':
    # Make a game instance, and run the game.
    ai = AlienInvasion()
    ai.run_game()
1 The pygame.init() function initializes the background settings that Pygame needs to work properly.
2 We call pygame.display.set_mode() to create a display window. The argument (1200, 800) is a tuple that defines the dimensions of the game window. We assign this display window to the attribute self.screen, so it will be available in all methods in the class.
3 The game is controlled by the run_game() method, which contains a while loop that runs continually. The while loop contains an event loop and code that manages screen updates.
4 To access the events that Pygame detects, we’ll use the pygame.event.get() function. This function returns a list of events that have taken place since the last time this function was called.
5 When the player clicks the game window’s close button, a pygame.QUIT event is detected and we call sys.exit() to exit the game.
6 The call to pygame.display.flip() tells Pygame to make the most recently drawn screen visible.

First, we import the sys and pygame modules. We’ll use tools in the sys module to exit the game when the player quits.

The object we assigned to self.screen is called a surface. A surface in Pygame is a part of the screen where a game element can be displayed. The surface returned by display.set_mode() represents the entire game window. When we activate the game’s animation loop, this surface will be redrawn on every pass through the loop, so it can be updated with any changes triggered by user input.

When you run this alien_invasion.py file, you should see an empty Pygame window.

Controlling the Frame Rate

Ideally, games should run at the same speed, or frame rate, on all systems. Controlling the frame rate of a game that can run on multiple systems is a complex issue, but Pygame offers a relatively simple way to accomplish this goal. We’ll make a clock, and ensure the clock ticks once on each pass through the main loop. Anytime the loop processes faster than the rate we define, Pygame will calculate the correct amount of time to pause so that the game runs at a consistent rate.

We’ll define the clock in the init() method:

alien_invasion.py
def __init__(self):
    """Initialize the game, and create game resources."""
    pygame.init()
    self.clock = pygame.time.Clock()
    # --snip--

After initializing pygame, we create an instance of the class Clock, from the pygame.time module. Then we’ll make the clock tick at the end of the while loop in run_game():

def run_game(self):
    """Start the main loop for the game."""
    while True:
        # --snip--
        pygame.display.flip()
        self.clock.tick(60)

The tick() method takes one argument: the frame rate for the game. Here I’m using a value of 60, so Pygame will do its best to make the loop run exactly 60 times per second.

Pygame’s clock should help the game run consistently on most systems. If it makes the game run less consistently on your system, you can try different values for the frame rate. If you can’t find a good frame rate on your system, you can leave the clock out entirely and adjust the game’s settings so it runs well on your system.

Setting the Background Color

Pygame creates a black screen by default, but that’s boring. Let’s set a different background color. We’ll do this at the end of the init() method.

alien_invasion.py
def __init__(self):
    # --snip--
    pygame.display.set_caption("Alien Invasion")

    # Set the background color.
    self.bg_color = (230, 230, 230)  (1)

def run_game(self):
    # --snip--
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                sys.exit()

        # Redraw the screen during each pass through the loop.
        self.screen.fill(self.bg_color)  (2)

        # Make the most recently drawn screen visible.
        pygame.display.flip()
        self.clock.tick(60)
1 Colors in Pygame are specified as RGB colors: a mix of red, green, and blue. Each color value can range from 0 to 255. The color value (230, 230, 230) mixes equal amounts of red, blue, and green, which produces a light gray background color. We assign this color to self.bg_color.
2 We fill the screen with the background color using the fill() method, which acts on a surface and takes only one argument: a color.

Creating a Settings Class

Each time we introduce new functionality into the game, we’ll typically create some new settings as well. Instead of adding settings throughout the code, let’s write a module called settings that contains a class called Settings to store all these values in one place. This approach allows us to work with just one settings object anytime we need to access an individual setting. This also makes it easier to modify the game’s appearance and behavior as our project grows. To modify the game, we’ll change the relevant values in settings.py instead of searching for different settings throughout the project.

Create a new file named settings.py inside your alien_invasion folder, and add this initial Settings class:

settings.py
class Settings:
    """A class to store all settings for Alien Invasion."""

    def __init__(self):
        """Initialize the game's settings."""
        # Screen settings
        self.screen_width = 1200
        self.screen_height = 800
        self.bg_color = (230, 230, 230)

To make an instance of Settings in the project and use it to access our settings, we need to modify alien_invasion.py as follows:

alien_invasion.py
# --snip--
import pygame

from settings import Settings

class AlienInvasion:
    """Overall class to manage game assets and behavior."""

    def __init__(self):
        """Initialize the game, and create game resources."""
        pygame.init()
        self.clock = pygame.time.Clock()
        self.settings = Settings()                      (1)

        self.screen = pygame.display.set_mode(          (2)
            (self.settings.screen_width, self.settings.screen_height))
        pygame.display.set_caption("Alien Invasion")

    def run_game(self):
        # --snip--
        # Redraw the screen during each pass through the loop.
        self.screen.fill(self.settings.bg_color)        (3)

        # Make the most recently drawn screen visible.
        pygame.display.flip()
        self.clock.tick(60)
# --snip--
1 We import Settings into the main program file and create an instance of Settings assigned to self.settings, after making the call to pygame.init().
2 When we create a screen, we use the screen_width and screen_height attributes of self.settings.
3 We use self.settings to access the background color when filling the screen as well.

When you run alien_invasion.py now you won’t yet see any changes, because all we’ve done is move the settings we were already using elsewhere. Now we’re ready to start adding new elements to the screen.

Adding the Ship Image

Let’s add the ship to our game. To draw the player’s ship on the screen, we’ll load an image and then use the Pygame blit() method to draw the image.

When you’re choosing artwork for your games, be sure to pay attention to licensing. The safest and cheapest way to start is to use freely licensed graphics that you can use and modify, from a website like opengameart.org.

You can use almost any type of image file in your game, but it’s easiest when you use a bitmap (.bmp) file because Pygame loads bitmaps by default. Although you can configure Pygame to use other file types, some file types depend on certain image libraries that must be installed on your computer. Most images you’ll find are in .jpg or .png formats, but you can convert them to bitmaps using tools like Photoshop, GIMP, and Paint.

Pay particular attention to the background color in your chosen image. Try to find a file with a transparent or solid background that you can replace with any background color, using an image editor. Your games will look best if the image’s background color matches your game’s background color. Alternatively, you can match your game’s background to the image’s background.

For Alien Invasion, you can use the file ship.bmp, which is available in this book’s resources at ehmatthes.github.io/pcc_3e. The file’s background color matches the settings we’re using in this project. Make a folder called images inside your main alien_invasion project folder. Save the file ship.bmp in the images folder.

Creating the Ship Class

After choosing an image for the ship, we need to display it on the screen. To use our ship, we’ll create a new ship module that will contain the class Ship. This class will manage most of the behavior of the player’s ship:

ship.py
import pygame

class Ship:
    """A class to manage the ship."""

    def __init__(self, ai_game):
        """Initialize the ship and set its starting position."""
        self.screen = ai_game.screen                    (1)
        self.screen_rect = ai_game.screen.get_rect()    (2)

        # Load the ship image and get its rect.
        self.image = pygame.image.load('images/ship.bmp')  (3)
        self.rect = self.image.get_rect()

        # Start each new ship at the bottom center of the screen.
        self.rect.midbottom = self.screen_rect.midbottom  (4)

    def blitme(self):                                   (5)
        """Draw the ship at its current location."""
        self.screen.blit(self.image, self.rect)
1 We assign the screen to an attribute of Ship, so we can access it easily in all the methods in this class.
2 We access the screen’s rect attribute using the get_rect() method and assign it to self.screen_rect, so we can place the ship in the correct location on the screen.
3 To load the image, we call pygame.image.load() and give it the location of our ship image. This function returns a surface representing the ship, which we assign to self.image. When the image is loaded, we call get_rect() to access the ship surface’s rect attribute so we can later use it to place the ship.
4 We position the ship at the bottom center of the screen by making the value of self.rect.midbottom match the midbottom attribute of the screen’s rect.
5 We define the blitme() method, which draws the image to the screen at the position specified by self.rect.

Pygame is efficient because it lets you treat all game elements like rectangles (rects), even if they’re not exactly shaped like rectangles. Treating an element as a rectangle is efficient because rectangles are simple geometric shapes. When Pygame needs to figure out whether two game elements have collided, for example, it can do this more quickly if it treats each object as a rectangle.

When you’re working with a rect object, you can use the x- and y-coordinates of the top, bottom, left, and right edges of the rectangle, as well as the center, to place the object. You can set any of these values to establish the current position of the rect. When you’re centering a game element, work with the center, centerx, or centery attributes of a rect. When you’re working at an edge of the screen, work with the top, bottom, left, or right attributes. There are also attributes that combine these properties, such as midbottom, midtop, midleft, and midright. When you’re adjusting the horizontal or vertical placement of the rect, you can just use the x and y attributes, which are the x- and y-coordinates of its top-left corner.

In Pygame, the origin (0, 0) is at the top-left corner of the screen, and coordinates increase as you go down and to the right. On a 1200×800 screen, the origin is at the top-left corner, and the bottom-right corner has the coordinates (1200, 800). These coordinates refer to the game window, not the physical screen.

Drawing the Ship to the Screen

Now let’s update alien_invasion.py so it creates a ship and calls the ship’s blitme() method:

alien_invasion.py
# --snip--
from settings import Settings
from ship import Ship

class AlienInvasion:
    """Overall class to manage game assets and behavior."""

    def __init__(self):
        # --snip--
        pygame.display.set_caption("Alien Invasion")
        self.ship = Ship(self)              (1)

    def run_game(self):
        # --snip--
        # Redraw the screen during each pass through the loop.
        self.screen.fill(self.settings.bg_color)
        self.ship.blitme()                  (2)

        # Make the most recently drawn screen visible.
        pygame.display.flip()
        self.clock.tick(60)
# --snip--
1 We import Ship and then make an instance of Ship after the screen has been created. The call to Ship() requires one argument: an instance of AlienInvasion. The self argument here refers to the current instance of AlienInvasion. This is the parameter that gives Ship access to the game’s resources, such as the screen object.
2 After filling the background, we draw the ship on the screen by calling ship.blitme(), so the ship appears on top of the background.

When you run alien_invasion.py now, you should see an empty game screen with the rocket ship sitting at the bottom center of the screen.

Refactoring: The _check_events() and _update_screen() Methods

In large projects, you’ll often refactor code you’ve written before adding more code. Refactoring simplifies the structure of the code you’ve already written, making it easier to build on. In this section, we’ll break the run_game() method, which is getting lengthy, into two helper methods. A helper method does work inside a class but isn’t meant to be used by code outside the class. In Python, a single leading underscore indicates a helper method.

The _check_events() Method

We’ll move the code that manages events to a separate method called _check_events(). This will simplify run_game() and isolate the event management loop. Isolating the event loop allows you to manage events separately from other aspects of the game, such as updating the screen.

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

        # Redraw the screen during each pass through the loop.
        # --snip--

def _check_events(self):            (2)
    """Respond to keypresses and mouse events."""
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            sys.exit()
1 We call the new method from inside the while loop in run_game(). To call a method from within a class, use dot notation with the variable self and the name of the method.
2 We make a new _check_events() method and move the lines that check whether the player has clicked to close the window into this new method.

The _update_screen() Method

To further simplify run_game(), we’ll move the code for updating the screen to a separate method called _update_screen():

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

def _check_events(self):
    # --snip--

def _update_screen(self):
    """Update images on the screen, and flip to the new screen."""
    self.screen.fill(self.settings.bg_color)
    self.ship.blitme()

    pygame.display.flip()

We moved the code that draws the background and the ship and flips the screen to _update_screen(). Now the body of the main loop in run_game() is much simpler. It’s easy to see that we’re looking for new events, updating the screen, and ticking the clock on each pass through the loop.

If you’ve already built a number of games, you’ll probably start out by breaking your code into methods like these. But if you’ve never tackled a project like this, you probably won’t know exactly how to structure your code at first. This approach gives you an idea of a realistic development process: you start out writing your code as simply as possible, and then refactor it as your project becomes more complex.

Try It Yourself

12-1. Blue Sky: Make a Pygame window with a blue background.

12-2. Game Character: Find a bitmap image of a game character you like or convert an image to a bitmap. Make a class that draws the character at the center of the screen, then match the background color of the image to the background color of the screen or vice versa.

Piloting the Ship

Next, we’ll give the player the ability to move the ship right and left. We’ll write code that responds when the player presses the right or left arrow key. We’ll focus first on movement to the right, and then we’ll apply the same principles to control movement to the left. As we add this code, you’ll learn how to control the movement of images on the screen and respond to user input.

Responding to a Keypress

Whenever the player presses a key, that keypress is registered in Pygame as an event. Each event is picked up by the pygame.event.get() method. We need to specify in our _check_events() method what kinds of events we want the game to check for. Each keypress is registered as a KEYDOWN event.

When Pygame detects a KEYDOWN event, we need to check whether the key that was pressed is one that triggers a certain action. For example, if the player presses the right arrow key, we want to increase the ship’s rect.x value to move the ship to the right:

alien_invasion.py
def _check_events(self):
    """Respond to keypresses and mouse events."""
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            sys.exit()
        elif event.type == pygame.KEYDOWN:    (1)
            if event.key == pygame.K_RIGHT:   (2)
                # Move the ship to the right.
                self.ship.rect.x += 1         (3)
1 Inside _check_events() we add an elif block to the event loop, to respond when Pygame detects a KEYDOWN event.
2 We check whether the key pressed, event.key, is the right arrow key. The right arrow key is represented by pygame.K_RIGHT.
3 If the right arrow key was pressed, we move the ship to the right by increasing the value of self.ship.rect.x by 1.

When you run alien_invasion.py now, the ship should move to the right one pixel every time you press the right arrow key. That’s a start, but it’s not an efficient way to control the ship. Let’s improve this control by allowing continuous movement.

Allowing Continuous Movement

When the player holds down the right arrow key, we want the ship to continue moving right until the player releases the key. We’ll have the game detect a pygame.KEYUP event so we’ll know when the right arrow key is released; then we’ll use the KEYDOWN and KEYUP events together with a flag called moving_right to implement continuous motion.

When the moving_right flag is False, the ship will be motionless. When the player presses the right arrow key, we’ll set the flag to True, and when the player releases the key, we’ll set the flag to False again.

The Ship class controls all attributes of the ship, so we’ll give it an attribute called moving_right and an update() method to check the status of the moving_right flag. The update() method will change the position of the ship if the flag is set to True. We’ll call this method once on each pass through the while loop to update the position of the ship.

Here are the changes to Ship:

ship.py
class Ship:
    """A class to manage the ship."""

    def __init__(self, ai_game):
        # --snip--
        # Start each new ship at the bottom center of the screen.
        self.rect.midbottom = self.screen_rect.midbottom

        # Movement flag; start with a ship that's not moving.
        self.moving_right = False    (1)

    def update(self):                (2)
        """Update the ship's position based on the movement flag."""
        if self.moving_right:
            self.rect.x += 1

    def blitme(self):
        # --snip--
1 We add a self.moving_right attribute in the init() method and set it to False initially.
2 We add update(), which moves the ship right if the flag is True. The update() method will be called from outside the class, so it’s not considered a helper method.

Now we need to modify _check_events() so that moving_right is set to True when the right arrow key is pressed and False when the key is released:

alien_invasion.py
def _check_events(self):
    """Respond to keypresses and mouse events."""
    for event in pygame.event.get():
        # --snip--
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_RIGHT:
                self.ship.moving_right = True      (1)
        elif event.type == pygame.KEYUP:           (2)
            if event.key == pygame.K_RIGHT:
                self.ship.moving_right = False
1 Instead of changing the ship’s position directly, we merely set moving_right to True when the player presses the right arrow key.
2 We add a new elif block, which responds to KEYUP events. When the player releases the right arrow key (K_RIGHT), we set moving_right to False.

Next, we modify the while loop in run_game() so it calls the ship’s update() method on each pass through the loop:

alien_invasion.py
def run_game(self):
    """Start the main loop for the game."""
    while True:
        self._check_events()
        self.ship.update()
        self._update_screen()
        self.clock.tick(60)

The ship’s position will be updated after we’ve checked for keyboard events and before we update the screen. This allows the ship’s position to be updated in response to player input and ensures the updated position will be used when drawing the ship to the screen.

Moving Both Left and Right

Now that the ship can move continuously to the right, adding movement to the left is straightforward. Again, we’ll modify the Ship class and the _check_events() method. Here are the relevant changes to init() and update() in Ship:

ship.py
def __init__(self, ai_game):
    # --snip--
    # Movement flags; start with a ship that's not moving.
    self.moving_right = False
    self.moving_left = False

def update(self):
    """Update the ship's position based on movement flags."""
    if self.moving_right:
        self.rect.x += 1
    if self.moving_left:
        self.rect.x -= 1

In init(), we add a self.moving_left flag. In update(), we use two separate if blocks, rather than an elif, to allow the ship’s rect.x value to be increased and then decreased when both arrow keys are held down. This results in the ship standing still. If we used elif for motion to the left, the right arrow key would always have priority. Using two if blocks makes the movements more accurate when the player might momentarily hold down both keys when changing directions.

We have to make two additions to _check_events():

alien_invasion.py
def _check_events(self):
    """Respond to keypresses and mouse events."""
    for event in pygame.event.get():
        # --snip--
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_RIGHT:
                self.ship.moving_right = True
            elif event.key == pygame.K_LEFT:
                self.ship.moving_left = True

        elif event.type == pygame.KEYUP:
            if event.key == pygame.K_RIGHT:
                self.ship.moving_right = False
            elif event.key == pygame.K_LEFT:
                self.ship.moving_left = False

If a KEYDOWN event occurs for the K_LEFT key, we set moving_left to True. If a KEYUP event occurs for the K_LEFT key, we set moving_left to False.

Adjusting the Ship’s Speed

Currently, the ship moves one pixel per cycle through the while loop, but we can take finer control of the ship’s speed by adding a ship_speed attribute to the Settings class. We’ll use this attribute to determine how far to move the ship on each pass through the loop:

settings.py
class Settings:
    """A class to store all settings for Alien Invasion."""

    def __init__(self):
        # --snip--

        # Ship settings
        self.ship_speed = 1.5

We set the initial value of ship_speed to 1.5. When the ship moves now, its position is adjusted by 1.5 pixels on each pass through the loop.

We’re using a float for the speed setting to give us finer control of the ship’s speed when we increase the tempo of the game later on. However, rect attributes such as x store only integer values, so we need to make some modifications to Ship:

ship.py
class Ship:
    """A class to manage the ship."""

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

        # Start each new ship at the bottom center of the screen.
        self.rect.midbottom = self.screen_rect.midbottom

        # Store a float for the ship's exact horizontal position.
        self.x = float(self.rect.x)        (2)

        # Movement flags; start with a ship that's not moving.
        self.moving_right = False
        self.moving_left = False

    def update(self):
        """Update the ship's position based on movement flags."""
        # Update the ship's x value, not the rect.
        if self.moving_right:
            self.x += self.settings.ship_speed   (3)
        if self.moving_left:
            self.x -= self.settings.ship_speed

        # Update rect object from self.x.
        self.rect.x = self.x                     (4)

    def blitme(self):
        # --snip--
1 We create a settings attribute for Ship, so we can use it in update().
2 Because we’re adjusting the position of the ship by fractions of a pixel, we need to assign the position to a variable that can store a float. To keep track of the ship’s position accurately, we define a new self.x attribute using the float() function to convert the value of self.rect.x.
3 When we change the ship’s position in update(), the value of self.x is adjusted by the amount stored in settings.ship_speed.
4 After self.x has been updated, we use the new value to update self.rect.x, which controls the position of the ship. Only the integer portion of self.x will be assigned to self.rect.x, but that’s fine for displaying the ship.

Limiting the Ship’s Range

At this point, the ship will disappear off either edge of the screen if you hold down an arrow key long enough. Let’s correct this so the ship stops moving when it reaches the screen’s edge. We do this by modifying the update() method in Ship:

ship.py
def update(self):
    """Update the ship's position based on movement flags."""
    # Update the ship's x value, not the rect.
    if self.moving_right and self.rect.right < self.screen_rect.right:  (1)
        self.x += self.settings.ship_speed
    if self.moving_left and self.rect.left > 0:                         (2)
        self.x -= self.settings.ship_speed

    # Update rect object from self.x.
    self.rect.x = self.x
1 The code self.rect.right returns the x-coordinate of the right edge of the ship’s rect. If this value is less than the value returned by self.screen_rect.right, the ship hasn’t reached the right edge of the screen.
2 If the value of the left side of the rect is greater than 0, the ship hasn’t reached the left edge of the screen. This ensures the ship is within these bounds before adjusting the value of self.x.

Refactoring _check_events()

The _check_events() method will increase in length as we continue to develop the game, so let’s break _check_events() into two separate methods: one that handles KEYDOWN events and another that handles KEYUP events:

alien_invasion.py
def _check_events(self):
    """Respond to keypresses and mouse events."""
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            sys.exit()
        elif event.type == pygame.KEYDOWN:
            self._check_keydown_events(event)
        elif event.type == pygame.KEYUP:
            self._check_keyup_events(event)

def _check_keydown_events(self, event):
    """Respond to keypresses."""
    if event.key == pygame.K_RIGHT:
        self.ship.moving_right = True
    elif event.key == pygame.K_LEFT:
        self.ship.moving_left = True

def _check_keyup_events(self, event):
    """Respond to key releases."""
    if event.key == pygame.K_RIGHT:
        self.ship.moving_right = False
    elif event.key == pygame.K_LEFT:
        self.ship.moving_left = False

We make two new helper methods: _check_keydown_events() and _check_keyup_events(). Each needs a self parameter and an event parameter. The bodies of these two methods are copied from _check_events(), and we’ve replaced the old code with calls to the new methods. The _check_events() method is simpler now with this cleaner code structure, which will make it easier to develop further responses to player input.

Pressing Q to Quit

Now that we’re responding to keypresses efficiently, we can add another way to quit the game. It gets tedious to click the X at the top of the game window to end the game every time you test a new feature, so we’ll add a keyboard shortcut to end the game when the player presses Q:

alien_invasion.py
def _check_keydown_events(self, event):
    # --snip--
    elif event.key == pygame.K_LEFT:
        self.ship.moving_left = True
    elif event.key == pygame.K_q:
        sys.exit()

In _check_keydown_events(), we add a new block that ends the game when the player presses Q. Now, when testing, you can press Q to close the game instead of using your cursor to close the window.

Running the Game in Fullscreen Mode

Pygame has a fullscreen mode that you might like better than running the game in a regular window. Some games look better in fullscreen mode, and on some systems, the game may perform better overall in fullscreen mode.

To run the game in fullscreen mode, make the following changes in init():

alien_invasion.py
def __init__(self):
    """Initialize the game, and create game resources."""
    pygame.init()
    self.settings = Settings()

    self.screen = pygame.display.set_mode((0, 0), pygame.FULLSCREEN)   (1)
    self.settings.screen_width = self.screen.get_rect().width          (2)
    self.settings.screen_height = self.screen.get_rect().height
    pygame.display.set_caption("Alien Invasion")
1 When creating the screen surface, we pass a size of (0, 0) and the parameter pygame.FULLSCREEN. This tells Pygame to figure out a window size that will fill the screen.
2 Because we don’t know the width and height of the screen ahead of time, we update these settings after the screen is created. We use the width and height attributes of the screen’s rect to update the settings object.

Make sure you can quit by pressing Q before running the game in fullscreen mode; Pygame offers no default way to quit a game while in fullscreen mode.

A Quick Recap

In the next section, we’ll add the ability to shoot bullets, which involves adding a new file called bullet.py and making some modifications to some of the files we’re already using. Right now, we have three files containing a number of classes and methods. Let’s review each of these files before adding more functionality.

alien_invasion.py

The main file, alien_invasion.py, contains the AlienInvasion class. This class creates a number of important attributes used throughout the game: the settings are assigned to settings, the main display surface is assigned to screen, and a ship instance is created in this file as well. The main loop of the game, a while loop, is also stored in this module. The while loop calls _check_events(), ship.update(), and _update_screen(). It also ticks the clock on each pass through the loop.

The _check_events() method detects relevant events, such as keypresses and releases, and processes each of these types of events through the methods _check_keydown_events() and _check_keyup_events(). For now, these methods manage the ship’s movement. The AlienInvasion class also contains _update_screen(), which redraws the screen on each pass through the main loop.

The alien_invasion.py file is the only file you need to run when you want to play Alien Invasion. The other files, settings.py and ship.py, contain code that is imported into this file.

settings.py

The settings.py file contains the Settings class. This class only has an init() method, which initializes attributes controlling the game’s appearance and the ship’s speed.

ship.py

The ship.py file contains the Ship class. The Ship class has an init() method, an update() method to manage the ship’s position, and a blitme() method to draw the ship to the screen. The image of the ship is stored in ship.bmp, which is in the images folder.

Try It Yourself

12-3. Pygame Documentation: We’re far enough into the game now that you might want to look at some of the Pygame documentation. The Pygame home page is at pygame.org, and the home page for the documentation is at pygame.org/docs. Just skim the documentation for now. You won’t need it to complete this project, but it will help if you want to modify Alien Invasion or make your own game afterward.

12-4. Rocket: Make a game that begins with a rocket in the center of the screen. Allow the player to move the rocket up, down, left, or right using the four arrow keys. Make sure the rocket never moves beyond any edge of the screen.

12-5. Keys: Make a Pygame file that creates an empty screen. In the event loop, print the event.key attribute whenever a pygame.KEYDOWN event is detected. Run the program and press various keys to see how Pygame responds.

Shooting Bullets

Now let’s add the ability to shoot bullets. We’ll write code that fires a bullet, which is represented by a small rectangle, when the player presses the spacebar. Bullets will then travel straight up the screen until they disappear off the top of the screen.

Adding the Bullet Settings

At the end of the init() method, we’ll update settings.py to include the values we’ll need for a new Bullet class:

settings.py
def __init__(self):
    # --snip--
    # Bullet settings
    self.bullet_speed = 2.0
    self.bullet_width = 3
    self.bullet_height = 15
    self.bullet_color = (60, 60, 60)

These settings create dark gray bullets with a width of 3 pixels and a height of 15 pixels. The bullets will travel slightly faster than the ship.

Creating the Bullet Class

Now create a bullet.py file to store our Bullet class. Here’s the first part of bullet.py:

bullet.py
import pygame
from pygame.sprite import Sprite

class Bullet(Sprite):
    """A class to manage bullets fired from the ship."""

    def __init__(self, ai_game):
        """Create a bullet object at the ship's current position."""
        super().__init__()
        self.screen = ai_game.screen
        self.settings = ai_game.settings
        self.color = self.settings.bullet_color

        # Create a bullet rect at (0, 0) and then set correct position.
        self.rect = pygame.Rect(0, 0, self.settings.bullet_width,   (1)
            self.settings.bullet_height)
        self.rect.midtop = ai_game.ship.rect.midtop                 (2)

        # Store the bullet's position as a float.
        self.y = float(self.rect.y)                                 (3)
1 The bullet isn’t based on an image, so we have to build a rect from scratch using the pygame.Rect() class. This class requires the x- and y-coordinates of the top-left corner of the rect, and the width and height of the rect. We initialize the rect at (0, 0), but we’ll move it to the correct location in the next line.
2 We set the bullet’s midtop attribute to match the ship’s midtop attribute. This will make the bullet emerge from the top of the ship, making it look like the bullet is fired from the ship.
3 We use a float for the bullet’s y-coordinate so we can make fine adjustments to the bullet’s speed.

Here’s the second part of bullet.py, update() and draw_bullet():

bullet.py
def update(self):
    """Move the bullet up the screen."""
    # Update the exact position of the bullet.
    self.y -= self.settings.bullet_speed   (1)
    # Update the rect position.
    self.rect.y = self.y                   (2)

def draw_bullet(self):
    """Draw the bullet to the screen."""
    pygame.draw.rect(self.screen, self.color, self.rect)   (3)
1 When a bullet is fired, it moves up the screen, which corresponds to a decreasing y-coordinate value. To update the position, we subtract the amount stored in settings.bullet_speed from self.y.
2 We then use the value of self.y to set the value of self.rect.y.
3 When we want to draw a bullet, we call draw_bullet(). The draw.rect() function fills the part of the screen defined by the bullet’s rect with the color stored in self.color.

Storing Bullets in a Group

Now that we have a Bullet class and the necessary settings defined, we can write code to fire a bullet each time the player presses the spacebar. We’ll create a group in AlienInvasion to store all the active bullets so we can manage the bullets that have already been fired. This group will be an instance of the pygame.sprite.Group class, which behaves like a list with some extra functionality that’s helpful when building games.

First, we’ll import the new Bullet class:

alien_invasion.py
# --snip--
from ship import Ship
from bullet import Bullet

Next we’ll create the group that holds the bullets in init():

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

Then we need to update the position of the bullets on each pass through the while loop:

alien_invasion.py
def run_game(self):
    """Start the main loop for the game."""
    while True:
        self._check_events()
        self.ship.update()
        self.bullets.update()
        self._update_screen()
        self.clock.tick(60)

When you call update() on a group, the group automatically calls update() for each sprite in the group. The line self.bullets.update() calls bullet.update() for each bullet we place in the group bullets.

Firing Bullets

In AlienInvasion, we need to modify _check_keydown_events() to fire a bullet when the player presses the spacebar. We also need to modify _update_screen() to make sure each bullet is drawn to the screen before we call flip().

There will be a bit of work to do when we fire a bullet, so let’s write a new method, _fire_bullet(), to handle this work:

alien_invasion.py
def _check_keydown_events(self, event):
    # --snip--
    elif event.key == pygame.K_q:
        sys.exit()
    elif event.key == pygame.K_SPACE:          (1)
        self._fire_bullet()

def _check_keyup_events(self, event):
    # --snip--

def _fire_bullet(self):
    """Create a new bullet and add it to the bullets group."""
    new_bullet = Bullet(self)                  (2)
    self.bullets.add(new_bullet)               (3)

def _update_screen(self):
    """Update images on the screen, and flip to the new screen."""
    self.screen.fill(self.settings.bg_color)
    for bullet in self.bullets.sprites():      (4)
        bullet.draw_bullet()
    self.ship.blitme()

    pygame.display.flip()
# --snip--
1 We call _fire_bullet() when the spacebar is pressed.
2 In _fire_bullet(), we make an instance of Bullet and call it new_bullet.
3 We then add it to the group bullets using the add() method. The add() method is similar to append(), but it’s written specifically for Pygame groups.
4 The bullets.sprites() method returns a list of all sprites in the group bullets. To draw all fired bullets to the screen, we loop through the sprites in bullets and call draw_bullet() on each one. We place this loop before the line that draws the ship, so the bullets don’t start out on top of the ship.

When you run alien_invasion.py now, you should be able to move the ship right and left and fire as many bullets as you want. The bullets travel up the screen and disappear when they reach the top.

Deleting Old Bullets

At the moment, the bullets disappear when they reach the top, but only because Pygame can’t draw them above the top of the screen. The bullets actually continue to exist; their y-coordinate values just grow increasingly negative. This is a problem because they continue to consume memory and processing power.

We need to get rid of these old bullets, or the game will slow down from doing so much unnecessary work. To do this, we need to detect when the bottom value of a bullet’s rect has a value of 0, which indicates the bullet has passed off the top of the screen:

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

        # Get rid of bullets that have disappeared.
        for bullet in self.bullets.copy():      (1)
            if bullet.rect.bottom <= 0:         (2)
                self.bullets.remove(bullet)     (3)
        print(len(self.bullets))                (4)

        self._update_screen()
        self.clock.tick(60)
1 When you use a for loop with a list (or a group in Pygame), Python expects that the list will stay the same length as long as the loop is running. That means you can’t remove items from a list or group within a for loop, so we have to loop over a copy of the group. We use the copy() method to set up the for loop, which leaves us free to modify the original bullets group inside the loop.
2 We check each bullet to see whether it has disappeared off the top of the screen.
3 If it has, we remove it from bullets.
4 We insert a print() call to show how many bullets currently exist in the game and verify they’re being deleted when they reach the top of the screen.

If this code works correctly, we can watch the terminal output while firing bullets and see that the number of bullets decreases to zero after each series of bullets has cleared the top of the screen. After you run the game and verify that bullets are being deleted properly, remove the print() call. If you leave it in, the game will slow down significantly because it takes more time to write output to the terminal than it does to draw graphics to the game window.

Limiting the Number of Bullets

Many shooting games limit the number of bullets a player can have on the screen at one time; doing so encourages players to shoot accurately. We’ll do the same in Alien Invasion.

First, store the number of bullets allowed in settings.py:

settings.py
    # Bullet settings
    # --snip--
    self.bullet_color = (60, 60, 60)
    self.bullets_allowed = 3

This limits the player to three bullets at a time. We’ll use this setting in AlienInvasion to check how many bullets exist before creating a new bullet in _fire_bullet():

alien_invasion.py
def _fire_bullet(self):
    """Create a new bullet and add it to the bullets group."""
    if len(self.bullets) < self.settings.bullets_allowed:
        new_bullet = Bullet(self)
        self.bullets.add(new_bullet)

When the player presses the spacebar, we check the length of bullets. If len(self.bullets) is less than three, we create a new bullet. But if three bullets are already active, nothing happens when the spacebar is pressed. When you run the game now, you should only be able to fire bullets in groups of three.

Creating the _update_bullets() Method

We want to keep the AlienInvasion class reasonably well organized, so now that we’ve written and checked the bullet management code, we can move it to a separate method. We’ll create a new method called _update_bullets() and add it just before _update_screen():

alien_invasion.py
def _update_bullets(self):
    """Update position of bullets and get rid of old bullets."""
    # Update bullet positions.
    self.bullets.update()

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

The code for _update_bullets() is cut and pasted from run_game(); all we’ve done here is clarify the comments.

The while loop in run_game() looks simple again:

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

Now our main loop contains only minimal code, so we can quickly read the method names and understand what’s happening in the game. The main loop checks for player input, and then updates the position of the ship and any bullets that have been fired. We then use the updated positions to draw a new screen and tick the clock at the end of each pass through the loop.

Try It Yourself

12-6. Sideways Shooter: Write a game that places a ship on the left side of the screen and allows the player to move the ship up and down. Make the ship fire a bullet that travels right across the screen when the player presses the spacebar. Make sure bullets are deleted once they disappear off the screen.

Summary

In this chapter, you learned to make a plan for a game and learned the basic structure of a game written in Pygame. You learned to set a background color and store settings in a separate class where you can adjust them more easily. You saw how to draw an image to the screen and give the player control over the movement of game elements. You created elements that move on their own, like bullets flying up a screen, and you deleted objects that are no longer needed. You also learned to refactor code in a project on a regular basis to facilitate ongoing development.

In Chapter 13, we’ll add aliens to Alien Invasion. By the end of the chapter, you’ll be able to shoot down aliens, hopefully before they reach your ship!

Applied Exercises: Ch 12 — A Ship That Fires Bullets

These exercises extend the chapter concepts without requiring Pygame. They focus on the underlying logic — state management, movement flags, speed with floats, bounding, and object lifecycle — patterns directly applicable to your infrastructure tooling.

Domus Digitalis / Homelab

D12-1. Node Movement Simulator: Without Pygame, write a class called NodeCursor that represents a selection cursor moving through a list of node hostnames. Give it moving_forward and moving_backward boolean flags, a position integer starting at 0, and an update() method that increments or decrements the position. Add boundary logic so position never goes below 0 or above len(nodes) - 1. Write a loop that simulates 10 update cycles and prints the current node at each step.

D12-2. Service Packet Simulator: Write a class called Packet that represents a log packet in transit through a pipeline stage. Give it a y float position starting at 100.0, a speed float, and an update() method that decrements y by speed each cycle. Write a loop that updates the packet until y reaches 0 or below, printing the position each step. Count how many cycles it took.

D12-3. VLAN Firewall Bounds: Write a class called VLANTraversal with a current_vlan integer and a vlan_range tuple (min_id, max_id). Add move_up() and move_down() methods that increment or decrement current_vlan only if within the range. Write a settings dictionary with traversal_speed = 1 and use it in the methods. Test by simulating 15 traversal steps with both directions.

D12-4. Stack Deploy Queue: Write a class called DeployQueue with a pending list and a deployed list. Add a method fire_deploy() that pops from pending and appends to deployed, but only if len(deployed) < max_concurrent (store max_concurrent in a settings dict). Add a method cleanup_deployed() that removes all items from deployed once they’ve been processed (simulate with a flag). Test with a queue of 6 services and a limit of 3.

D12-5. BGP Event Loop Refactor: Write a class called BGPMonitor with a _check_events() method that reads from a list of simulated events (strings like 'peer_up', 'peer_down', 'quit'), a _update_state() method that updates a peer_status dict, and a run() method that calls both in a loop until 'quit' is encountered. Demonstrate the single-responsibility refactoring pattern from the chapter.

CHLA / ISE / Network Security

C12-1. Auth Request Movement: Write a class called AuthRequest with a stage integer (0 = received, max = authorized), moving_forward and moving_backward flags, and an update() method that advances or retreats the stage based on flags with boundary enforcement. Use a settings dict with stage_speed = 1. Simulate 8 update cycles.

C12-2. Syslog Packet Lifecycle: Write a class called SyslogPacket with a y float position, a speed float, and a boolean active flag. The update() method should decrement y by speed; when y ⇐ 0, set active = False. Write a loop that creates 5 packets at staggered positions, updates them each cycle, removes inactive ones (using a copy loop), and prints the count of active packets per cycle.

C12-3. Policy Evaluation Bounds: Write a class called PolicyEvaluator with a current_policy integer index into a policy_list. Add move_next() and move_prev() methods with boundary enforcement. Add a settings dict with eval_speed = 1. Add a fire_evaluation() method that creates an evaluation record if len(active_evals) < max_evals. Simulate 10 evaluation cycles.

C12-4. Pipeline Stage Manager: Write a class called PipelineManager with a stages list and active_stages list. Add _fire_stage() that adds a stage to active_stages only if below max_concurrent. Add _update_stages() that decrements a progress counter on each active stage and removes it when complete (simulate with a counter starting at 3). Add a run() method that calls both each cycle for 8 cycles.

C12-5. ISE Event Loop Pattern: Write a class called ISEEventLoop with _check_keydown_events(event), _check_keyup_events(event), and _update_screen() as separate methods. Simulate with a list of dicts like {'type': 'KEYDOWN', 'key': 'AUTH'}. The _check_events() method should dispatch to the appropriate handler. The run() method should call _check_events(), update state, and call _update_screen() each cycle for 6 cycles.

General Sysadmin / Linux

L12-1. Service Cursor: Write a class called ServiceSelector that moves through a list of service names. Give it moving_up and moving_down boolean flags, a position integer, and an update() method with boundary enforcement. Use a settings dict with cursor_speed = 1. Simulate 12 update cycles alternating direction.

L12-2. Log Line Processor: Write a class called LogLine with a progress float starting at 0.0, a speed float, and an active flag. The update() method increments progress by speed; when progress >= 100.0, set active = False. Create 5 log line objects with different speeds, update them in a loop, remove inactive ones using a copy loop, and print the count each cycle until all are done.

L12-3. Filesystem Traversal Bounds: Write a class called FsTraversal with a depth integer and a max_depth setting. Add descend() and ascend() methods that change depth with boundary enforcement (0 to max_depth). Add a settings dict. Simulate 10 traversal steps and print the current depth each time.

L12-4. Package Install Queue: Write a class called InstallQueue with a pending list and an installing list. Add _fire_install() that moves a package from pending to installing only if len(installing) < max_concurrent. Add _update_installs() that removes completed installs (simulate with a countdown). Add a run() method for 8 cycles.

L12-5. Sysadmin Event Dispatcher: Write a class called SysadminMonitor modeled on the refactored AlienInvasion structure. Implement _check_events(event), _check_keydown(event), _check_keyup(event), and _update_state() as separate methods. Use a list of simulated event dicts. The run() method calls all methods each cycle for 6 cycles and prints the current state.

Spanish / DELE C2

E12-1. Vocabulary Navigator: Write a class called VocabNavigator that moves through a list of Spanish vocabulary words. Give it moving_forward and moving_backward boolean flags, a position integer, and an update() method with boundary enforcement. Use a settings dict with nav_speed = 1. Simulate 10 update cycles and print the current word each step.

E12-2. Study Session Timer: Write a class called StudyTimer with a time_remaining float, a speed float (minutes per cycle), and an active flag. The update() method decrements time_remaining by speed; when time_remaining ⇐ 0, set active = False. Simulate with 3 study topics at different durations. Update them in a loop, remove completed ones using a copy loop, and print the count each cycle.

E12-3. Chapter Bounds: Write a class called ChapterNavigator with a current_chapter integer (1–126 for Don Quijote). Add next_chapter() and prev_chapter() methods with boundary enforcement. Use a settings dict with nav_speed = 1. Simulate 15 navigation steps and print the chapter number each time.

E12-4. DELE Exercise Queue: Write a class called ExerciseQueue with a pending list and an active list. Add _fire_exercise() that moves an exercise from pending to active only if len(active) < max_active. Add _update_exercises() that removes completed exercises (simulate with a countdown). Add a run() method for 8 cycles.

E12-5. Study Event Loop: Write a class called StudySession modeled on the refactored game event loop. Implement _check_events(event), _check_start_events(event), _check_stop_events(event), and _update_progress() as separate methods. Use a list of simulated study events. The run() method calls all methods each cycle for 6 cycles and prints the current progress state.